# WarpBuild > WarpBuild provides GitHub Actions infrastructure: hosted and bring-your-own-cloud runners for Linux x64, Linux ARM64, macOS, and Windows, plus caching, remote Docker builders, observability, and developer tooling. Use the documentation for setup guidance and the official structured data feeds for current product, pricing, and comparison records. --- # WarpBuild - 2x faster, 50% cheaper Github Actions runners URL: https://www.warpbuild.com/ Description: WarpBuild is a drop in replacement for Github runners that are 2x faster and 50% cheaper. Get light speed builds and light weight bills in 30 seconds. # Fast CI for humans and agents. High-performance GitHub Actions runners - 2x faster, 50% cheaper. Drop-in replacement for your team and its agents. Zero config. [Get Started Free](https://app.warpbuild.com)[Schedule Demo](https://cal.com/suryao/start) No credit card required · 2-minute setup [Remote Docker buildersBuild and push Docker images with blazing fast remote builders](/docs/ci/docker-builders)[Managed or in your cloudDeploy in our managed cloud or your own secure infrastructure](/docs/ci/byoc)[MacOS, Windows, and LinuxFull compatibility across all major operating systems](/docs/ci/cloud-runners#macos-m4-pro-on-arm64)[x86-64 and ARM64 supportSupport for both x86-64 and ARM64 architectures](/docs/ci/cloud-runners#linux-arm64)[Unlimited concurrencyScale infinitely with no limits on parallel jobs](/docs/ci/cloud-runners#concurrency)[Get Started in MinutesOne-line change to your workflow file. No complex setup or migration required.](https://app.warpbuild.com) ![Sonar](/images/customers/sonarqube.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Comcast](/images/customers/comcast.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Sky](/_next/image?url=%2Fimages%2Fcustomers%2Fsky-uk.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Braintrust](/images/customers/braintrust.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Adaptive](/_next/image?url=%2Fimages%2Fcustomers%2Fadaptive.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![OxCaml](/images/customers/oxcaml.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![DDEV](/images/customers/ddev.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Kintsugi](/images/customers/kintsugi-ai.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Bitcoin](/images/customers/bitcoin.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![LanceDB](/_next/image?url=%2Fimages%2Fcustomers%2Flancedb.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) //WarpBuild CI ## Blazing Fast Github Actions Runners 2x faster builds with optimized runners, caching, and incremental builds. Fully maintenance free. HighlightsOn AWS, GCP, AzureDisk SnapshotsQuickstart ### Flexible Deployment Managed cloud or your own AWS, GCP, Azure VPC. ![Flexible Deployment](/images/byoc-all-cloud.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Secure Ephemeral VMs, complete isolation, no code access. ![Secure](/images/secure.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Built for scale Unlimited job concurrency, fast local caching, and tools designed to support collaboration for 1000s of developers. ![Built for scale](/images/scale.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Incremental Builds Reduce build times and improve efficiency with full disk snapshots. ![Incremental Builds](/images/snap.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Unlimited concurrency Scale to thousands of parallel jobs per workflow. ![Unlimited concurrency](/images/concurrency.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Blazing fast caching 3-10x faster caching for artifacts and container layers. ![Blazing fast caching](/images/ci-cache.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Linux, MacOS, Windows Support for all OSes and architectures ![Linux, MacOS, Windows](/images/osses.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Static IPs (BYOC Only) Setup static IPs for security sensitive runner allowlists. Available with Bring Your Own Cloud deployments. ![Static IPs (BYOC Only)](/images/static-ip.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) //CI in the AI era ## Your agents ship faster than your CI AI made writing code cheap. It did nothing to make building and testing it cheap. The bottleneck moved from the keyboard to the runner queue. [CI built for AI velocity](/ai) 01 ### Agents write more code Coding assistants turn ideas into review-ready branches in minutes. Output per engineer goes up; the number of changes in flight goes up with it. 02 ### Every change is a pipeline Each PR, each push, each rebase and retry fans out into the full build and test matrix. Velocity upstream lands as load downstream. 03 ### CI becomes the queue Agents don't stop at 6pm. When runner capacity is capped, work waits on compute instead of on people, and the feedback loop stretches. 04 ### The bill tracks the agents Minutes billed at GitHub-hosted rates now scale with how much code you generate, not with how many engineers you hired. 2xfaster builds on every runner 50%lower cost per minute 0queue time with instant autoscaling [See how WarpBuild keeps up](/ai) //Enterprise ## Enterprise ready by design, battle-tested at scale Connect only the products you need. Enterprise grade security, compliance, and support. [Schedule a Call](https://cal.com/suryao/start) ![Bring Your Own Cloud](/images/home-cloud.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Bring Your Own Cloud Data never leaves your perimeter with BYOC deployments on AWS, GCP, Azure. ![Data Residency](/images/home-data.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Data Residency Pin any data you choose to a specific region and eliminate cloud ingress and egress costs with [region-specific infrastructure](/products/region-specific-infrastructure). ![SOC2 Type 2 compliant](/images/soc2.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### SOC2 Type 2 compliant Read more about our [security practices](/docs/ci/security) and our [SOC2 report](https://trust.warpbuild.com/) ![24x7 Support](/images/home-sup.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### 24x7 Support 24x7 support and SLA guarantees for uninterrupted operations. ![No LLM Data Retention](/images/home-data.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### No LLM Data Retention Zero data retention agreement with LLM providers so your data is never used for training models. ![SSO and Access Controls](/images/home-key.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### SSO and Access Controls Connect with your IDP for easy user management and controls. ## What Our CI Customers Say Run times went down from \~25 mins to \~9 mins Even bazel cache load time was faster by 30% Wow. These macOS runners are a LOT faster than GiHub's. Previous builds would take 30 mins, now they're done in 8! Going from GitHub standard runners to your cheapest runners will save 30-40% time off the average workflow at half the price, and the workflow I care most about is staying the same price but 60% time savings. Thanks to Warp we're building our nextjs landing page with Github Actions (for Cloudflare Pages) faster than Vercel's CI/CD I get to save the company money and save us on build times. Making me look good to my management team. I gotta say, of like 4-5 providers we've tried, your support has been incredible and has really made the difference for us. Tried out pretty much every provider in the market, and this one works the best out of the box Building and packaging our #rust library for iOS and Android used to take half an hour in Cl. Switched it to @WarpBuilds MacOS runners and it's down to 8 mins. Plus it's cheaper! ## Ship faster with WarpBuild. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # About WarpBuild - The Team Building Faster CI/CD | WarpBuild URL: https://www.warpbuild.com/about Description: Meet the WarpBuild team - engineers passionate about developer tools and infrastructure. Backed by Y Combinator, we're building the future of fast, reliable CI/CD. ![Globe Grid Background](/images/globe-grid.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![WarpBuild Logo](/images/logo.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) Building the future of cloud infrastructure WarpBuild enables fast development iterations for engineering teams by providing a complete set of tools and workflows that make building, iterating, and deploying code fast, reliable, and delightful. CI workflows today are slow, interrupt the flow of developers, and opaque. WarpBuild is the solution. We are a team of engineers, building together for years. We share a passion for developer tools and infrastructure, and to make something people want. We are backed by some of the best tech investors who are amazing supporters of builders working on big, ambitious ideas. Backed by ![Y Combinator](/images/yc.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) --- # CI Built for the Pace of AI | WarpBuild | WarpBuild URL: https://www.warpbuild.com/ai Description: AI agents ship code faster than CI can build it. WarpBuild runs GitHub Actions 2x faster at half the cost, with instant autoscaling, unlimited caching, and no queues. //CI for AI-native engineering teams # Your agents ship faster than your CI. AI made writing code cheap. It did nothing to make building and testing it cheap. WarpBuild runs GitHub Actions 2x faster at half the cost, with instant autoscaling so the queue never becomes the bottleneck. [Get Started Free](https://app.warpbuild.com)[Schedule Demo](https://cal.com/suryao/start) One-line change · No credit card required //The problem ## Developer velocity went up. CI capacity didn't. The constraint on shipping used to be how fast people could write code. With assistants and agents in the loop, it is how fast your pipelines can validate it. ### Code generation outran code validation Assistants and autonomous agents produce review-ready branches in minutes. Nothing about that made compiling, testing, or packaging that code any faster. ### Every change fans out into a matrix One PR is rarely one job. Push, rebase, fix a lint error, retry a flake - each round trip re-runs the full matrix across every OS and architecture you support. ### Agents don't wait for business hours CI load used to follow a workday curve with quiet nights and weekends. Agent-driven pipelines run flat out around the clock, so peak capacity is now the baseline. ### The bill scales with output, not headcount When minutes are billed at GitHub-hosted rates, CI spend grows with how much code your team and its agents generate. Budgets planned around engineer count stop holding. //What changed ## The bottleneck is compute-bound, not human-bound Every assumption CI capacity planning was built on assumed a human typing on the other end. That assumption is gone. ### Before agents * \-Throughput was capped by how fast engineers could write code * \-CI load followed a 9-to-6 curve with quiet nights and weekends * \-A 20-minute build cost one context switch per developer * \-Runner spend was a rounding error next to salaries ### With agents in the loop * +Throughput is capped by how fast runners return a verdict * +Pipelines run flat out, 24/7, driven by agents and humans together * +A 20-minute build stalls every agent loop waiting on that result * +Runner spend is a line item leadership asks about by name //The solution ## CI built for the pace of AI Drop-in GitHub Actions runners that are faster per job, cheaper per minute, and elastic enough that a burst of agent-driven builds never forms a queue. [2x faster runnersFaster CPUs, NVMe-backed disks, and better network throughput than GitHub-hosted runners. The same workflow finishes in roughly half the wall-clock time.](/compare/github)[Instant autoscaling, no queueRunners boot on demand with unlimited concurrency. Matrix strategies actually parallelize instead of queuing behind your own jobs at peak.](/docs/ci/cloud-runners#concurrency)[Half the cost per minutePer-minute billing at half the rate of GitHub-hosted runners. Combined with shorter runtimes, most teams cut CI spend by well over half.](/pricing)[Unlimited, faster caching3-10x faster cache save and restore with no storage quotas, so dependency installs and build artifacts stop dominating every run.](/products/ci/cache)[Docker layer caching and remote buildersRemote Docker builders with persistent layer caches turn repeated image builds into near-instant rebuilds for both x86-64 and ARM64.](/products/ci/docker-builders)[Every platform your agents targetLinux on x86-64 and ARM64, macOS on Apple silicon including M4 Pro, and Windows. Run in WarpBuild's cloud or your own AWS, GCP, or Azure account.](/products/ci/cloud-runners) ### Build wall-clock time GitHub-hostedBaseline WarpBuild\~2x faster ### Cost per minute GitHub-hostedBaseline WarpBuild\~50% lower ### Cache throughput GitHub-hostedBaseline WarpBuild3-10x faster ### Concurrency at peak GitHub-hostedPlan-capped WarpBuildUnlimited //Getting started ## One line of YAML, then stop thinking about it WarpBuild is a drop-in replacement for GitHub-hosted runners. Existing workflows, actions, and preinstalled tooling all keep working. ``` - runs-on: ubuntu-latest+ runs-on: warp-ubuntu-latest-x64-2x ``` 1 ### Change one line of YAML Swap runs-on in your workflow file. No workflow rewrites, no migration project, no change to how your agents open PRs. 2 ### Watch the queue disappear Runners boot on demand with unlimited concurrency, so peak load from agents and humans lands on capacity instead of a waiting line. 3 ### Track the numbers Per-workflow timing and spend in the [WarpBuild dashboard](https://app.warpbuild.com), so you can see which pipelines your AI throughput actually moved. //Proven at scale ## Running the pipelines behind teams that ship constantly From solo open-source projects to thousand-developer organizations, on SOC2 Type 2 attested infrastructure with ephemeral, fully isolated VMs. ![Sonar](/images/customers/sonarqube.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Comcast](/images/customers/comcast.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Sky](/_next/image?url=%2Fimages%2Fcustomers%2Fsky-uk.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Braintrust](/images/customers/braintrust.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Adaptive](/_next/image?url=%2Fimages%2Fcustomers%2Fadaptive.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![OxCaml](/images/customers/oxcaml.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![DDEV](/images/customers/ddev.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Kintsugi](/images/customers/kintsugi-ai.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Bitcoin](/images/customers/bitcoin.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![LanceDB](/_next/image?url=%2Fimages%2Fcustomers%2Flancedb.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) Run times went down from \~25 mins to \~9 mins. Even bazel cache load time was faster by 30% Going from GitHub standard runners to your cheapest runners will save 30-40% time off the average workflow at half the price, and the workflow I care most about is staying the same price but 60% time savings I get to save the company money and save us on build times. Making me look good to my management team. //FAQ ## Frequently asked questions Do we have to change how our agents or assistants work? No. WarpBuild sits underneath GitHub Actions. Whatever opens the PR - a developer, Claude Code, or an autonomous agent - hits the same workflows. The only change is the runs-on label, so tooling that generates or reviews code is unaffected. Why does faster CI matter more with AI in the loop? An agent's iteration speed is gated on how quickly CI returns a verdict. A 20-minute pipeline caps how many correction loops an agent can complete in an hour, and that cost is paid on every retry. Halving build time compounds across every loop, for humans and agents alike. How much does this actually reduce our CI bill? WarpBuild runners are priced at roughly half the per-minute rate of equivalent GitHub-hosted runners, and jobs finish in about half the time. Teams that also adopt faster caching and Docker layer caching typically see larger reductions. See for current rates. What happens when a burst of agent-driven builds hits at once? Runners are provisioned on demand with unlimited job concurrency, so a burst of parallel workflows starts immediately rather than queuing behind your own jobs. There is no fixed pool to size or pre-warm. Which platforms and architectures are supported? Linux on x86-64 and ARM64, macOS on Apple silicon including M4 Pro, and Windows. GPU runners are coming soon. Runners can be hosted in WarpBuild's cloud or deployed into your own AWS, GCP, or Azure account with BYOC. Is this safe for code our agents wrote but nobody has reviewed? Yes. Every job runs on an ephemeral, fully isolated VM that is destroyed after the run, and WarpBuild never retains your source. WarpBuild is SOC2 Type 2 attested with SSO and access controls. See for details. ## Stop making your agents wait. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # Runner Alternatives | WarpBuild URL: https://www.warpbuild.com/alternatives Description: Alternatives pages for GitHub Actions runner providers, with honest selection criteria and cited facts. # Runner Alternatives Alternatives to each GitHub Actions runner option, with selection criteria and cited facts. * [Actions Runner Controller (ARC) AlternativesManaged alternatives to running Actions Runner Controller yourself, what each provider publishes, how to choose on ops burden versus control, and when to stay.](/alternatives/arc) * [Blacksmith Alternatives for GitHub ActionsFive real alternatives to Blacksmith for GitHub Actions runners, with cited facts on cloud placement, regions, concurrency, macOS depth, and billing shape.](/alternatives/blacksmith) * [Depot Alternatives for GitHub ActionsThe credible alternatives to Depot for GitHub Actions runners, what each one publishes about coverage, pricing shape, and residency, and how to choose.](/alternatives/depot) * [GitHub Actions Larger Runner AlternativesThe managed and self-hosted alternatives to GitHub-hosted larger runners, what each one publishes about platforms, concurrency, and deployment, with sources.](/alternatives/github-actions) * [Namespace Alternatives for GitHub ActionsFive real alternatives to Namespace for GitHub Actions runners, with cited facts on coverage, concurrency, bring your own cloud, pricing structure and support.](/alternatives/namespace) --- # Actions Runner Controller (ARC) Alternatives | WarpBuild URL: https://www.warpbuild.com/alternatives/arc Description: Managed alternatives to running Actions Runner Controller yourself, what each provider publishes, how to choose on ops burden versus control, and when to stay. # Actions Runner Controller (ARC) Alternatives Managed alternatives to running Actions Runner Controller yourself, what each provider publishes, how to choose on ops burden versus control, and when to stay. Last updated: 2026-08-13 Teams look for an [Actions Runner Controller](https://github.com/actions/actions-runner-controller) alternative when the software stops being the hard part and the cluster starts being the hard part. ARC itself is free under Apache-2.0; the alternatives below are managed services that take the operator, the listener, the runner images, the upgrades, and the on-call load off your team, at a per-minute price. This page describes each option with facts from its own public pages, gives selection criteria centered on the tradeoff between operating burden and control, and closes with the cases where staying on ARC is correct. For the head-to-head view, see [WarpBuild vs ARC](/compare/arc). ## [What You Are Actually Replacing](#what-you-are-actually-replacing)Copy Anchor Link ARC is a Kubernetes operator that runs GitHub Actions runners as pods, with an event-driven listener that holds a long poll to the GitHub Actions Service and creates just-in-time ephemeral runners ([GitHub docs](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller)). Replacing it means replacing five things: 1. **Cluster operations.** Nodes, the control plane, storage provisioners for Kubernetes mode, and the isolation guidance that recommends running these workloads away from production. 2. **The upgrade path.** GitHub documents that Helm cannot upgrade ARC and that the procedure is a five-step uninstall and reinstall, with a second cluster in another region as the documented way to avoid downtime ([docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets#upgrading-arc)). 3. **Runner image maintenance.** The shipped image carries "the least amount of packages necessary", so your toolchain is your build, and the 30-day runner-update rule sets the rebuild clock ([docs](https://docs.github.com/en/actions/reference/runners/self-hosted-runners#runner-software-updates-on-self-hosted-runners)). 4. **Scaling policy.** `maxRunners` and `minRunners` are static Helm values, and scheduled scaling is something you build with a cron job ([docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets)). 5. **The support boundary.** GitHub recommends a Kubernetes expert on staff and publishes a list of Kubernetes topics outside GitHub Support's scope ([support scope](https://docs.github.com/en/actions/concepts/runners/support-for-arc)). A managed provider absorbs all five. What you give up is the guarantee that nothing outside your account participates in your build path. ## [Managed Alternatives](#managed-alternatives)Copy Anchor Link ### [WarpBuild](#warpbuild)Copy Anchor Link WarpBuild runs GitHub Actions runners as ephemeral VMs on its own cloud or inside your AWS, GCP, or Azure account. Platform coverage is Linux x64, Linux ARM64, macOS, and Windows. macOS runners cover macOS 14, 15, and 26 in 6 vCPU and 12 vCPU sizes, with Xcode 27.0 and the iOS, tvOS, watchOS, and visionOS simulator runtimes on the macOS 26 image ([docs](https://www.warpbuild.com/docs/ci/cloud-runners)). The BYOC path is the closest structural replacement for ARC: connect a cloud account through an IAM role or service account, create a stack that pins region, VPC, and object storage, then define custom runners with instance types, disks, and IP settings ([BYOC docs](https://www.warpbuild.com/docs/ci/byoc)). The VMs are created in your account, in your region, with no Kubernetes cluster in the picture. BYOC also supports custom VM images, spot instances, static IPs, and standby disks that boot a runner in about 15 seconds ([feature matrix](https://www.warpbuild.com/docs/ci/feature-matrix)). Pricing is usage based and billed per minute, with no base subscription fee, no platform fee, and no seat fee, and signup includes $10 in free credits ([pricing](/pricing)). Generally available Linux and Windows runners do not have plan-level concurrency caps. [Single sign-on](/docs/sso) with SAML 2.0 and OIDC costs a flat $250 per month, whatever the user count. Slack support channels are available on demand, and the control plane is SOC 2 Type 2 certified with evidence at [trust.warpbuild.com](https://trust.warpbuild.com/). On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you, and that applies whether your runners live in your cloud or ours. A [Terraform provider](https://www.warpbuild.com/docs/ci/api-keys/terraform) covers BYOC on AWS. Regions available for hosted runners are US and EU. ### [Blacksmith](#blacksmith)Copy Anchor Link [Blacksmith](https://www.blacksmith.sh/) operates its own homogeneous compute pool and runs jobs in Firecracker microVMs. Their docs publish Linux x64 and Linux ARM64 runner families on Ubuntu 24.04 and 22.04 at five sizes each, Windows Server 2025 runners marked public beta, and macOS runners on Apple Silicon in two sizes ([runner overview](https://docs.blacksmith.sh/blacksmith-runners/overview)). Their security page describes a fleet procured from data center providers in the US and EU ([security](https://www.blacksmith.sh/security)). No bring-your-own-cloud, VPC, or customer-account deployment option is advertised on their pricing, security, or docs pages (verified absent on 2026-08-13). Pricing is per minute with separately metered add-ons; see their [pricing page](https://www.blacksmith.sh/pricing) for current rates. Their docs state that Blacksmith is limited to GitHub organizations and unavailable for personal repositories. Consider Blacksmith when you want a fast one-line migration to a vendor-operated fleet and have no requirement to keep compute inside your own account. ### [Namespace](#namespace)Copy Anchor Link [Namespace](https://namespace.so/) runs a vertically integrated fleet on hardware they own, and they have written publicly about running more than 95 percent of their platform on their own infrastructure since early 2024 ([blog](https://namespace.so/blog/so-you-want-to-build-your-own-datacenter)). Their GitHub Actions runners cover Linux amd64 and arm64, Windows, and macOS ([docs](https://namespace.so/docs/solutions/github-actions)), with macOS on Apple Silicon offering four macOS versions and six machine shapes plus Xcode selectors ([macOS docs](https://namespace.so/docs/architecture/compute/macos)). Windows runners reached general availability on 2026-08-05 per their [changelog](https://namespace.so/changelog). There is no BYOC option; all compute runs on the Namespace fleet, and their federation documentation covers identity rather than deployment (verified absent on 2026-08-13). Plans publish vCPU-denominated concurrency caps and unit-minute multipliers; see their [pricing page](https://namespace.so/pricing). Consider Namespace when macOS and Apple Silicon depth is the deciding factor and vendor-operated hardware is acceptable. ### [Depot](#depot)Copy Anchor Link [Depot](https://depot.dev/) started with container builds and added GitHub Actions runners alongside them. Runner types cover Linux x64 on AMD EPYC, Linux ARM64 on Graviton4, Windows Server 2022 and 2025, and macOS on Apple Silicon in a single shape across three OS labels ([runner types](https://depot.dev/docs/github-actions/runner-types)). Their docs state that macOS capacity is a fixed pool with FIFO processing and that jobs can queue during demand spikes ([troubleshooting](https://depot.dev/docs/github-actions/troubleshooting)). Depot Managed deploys the Depot data plane into your own AWS account as a single-tenant sub-account, available on their Business plan; GCP is described as under consideration and Azure is absent (verified absent on 2026-08-13) ([Depot Managed](https://depot.dev/docs/managed/overview)). Pricing and plan gates are on their [pricing page](https://depot.dev/pricing). Consider Depot when container image builds are the dominant cost in your pipeline and AWS-only customer-account deployment satisfies your control requirement. ### [GitHub-hosted larger runners](#github-hosted-larger-runners)Copy Anchor Link Staying entirely inside GitHub is a legitimate alternative to operating ARC. [Larger runners](https://docs.github.com/en/actions/concepts/runners/larger-runners) give you bigger Linux, Windows, macOS, and arm64 shapes than the standard hosted runners, managed and autoscaled by GitHub, with runner groups for access control and, on GitHub Enterprise Cloud, Azure private networking and static IP ranges. Nothing runs in your account: GitHub hosts these runners on virtual machines in Azure. The billing rule worth reading before you choose this path is that larger runners are always charged for, including on public repositories and when your plan's included minutes remain ([billing docs](https://docs.github.com/en/billing/concepts/product-billing/github-actions)). Per-minute rates and per-plan included minutes are published on the same page and on [github.com/pricing](https://github.com/pricing). Consider GitHub-hosted larger runners when procurement simplicity outweighs everything else and your workloads fit the published shapes. ## [How to Choose](#how-to-choose)Copy Anchor Link The decision is a tradeoff between operating burden and control. Work through these questions with your own answers before comparing prices. **Does compute have to run in an account you own?** If yes, the field narrows immediately: WarpBuild BYOC on AWS, GCP, or Azure, Depot Managed on AWS, or staying on ARC. If no, the vendor-operated fleets are in scope and the question becomes platform coverage and price. **Which platforms do your workflows actually need?** ARC's runner image is Linux only and ARC documentation does not cover macOS runners (verified on 2026-08-13). If iOS or macOS builds are in your pipeline, you are already running a second system next to ARC, and consolidating it is part of the value of moving. **Who owns the runner image?** On ARC you own it, including the 30-day update window that gates job queuing. On managed providers the vendor owns the hosted image and publishes changes; WarpBuild also supports custom VM images on BYOC subject to documented requirements ([custom VM images](https://www.warpbuild.com/docs/ci/byoc/custom-vm-images)). **What happens on upgrade day?** Price the ARC upgrade procedure against a managed upgrade you do not perform. If a second cluster in a second region is the only way you can upgrade without a runner outage, that cluster belongs in the comparison. **How does capacity respond to a Monday morning spike?** ARC scales on queue events but stays inside `maxRunners`, and warm capacity means paying for idle pods. Ask each provider what their concurrency policy is and where the ceiling sits. Generally available WarpBuild Linux and Windows runners do not have plan-level concurrency caps. **Who answers at 2am?** Compare community issue threads against a support channel with a contract behind it. GitHub Support publishes a list of Kubernetes topics outside its scope for ARC deployments. **What does an auditor need?** If your customers ask for evidence about the build environment, a vendor with a published attestation shortens the conversation. ARC publishes no compliance certification of its own (verified absent on 2026-08-13), so the evidence work belongs to your cluster and your cloud. **What is the switching cost in both directions?** For every option here, the workflow change is a `runs-on` label. Reversibility should be a criterion, and it favors keeping your ARC deployment installed through a parallel-run period. ## [When to Stay on ARC](#when-to-stay-on-arc)Copy Anchor Link **A Kubernetes platform team already exists.** GitHub recommends a Kubernetes expert on staff for ARC adoption. If you have one, plus a cluster, an upgrade process, a logging pipeline, and an on-call rotation, then most of the maintenance inventory is work you already perform, and ARC becomes a marginal workload on paid capacity. **Policy forbids a third-party control plane in the build path.** ARC runs entirely in your cluster with no vendor control plane, which is the one thing no managed provider on this page can offer. If that is a contractual requirement, the decision is made. **The network is the constraint.** Air-gapped or heavily proxied environments need the knobs ARC documents: outbound proxy configuration for controller, listener, and runners, custom CA injection into both trust stores, and private container registry mirroring. **You need PodSpec-level control.** Node selection, topology spread, priority classes, security contexts, capabilities, and custom volumes are all first-class in ARC because your cluster expresses them directly. **You are on GitHub Enterprise Server 3.9 or later** and want autoscaling runners in the same estate, which ARC documents as supported. If you land on one of these, has the dimension-by-dimension detail to document the decision. If you land on the other side, [migrate from ARC to WarpBuild](/migrate/arc-to-warpbuild) covers the runbook, including the decommissioning order for the cluster deployment. ## FAQ ### What is the closest alternative to ARC if I want runners inside my own cloud account? WarpBuild BYOC and Depot Managed are the two options in this list that place runner compute in a cloud account you own. WarpBuild BYOC runs on AWS, GCP, and Azure with a Terraform provider for AWS. Depot documents Depot Managed as an AWS-only deployment available on their Business plan. ### Do any of these alternatives run macOS runners? WarpBuild, Blacksmith, Namespace, Depot, and GitHub-hosted larger runners all publish macOS runners on Apple Silicon. ARC does not: its runner image is Linux only, published for linux/amd64 and linux/arm64, and ARC documentation does not cover macOS runners, verified on 2026-08-13. ### Is leaving ARC reversible? The workflow change is a runs-on label, so reverting is a single commit as long as the ARC deployment is still installed. Keep the cluster and the scale sets running through a parallel-run period, then decommission once the workflow set has run green on the new provider. ### Does moving to a managed provider mean giving up control of the runner image? Not entirely. WarpBuild maintains the hosted runner images and publishes changes in a monthly docs changelog, and BYOC deployments can use custom VM images you build, subject to the documented image requirements. The tradeoff is that you stop owning the rebuild cadence that ARC's 30-day runner-update rule imposes. ### When should we keep running ARC? When a Kubernetes platform team and cluster already exist, when policy forbids a third-party control plane in the build path, when runners must sit inside a tightly controlled network, or when you need PodSpec-level control of scheduling and hardware. Those cases are covered in detail below. --- # Blacksmith Alternatives for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/alternatives/blacksmith Description: Five real alternatives to Blacksmith for GitHub Actions runners, with cited facts on cloud placement, regions, concurrency, macOS depth, and billing shape. # Blacksmith Alternatives for GitHub Actions Five real alternatives to Blacksmith for GitHub Actions runners, with cited facts on cloud placement, regions, concurrency, macOS depth, and billing shape. Last updated: 2026-08-13 Teams shopping for a Blacksmith alternative usually want one of four things: runners inside their own cloud account, a region they can name in a compliance answer, deeper macOS and Xcode coverage, or a bill with fewer meters on it. This page lists the options that are real products with public documentation, states what each one publishes, and links every fact to the vendor page that states it, checked on 2026-08-13. Already comparing head to head? [WarpBuild vs Blacksmith](/compare/blacksmith) covers the two platforms dimension by dimension. Already decided? [Blacksmith to WarpBuild migration](/migrate/blacksmith-to-warpbuild) has the label diff and the rollback steps. ## [Why Teams Look for Alternatives](#why-teams-look-for-alternatives)Copy Anchor Link Blacksmith is a capable platform, and most switching conversations start from a requirement it does not cover rather than from a complaint. The four requirements we see most often: **Compute placement.** No bring-your-own-cloud, VPC, or customer-account deployment option appears on their [pricing page](https://www.blacksmith.sh/pricing), their [security page](https://www.blacksmith.sh/security), or anywhere in their [docs sitemap](https://docs.blacksmith.sh/sitemap.xml) as of 2026-08-13\. Their published design is a vendor-operated fleet procured from data center providers in the US and EU. A job that needs to reach a private database or an internal registry inside your own network has no documented path. **Region selection.** Their docs name three regions (US West, EU West, EU Central) in the [Docker container caching page](https://docs.blacksmith.sh/blacksmith-caching/docker-container-caching), and no page documents how a customer selects one for a job or an organization. **Identity.** Their [settings docs](https://docs.blacksmith.sh/introduction/settings) state that Blacksmith currently only supports login using GitHub, and no SAML or SCIM product is published. Teams that run access reviews out of an identity provider need something else. **macOS depth.** Their macOS offering publishes two sizes and three image selectors resolving to two macOS versions, and no Xcode version list or Xcode selector appears anywhere in their docs. ## [What to Evaluate](#what-to-evaluate)Copy Anchor Link Score every candidate on the same dimensions, in this order, because the first four decide most evaluations and the rest decide the renewal. 1. **Where the compute runs.** Vendor fleet, your cloud account, or your own Kubernetes cluster. This drives your security review, your network access, and your data flow diagram. 2. **Region naming and pinning.** Can you name the region in a customer questionnaire, and can you pin it without a support ticket? 3. **Concurrency policy.** Published caps, denominated in jobs or vCPU, versus no hard limits. Check whether the cap is per plan and whether queueing controls exist because of it. 4. **Meter count and free tier.** Count the line items that can appear on an invoice: runner minutes plus cache storage, artifact storage, egress, seats, support, and add-ons. Fewer meters means a forecast you can defend. 5. **macOS and Xcode coverage.** If you ship Apple software, count the macOS versions, the shapes, and the published Xcode versions on both sides before you decide. 6. **Identity and access.** SAML, SCIM, and a role model independent of GitHub org membership. 7. **API and infrastructure as code.** A working API reference and a Terraform provider, if runner configuration belongs in the same state as the rest of your infrastructure. 8. **Cache portability.** Assume none. Ask what a cold start costs you on the first run and what vendor-specific actions you would need to remove on the way out. 9. **Support channel and SLA.** Which tier gets a Slack channel, what the response times are, and whether an uptime SLA carries service credits. 10. **Shipping cadence.** A public changelog tells you when a runner image changed under a build that started failing. ## [The Alternatives](#the-alternatives)Copy Anchor Link ### [WarpBuild](#warpbuild)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, selected with `warp-` labels. BYOC runs on AWS, GCP, and Azure, so the runner VMs live inside your account, and a [Terraform provider](/docs/ci/api-keys/terraform) covers BYOC on AWS. Regions are US and EU. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts dynamically. Single sign-on costs a flat $250 per month, whatever the user count. Slack support channels are available on demand, and the platform holds SOC 2 Type 2 with evidence at [trust.warpbuild.com](https://trust.warpbuild.com/). Tooling includes snapshot runners, remote Docker builders, CI observability, an MCP server, and the [Action Debugger](/docs/ci/tools/action-debugger). On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you, and that applies whether your runners live in your cloud or ours. Runner image and platform changes ship monthly in the public [changelog](/docs/ci/changelog). ### [Depot](#depot)Copy Anchor Link Depot runs GitHub Actions runners alongside a container build product that is their center of gravity. Their [runner types page](https://depot.dev/docs/github-actions/runner-types) publishes Linux x64 on AMD EPYC instances, Linux ARM64 on Graviton4, Windows Server 2022 and 2025, and macOS at three OS labels on a single shape of 8 CPUs and 24 GB. That same page carries a queue-time caveat in their own words: Apple licensing constraints mean their macOS capacity is not fully elastic and macOS jobs can wait longer during high demand, with dedicated reserved capacity offered as an annual-commitment mitigation. [Depot Managed](https://depot.dev/docs/managed/overview) deploys their data plane into your AWS account, is gated to their Business plan, and lists GCP as under consideration with no Azure option. No Terraform provider appears in their site index. Their [pricing page](https://depot.dev/pricing) publishes plan tiers with a monthly floor, unlimited concurrency on every plan, per-second billing, and two regions, us-east-1 and eu-central-1\. They publish [per-commit benchmarks](https://depot.dev/benchmark/posthog) for container builds, which is unusual transparency in this category. ### [Namespace](#namespace)Copy Anchor Link Namespace runs GitHub Actions runners on hardware they own. Their engineering blog states they have run more than 95 percent of their platform on their own infrastructure [since early 2024](https://namespace.so/blog/so-you-want-to-build-your-own-datacenter). They are the strongest macOS offering in this set: their [macOS docs](https://namespace.so/docs/architecture/compute/macos) publish four macOS versions, six machine shapes, Xcode selection through shape selectors including a bleeding-edge channel, and an image-update RSS feed. There is no bring-your-own-cloud option; their [federation docs](https://namespace.so/docs/federation) cover workload identity only. Their [pricing page](https://namespace.so/pricing) publishes per-plan concurrency caps denominated in vCPU (the Developer plan is capped at 32 vCPU for Linux and 12 vCPU for macOS), a unit-minute model with platform multipliers, and a dozen separate invoice meters. Free access is a 30-day trial rather than a permanent free tier. Their [data residency docs](https://namespace.so/docs/workspaces/data-residency) state that workspace data may be processed across their global infrastructure by default, that regional preference goes through their support team, and that region-exclusive residency requires an enterprise plan. ### [GitHub larger runners](#github-larger-runners)Copy Anchor Link Staying on GitHub is a legitimate answer, especially for teams whose main problem is a slow default runner rather than a cost or placement problem. GitHub's [larger runners](https://docs.github.com/en/actions/reference/runners/larger-runners) add bigger x64 and ARM64 Linux and Windows shapes, macOS large and xlarge shapes, and GPU shapes. They are available to organizations and enterprises on GitHub Team and GitHub Enterprise Cloud plans, and GitHub states that larger runners are [always charged for](https://docs.github.com/en/billing/concepts/product-billing/github-actions), including on public repositories, with included plan minutes not applicable. GitHub publishes [per-plan concurrency caps](https://docs.github.com/en/actions/reference/limits), and macOS concurrency is shared between standard and larger runners. All of it runs in Microsoft Azure under GitHub's account, so there is [no customer-account option](https://docs.github.com/en/actions/concepts/runners/github-hosted-runners); Azure private networking and static IP ranges for larger runners on GitHub Enterprise Cloud are the nearest adjacent capabilities. Nothing beats it on integration depth and setup time. ### [Actions Runner Controller](#actions-runner-controller)Copy Anchor Link [Actions Runner Controller](https://github.com/actions/actions-runner-controller) is GitHub's open source Kubernetes operator for self-hosted runners, shipped as two Helm charts and one container image. The software is free and the operating cost is yours: cluster capacity, scaling configuration, runner image maintenance, and chart upgrades. The GitHub-maintained runner image is Linux only, built from a .NET runtime base for `linux/amd64` and `linux/arm64` [in their publish workflow](https://github.com/actions/runner/blob/main/.github/workflows/docker-publish.yml). Windows support on the modern scale-set mode is [an open issue](https://github.com/actions/actions-runner-controller/issues/1001) rather than a shipped feature, and a full-repository search for macOS at the current commit found exactly one match, inside a legacy document describing GitHub-hosted runners rather than ARC capability. ARC is the right answer for a platform team that already operates Kubernetes and has a policy reason to keep GitHub Actions compute in house. ## [How WarpBuild Compares](#how-warpbuild-compares)Copy Anchor Link Against Blacksmith, the WarpBuild differences are placement, residency, identity, and the API surface: BYOC on three clouds, named US and EU regions, SAML and OIDC single sign-on with a published fee, a working API for every feature, and a Terraform provider for BYOC on AWS. Concurrency and SOC 2 Type 2 are parity between the two. Blacksmith's permanent free monthly minute allowance, transparent `actions/cache` interception, and Bazel remote cache have no WarpBuild equivalent, and the [comparison page](/compare/blacksmith) states each of those plainly. Against Depot, WarpBuild covers three clouds for BYOC rather than one, publishes its Xcode versions per image with multiple sizes and configurations per chip, and charges no monthly plan floor. Depot's container build product and their public per-commit benchmarks are genuinely ahead of the field, and concurrency is parity. Against Namespace, WarpBuild offers BYOC that Namespace does not publish, no hard concurrency caps against their per-plan vCPU caps, and a single usage-based meter against their unit-minute multipliers and their storage meters. Namespace publishes more macOS versions and a bleeding-edge Xcode channel, so an iOS team chasing beta toolchains should count both catalogs before deciding. Against GitHub larger runners and ARC, the tradeoff is the familiar one: WarpBuild adds placement control, region naming, and runner tooling that GitHub-hosted runners lack, and removes the cluster operations that ARC requires. Editorial recommendation: shortlist on requirement 1 and requirement 2 from the evaluation list, then run the top two candidates in parallel on a real repository for a week. Both switches are one line per job, and both rollbacks are the same line in reverse. ## FAQ ### What is the closest alternative to Blacksmith? For a team that wants managed GitHub Actions runners with the same one-label adoption, WarpBuild and Depot are the closest matches on product shape, and Namespace is the closest on macOS depth. The dimension that separates them is where the compute runs: WarpBuild BYOC runs on AWS, GCP, and Azure (https://www.warpbuild.com/docs/ci/byoc), Depot Managed is AWS-only and gated to their Business plan (https://depot.dev/docs/managed/overview), and Namespace runs everything on their own fleet (https://namespace.so/docs/federation). ### Is there a free alternative to Blacksmith? Actions Runner Controller is free software you run yourself on Kubernetes, published by GitHub at https://github.com/actions/actions-runner-controller. The software costs nothing and the cluster, the images, the scaling configuration, and the upgrades are yours to operate. Managed vendors charge per minute instead. GitHub-hosted standard runners are free for public repositories, and GitHub states that larger runners are always charged for, even on public repositories (https://docs.github.com/en/billing/concepts/product-billing/github-actions). ### Which alternative supports running runners in my own cloud account? WarpBuild BYOC runs on AWS, GCP, and Azure, with a Terraform provider for AWS (https://www.warpbuild.com/docs/ci/byoc). Depot Managed deploys their data plane into your AWS account on the Business plan and lists GCP as under consideration (https://depot.dev/docs/managed/overview). Namespace publishes no bring-your-own-cloud option; their federation docs cover workload identity only (https://namespace.so/docs/federation). GitHub-hosted runners run in Microsoft Azure under GitHub's account (https://docs.github.com/en/actions/concepts/runners/github-hosted-runners). ### How hard is it to switch runner vendors? For managed vendors, the workflow change is the runs-on label, so a switch and a rollback are both one line per job plus any vendor-specific action steps. What does not move is cache state: expect one cold run per cache key on the new platform while caches warm. Actions Runner Controller is the exception, because adopting or leaving it means standing up or decommissioning a Kubernetes deployment. See /migrate/blacksmith-to-warpbuild for a worked example. ### Do the alternatives publish their compliance posture? WarpBuild holds SOC 2 Type 2 with evidence at https://trust.warpbuild.com/. Blacksmith states SOC 2 Type 2 and GDPR on https://www.blacksmith.sh/security with a trust center at https://trust.oneleet.com/blacksmith-sh. Namespace publishes SOC 2 Type 2 evidence at https://trust.namespace.so and Depot publishes a security page at https://depot.dev/docs/security. Ask each vendor for the report itself; most gate it behind a request form. --- # Depot Alternatives for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/alternatives/depot Description: The credible alternatives to Depot for GitHub Actions runners, what each one publishes about coverage, pricing shape, and residency, and how to choose. # Depot Alternatives for GitHub Actions The credible alternatives to Depot for GitHub Actions runners, what each one publishes about coverage, pricing shape, and residency, and how to choose. Last updated: 2026-08-13 Teams shop for a Depot alternative when their spend sits in GitHub Actions minutes rather than container builds, when they need runners inside a cloud account that [Depot Managed](https://depot.dev/docs/managed/overview) does not cover, or when the [single macOS hardware shape](https://depot.dev/docs/github-actions/runner-types) is not enough. This page lists the alternatives worth a real evaluation, with every vendor fact linked to the page that states it and checked on 2026-08-13. Looking for the head to head instead? [WarpBuild vs Depot](/compare/depot) has the dimension table. [Migrating from Depot to WarpBuild](/migrate/depot-to-warpbuild) has the label map and the rollback path. ## [How to Choose](#how-to-choose)Copy Anchor Link Six questions separate these options faster than any feature list. 1. **Where does the compute run?** A vendor fleet, your own cloud account, or your own Kubernetes cluster. This decides your data residency story, your security review, and who carries the pager. 2. **How many macOS shapes and Xcode versions do you need?** macOS is where this category diverges most, both in machine sizes and in what each vendor publishes about image contents. 3. **What shape is the bill?** A monthly plan with included allowances and usage meters on top behaves differently from a purely usage based rate, especially for teams with spiky monthly minute counts. 4. **What is the concurrency policy?** Most vendors in this set publish no hard limits. GitHub publishes per plan caps. Read the published policy rather than a marketing line. 5. **What happens to caching when you switch?** Cache contents are never portable between providers. What matters is how many workflow edits the new cache costs you and whether your build tool has a documented integration. 6. **How reversible is the move?** If the only change is a `runs-on` label, a bad outcome costs you one pull request. If the move rewrites workflow files into a new directory, the exit is more expensive than the entry. ## [WarpBuild](#warpbuild)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners for GitHub Actions, plus remote Docker builders. Runners are selected by a `warp-` label, so adoption and reversal are both a one line workflow change. Product surface beyond the fleet: snapshot runners, remote Docker builders, CI observability, an MCP server, and the [Action Debugger](/docs/ci/tools/action-debugger) for an SSH session into a running job. The differences that matter against Depot specifically: * **BYOC on three clouds.** Managed runners inside your own AWS, GCP, or Azure account, with a Terraform path for AWS. See [BYOC](/docs/ci/byoc). * **Two macOS sizes across three macOS versions.** 6 vCPU with 14 GB and 12 vCPU with 28 GB of memory, with the Xcode version on the macOS 26 image published in [cloud runners](/docs/ci/cloud-runners). * **Purely usage based pricing.** No base subscription fee, no platform fee, and no seat fee, with $10 in free credits at signup. Rates per label are on the [pricing page](/pricing). * **No plan-level concurrency caps on generally available Linux and Windows runners.** macOS capacity and beta runners have separate constraints. * **US and EU regions**, with enterprise deployments on [region specific infrastructure](/products/region-specific-infrastructure). On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you, whether your runners live in your cloud or ours. * **SOC 2 Type 2**, with the report requestable at [trust.warpbuild.com](https://trust.warpbuild.com/), SAML 2.0 and OIDC single sign on at a flat $250 per month, whatever the user count, and Slack support channels on demand. ## [Blacksmith](#blacksmith)Copy Anchor Link Blacksmith runs managed GitHub Actions runners on their own bare metal fleet, with Linux x64, ARM, Windows, and macOS coverage. Their [quickstart](https://docs.blacksmith.sh/introduction/quickstart) publishes a one line `runs-on` diff with explicit label mappings, such as `ubuntu-latest` to `blacksmith-2vcpu-ubuntu-2404`, alongside a wizard that rewrites workflow files for you. What stands out on their published pages, checked 2026-08-13: * 3,000 free minutes per month with no credit card, printed on every pay as you go tier of their [pricing page](https://www.blacksmith.sh/pricing). * Caching that needs no workflow edits. Their [dependency caching docs](https://docs.blacksmith.sh/blacksmith-caching/dependencies-actions) state that zero code changes are required, because standard `actions/cache` and `setup-*` actions hit their colocated cache. * No concurrency limits on any tier, stated in their docs. * Automatic free size upgrades when their fleet has spare capacity, per their [runner overview](https://docs.blacksmith.sh/blacksmith-runners/overview). * SOC 2 Type 2 since September 2024, a published DPA and sub-processor list, and a public status page. This is parity with WarpBuild on compliance, and no page should frame it as a gap. * A deep observability surface: CI analytics, run history, cross run log search, machine metrics, and SSH into running jobs. The trade against Depot is a simpler adoption story and a free monthly allowance, set against a narrower product portfolio. Blacksmith sells runners and caching. Depot's [own docs index](https://depot.dev/llms.txt) covers container builds, a registry, agent sandboxes, and a separate CI engine as well. ## [Namespace](#namespace)Copy Anchor Link Namespace runs GitHub Actions runners on hardware they own. Their engineering post on [building their own datacenter](https://namespace.so/blog/so-you-want-to-build-your-own-datacenter) states that more than 95 percent of their platform has run on their own infrastructure since early 2024, across sites on the US East Coast, the US West Coast, and Europe. They are the strongest macOS option in this set. Their [macOS architecture docs](https://namespace.so/docs/architecture/compute/macos) publish four macOS versions, six machine shapes up to 12 vCPU with 56 GB of memory, bare metal Apple Silicon, and Xcode selectors that include a beta channel. Their [machine shapes page](https://namespace.so/docs/architecture/compute/machine-shapes) also lists Linux on Apple Silicon as a distinct early access platform. Other published strengths, checked 2026-08-13: a large named cache surface backed by local NVMe volumes rather than a network cache API; ALLOW, BLOCK, and ADVISORY [egress policies](https://namespace.so/docs/security/egress-policy) on Linux and macOS; SOC 2 Type II with a public [trust center](https://trust.namespace.so); no charge for users or seats, per their [billing docs](https://namespace.so/docs/workspaces/billing-and-limits); a [changelog](https://namespace.so/changelog) with 226 dated entries; and a one line `runs-on` migration documented in their [GitHub Actions guide](https://namespace.so/docs/solutions/github-actions). Two things to check against your own requirements. All Namespace compute runs on their fleet, and their [federation docs](https://namespace.so/docs/federation) cover identity rather than deploying compute into your cloud account. Plan level concurrency is published as vCPU denominated caps on their [pricing page](https://namespace.so/pricing), which is a different model from an unlimited concurrency policy. ## [GitHub-Hosted Larger Runners](#github-hosted-larger-runners)Copy Anchor Link The option most teams skip. GitHub sells larger machine sizes for their own hosted fleet, documented on the [larger runners reference](https://docs.github.com/en/actions/reference/runners/larger-runners). Billing, permissions, secrets, OIDC, and metrics stay inside the product that already hosts your repository, and there is no third party in the security review. The tradeoffs are published as clearly as the benefits. GitHub sets per plan concurrency caps on its [limits page](https://docs.github.com/en/actions/reference/limits), where the other options in this list mostly publish no hard limits. Standard runners are free for public repositories, but larger runners are never free, even on public repositories, per [their billing concepts page](https://docs.github.com/en/billing/concepts/product-billing/github-actions). Data residency is an Enterprise Cloud feature documented [separately](https://docs.github.com/en/enterprise-cloud@latest/admin/data-residency/about-github-enterprise-cloud-with-data-residency), and there is no option to run the fleet inside your own cloud account. Choose this when the simplicity of a single vendor outweighs the price and the caps, and when your workflows are not blocked on queue time or machine size. ## [Actions Runner Controller](#actions-runner-controller)Copy Anchor Link Actions Runner Controller is GitHub's open source Kubernetes operator for self hosted runners. GitHub's [ARC concepts page](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller) describes it as an operator that orchestrates and scales self hosted runners, with runners as pods in your own cluster, shipped as two Helm charts and one container image. The software is free; the cost is your cluster and your engineering time. Facts to weigh, verified 2026-08-13: * The GitHub maintained runner image is Linux based and published for `linux/amd64` and `linux/arm64` only, per its [build workflow](https://github.com/actions/runner/blob/main/.github/workflows/docker-publish.yml). * ARC's documentation does not cover macOS runners. This was verified across the ARC repository and GitHub's ARC docs on 2026-08-13, and it is an absence in their documentation rather than a statement GitHub has published. * No Terraform provider or module ships in the ARC repository; the installation surface is [Helm charts](https://github.com/actions/actions-runner-controller/tree/master/charts) and raw manifests. * GitHub's [ARC support scope](https://docs.github.com/en/actions/concepts/runners/support-for-arc) excludes installation tooling other than Helm and excludes managed Kubernetes provider specifics, so cluster problems are yours. ARC is the right answer for a platform team that already operates Kubernetes and has a policy reason to keep runners inside its own boundary. It is the wrong answer for a team that wants runners to stop being a project. ## [Which Option Fits](#which-option-fits)Copy Anchor Link | Option | Where the compute runs | Strongest fit | | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | [WarpBuild](/pricing) | Vendor fleet or your AWS, GCP, or Azure account | Teams that want runner placement choice, macOS sizes, and usage based billing | | [Depot](https://depot.dev/pricing) | Their fleet, or your AWS account on their top plan | Teams whose bottleneck is container builds, or who want a full build platform from one vendor | | [Blacksmith](https://www.blacksmith.sh/pricing) | Their bare metal fleet | Teams that want a fast adoption path, a free monthly allowance, and caching with no workflow edits | | [Namespace](https://namespace.so/pricing) | Their owned hardware | Teams with the deepest macOS and Xcode matrix requirements | | [GitHub larger runners](https://docs.github.com/en/actions/reference/runners/larger-runners) | GitHub's fleet | Teams that will trade price and concurrency caps for one vendor | | [Actions Runner Controller](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller) | Your Kubernetes cluster | Platform teams with existing cluster operations and a policy that requires self management | Every row in this table links to the page where that vendor states its own terms. Prices change more often than comparison pages do, so read the linked page before you decide. ## FAQ ### Why do teams look for a Depot alternative? The three reasons we hear most are runner placement, macOS depth, and pricing shape. Depot Managed deploys into AWS only and on their Business plan (https://depot.dev/docs/managed/overview), their three macOS labels share one 8 CPU and 24 GB hardware shape (https://depot.dev/docs/github-actions/runner-types), and their self serve plans are monthly subscriptions with usage metered separately on top (https://depot.dev/pricing). All three were checked on 2026-08-13. ### Which alternative has the deepest macOS offering? Namespace publishes the widest macOS matrix in this set, with four macOS versions, six machine shapes, and Xcode selectors including a beta channel (https://namespace.so/docs/architecture/compute/macos, checked 2026-08-13). WarpBuild publishes two sizes across macOS 14, 15, and 26 and names the Xcode version its macOS 26 image ships. Count both against the Xcode versions your build actually needs. ### Which alternatives run inside my own cloud account? WarpBuild runs BYOC on AWS, GCP, and Azure, with a Terraform path for AWS. Actions Runner Controller runs in a Kubernetes cluster you operate yourself. Depot Managed covers AWS on their Business plan (https://depot.dev/docs/managed/overview). Namespace runs all compute on their own fleet. Checked on 2026-08-13. ### Is switching from Depot to another runner provider reversible? Yes for the runner label itself. Depot documents adoption as a one line change to the runs-on label (https://depot.dev/docs/github-actions/overview), so reverting is the same one line change in the other direction. Cache contents do not move between providers, so the first runs after a switch in either direction are cold. --- # GitHub Actions Larger Runner Alternatives | WarpBuild URL: https://www.warpbuild.com/alternatives/github-actions Description: The managed and self-hosted alternatives to GitHub-hosted larger runners, what each one publishes about platforms, concurrency, and deployment, with sources. # GitHub Actions Larger Runner Alternatives The managed and self-hosted alternatives to GitHub-hosted larger runners, what each one publishes about platforms, concurrency, and deployment, with sources. Last updated: 2026-08-13 People searching for a GitHub Actions larger runner alternative usually want one of three things: more machine for the money, more concurrent jobs than their plan allows, or runners that execute inside their own cloud account. This page lists the real options, describes each one from its own public documentation, and gives the criteria that decide between them. Every competitor fact below links the vendor page it came from and was checked on 2026-08-13\. No vendor prices are restated here; each entry links its pricing page. Comparing WarpBuild against GitHub-hosted runners specifically? That is [WarpBuild vs GitHub-hosted runners](/compare/github-actions). Ready to move? [Migrate from GitHub-hosted runners to WarpBuild](/migrate/github-hosted-to-warpbuild). ## [Why Teams Look for an Alternative](#why-teams-look-for-an-alternative)Copy Anchor Link Four reasons come up repeatedly, and each one maps to a published GitHub fact rather than a feeling. **Concurrency caps.** GitHub publishes hard per-plan caps at [Actions limits](https://docs.github.com/en/actions/reference/limits): 20 concurrent jobs on Free, 40 on Pro, 60 on Team, 500 on Enterprise, and a macOS cap of 5 concurrent jobs on Free, Pro, and Team that is shared across standard and larger runners. A monorepo with a wide test matrix hits the macOS number first. **Default machine size.** On private repositories, `ubuntu-latest` is 2 vCPU and 8 GB, per the [hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Bigger means larger runners, which GitHub gates to organizations on the Team and Enterprise Cloud plans and always charges for, even on public repositories, per the [Actions billing page](https://docs.github.com/en/billing/concepts/product-billing/github-actions). **macOS cost and shape.** GitHub's macOS rates are the highest per-minute rates on its price list, published on the [minute multipliers page](https://docs.github.com/en/billing/reference/actions-minute-multipliers). **Where the runner runs.** GitHub states it hosts runners on Azure virtual machines, on the [hosted runner concepts page](https://docs.github.com/en/actions/concepts/runners/github-hosted-runners). There is no option to place hosted runner compute inside your own cloud account, verified absent on 2026-08-13\. Teams with data-handling requirements or with large artifact pulls from their own object storage care about that. ## [Selection Criteria](#selection-criteria)Copy Anchor Link Score the options against the constraints you actually have, in roughly this order. 1. **Platform coverage.** Linux x64 and ARM64, Windows, and macOS are not all offered by every provider, and some ship a platform in beta. Check your matrix against theirs before anything else. 2. **Deployment model.** Vendor fleet, your cloud account, or your own Kubernetes cluster. This decides your compliance story and who carries the operational load. 3. **Concurrency.** Published caps, or the absence of them, and whether the cap is denominated in jobs or in vCPU. 4. **macOS depth.** Number of shapes, number of macOS versions, whether an Xcode version selector exists, and whether capacity is elastic. 5. **Pricing shape, not headline rate.** Plan floor, seat fees, billing unit, how many separate meters, whether add-ons such as cache storage bill separately. 6. **Cache architecture.** Whether their cache is a drop-in for `actions/cache`, where it lives, and what it costs. 7. **Residency and egress.** Which regions are nameable, how you select one, and which egress bill each claim refers to. Egress on a vendor's own network and egress your cloud provider charges you are different bills. 8. **Identity and support.** Whether SSO exists and how it is gated, and what support channel you get at your spend level. 9. **Compliance evidence.** A published trust page with the reports you need, rather than a badge in a footer. 10. **Reversibility.** How long it takes to go back. If the answer is one commit, the evaluation risk is small. ## [The Alternatives](#the-alternatives)Copy Anchor Link ### [WarpBuild](#warpbuild)Copy Anchor Link Managed GitHub Actions runners on Linux x64, Linux ARM64, macOS, and Windows, plus remote Docker builders. Linux and Windows come in 2 to 32 vCPU sizes (Windows from 4 vCPU) and macOS in 6 and 12 vCPU sizes, on every account with no plan gate. Labels and shapes are in the [cloud runner catalog](/docs/ci/cloud-runners). BYOC runs on AWS, GCP, and Azure, with Terraform support for BYOC on AWS, so the runners boot in the account you already audit. US and EU are the named regions. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts dynamically. Pricing is purely usage based, billed per minute, with no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits. Rates are on the [pricing page](/pricing). Against GitHub's own list prices, `warp-ubuntu-latest-x64-4x` at 4 vCPU and 16 GB costs $0.008 per minute against $0.012 per minute for GitHub's 4-core Linux larger runner at the same shape, which is 33 percent lower list price, and `warp-macos-latest-arm64-6x` at 6 vCPU and 14 GB costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14 GB, which is 22 percent lower list price. GitHub rates from [actions-minute-multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. Tooling beyond the runner: [snapshot runners](/docs/ci/features/snapshot-runners) that boot later jobs from a captured VM state, the [Action Debugger](/docs/ci/tools/action-debugger) for an SSH session into a paused job, [CI observability](/docs/ci/features/observability) with OpenTelemetry metrics correlated to job logs, an [MCP server](/docs/ci/mcp), and API access to every feature. SSO costs a flat $250 per month, whatever the user count ([pricing](/pricing)). Slack support channels are available on demand, and the SOC 2 Type 2 evidence is at [trust.warpbuild.com](https://trust.warpbuild.com/). Where it does not fit: personal GitHub accounts, since runners register into an organization's Default runner group; workloads needing more than 32 vCPU on a general runner; and the two GitHub-only classes, meaning the images carrying a Tesla T4 and the `windows-11-arm` images. ### [Blacksmith](#blacksmith)Copy Anchor Link Blacksmith publishes runner families for Linux x64 and ARM64 on Ubuntu 22.04 and 24.04 at five sizes each, Windows Server 2025 which their docs label public beta, and macOS on Apple Silicon in two sizes, per the [Blacksmith runners overview](https://docs.blacksmith.sh/blacksmith-runners/overview). Their docs state that Blacksmith is limited to GitHub organizations and is not available for personal repositories. Their architecture is a vendor-operated fleet: their [security page](https://www.blacksmith.sh/security) describes a fleet of 32 vCPU machines procured from data center providers in the US and EU, running jobs in Firecracker microVMs, and their runner docs describe maintaining their own homogeneous compute pool. A bring-your-own-cloud or customer-account deployment option was verified absent on 2026-08-13 across their pricing, security, and docs pages and their full docs sitemap. Their docs state no concurrency limits on any tier, which is parity with WarpBuild rather than a difference. Their pricing page publishes a 3,000 free minutes per month allowance per organization with no credit card required, denominated in x64 2 vCPU minutes with published conversion ratios for other families; see [blacksmith.sh/pricing](https://www.blacksmith.sh/pricing). Compliance is SOC 2 Type 2 and GDPR with a trust center, per their security page, which is parity with WarpBuild. Two facts worth checking against your requirements: authentication is through GitHub only, with no SAML or SCIM offering, verified absent on 2026-08-13 on their [settings docs](https://docs.blacksmith.sh/introduction/settings) and pricing page; and no product changelog is published, with [blacksmith.sh/changelog](https://www.blacksmith.sh/changelog) returning 404 on 2026-08-13 and no changelog URL in either sitemap. Their engineering blog is the nearest public shipping signal. On macOS, no Xcode version selector or Xcode version list is published anywhere in their docs, verified on 2026-08-13. ### [Namespace](#namespace)Copy Anchor Link Namespace runs Linux amd64 and arm64, Windows, and macOS, per their [GitHub Actions solution page](https://namespace.so/docs/solutions/github-actions). Windows runners reached general availability on 2026-08-05 and are excluded from their Developer plan, per their [changelog](https://namespace.so/changelog) and [pricing page](https://namespace.so/pricing). Their macOS offering is the deepest in this set. Their [macOS architecture docs](https://namespace.so/docs/architecture/compute/macos) publish four macOS versions, six machine shapes, Xcode selection through shape selectors, a bleeding-edge image channel, VNC access for macOS runners, and an image-update RSS feed. If macOS breadth is your single deciding factor, count their published shapes and Xcode selectors against any other vendor's before choosing. They run their own hardware. Their [datacenter blog post](https://namespace.so/blog/so-you-want-to-build-your-own-datacenter) states they have run more than 95 percent of their platform on their own infrastructure since early 2024\. There is no bring-your-own-cloud option; their [federation docs](https://namespace.so/docs/federation) cover workload identity federation rather than customer-account compute, verified absent on 2026-08-13. Two published constraints to weigh. Concurrency is capped per plan and denominated in vCPU, with the caps printed in the plan matrix on their [pricing page](https://namespace.so/pricing), and they ship explicit job-priority controls for the case where a workspace is hitting those caps. Data residency names no public regions on their [residency page](https://namespace.so/docs/workspaces/data-residency), regional preference goes through a support request, region-exclusive residency requires an enterprise plan, and that page states metadata is replicated globally. Their pricing model is a unit-minute model with per-platform multipliers plus separate storage meters, so model your own workload rather than reading the headline rate. ### [Depot](#depot)Copy Anchor Link Depot's center of gravity is container builds, with public per-commit benchmarks published at [depot.dev/benchmark](https://depot.dev/benchmark). Their GitHub Actions runners cover Linux x64 and ARM64, Windows Server 2022 and 2025, and macOS, per their [runner types docs](https://depot.dev/docs/github-actions/runner-types). Concurrency is unlimited on every plan, which is parity with WarpBuild. Their macOS offering is one hardware shape at 8 CPUs and 24 GB across three OS labels, and their own docs carry a capacity caveat worth reading before you commit a release pipeline to it: Apple licensing requires a minimum 24-hour instance lease, so their macOS pool is fixed rather than elastic, jobs are processed first in first out, and queueing occurs when demand exceeds the pool, per their [troubleshooting docs](https://depot.dev/docs/github-actions/troubleshooting). They offer reserved dedicated macOS capacity under an annual commitment as the mitigation. Depot Managed is their customer-account deployment: AWS only, gated to their Business plan, with Google Cloud described as under consideration and no Azure option, per the [Depot Managed overview](https://depot.dev/docs/managed/overview). No Terraform provider or module appears in their site index, verified absent on 2026-08-13\. Depot Cloud runs in us-east-1 and eu-central-1, per the FAQ on their [pricing page](https://depot.dev/pricing). One structural fact to factor into a multi-year decision: Depot also ships Depot CI, their own CI engine, which their docs describe as running workflows in x86\_64 sandboxes with no Arm, macOS, or Windows sandboxes, per the [Depot CI overview](https://depot.dev/docs/ci/overview). If your plan is to stay on GitHub Actions indefinitely, evaluate the runners product on its own terms. ### [Actions Runner Controller (ARC)](#actions-runner-controller-arc)Copy Anchor Link ARC is GitHub's open source Kubernetes operator for self-hosted runners, documented at [docs.github.com](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller) with the source at [actions/actions-runner-controller](https://github.com/actions/actions-runner-controller). The software is free. Runners are pods in a cluster you own, so the compute bill, the network placement, and the isolation policy are all yours to set. It is shipped as two Helm charts and one container image, published only as OCI packages. The GitHub-maintained runner image is Linux, built for `linux/amd64` and `linux/arm64` only, per the [runner image Dockerfile](https://github.com/actions/runner/blob/main/images/Dockerfile). ARC documentation does not cover macOS runners; that absence was verified across the ARC repository and GitHub's ARC documentation on 2026-08-13\. Windows support on the modern scale-set mode is an open issue rather than a shipped feature, tracked at [issue 1001](https://github.com/actions/actions-runner-controller/issues/1001). The honest frame for ARC is build against buy. You take on cluster operations, autoscaling configuration, runner image maintenance, and chart upgrades, and your support channel is the public issue tracker. Teams that already run a Kubernetes platform with an on-call rotation, or that have a policy against third-party runner vendors, get a genuinely good fit. Teams without that platform usually discover that the free software has a staffing cost. The full build-against-buy treatment is on [WarpBuild vs Actions Runner Controller](/compare/arc). ## [When to Stay on GitHub-Hosted Runners](#when-to-stay-on-github-hosted-runners)Copy Anchor Link Switching has a cost, and for a large share of repositories the honest recommendation is to stay put. * **Public repositories on standard runners.** GitHub states Actions usage is free for public repositories using standard GitHub-hosted runners, and those repositories get the 4 vCPU and 16 GB shapes rather than the 2 vCPU shapes, per the [Actions billing page](https://docs.github.com/en/billing/concepts/product-billing/github-actions) and the [hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). * **Private repositories inside included minutes.** 2,000 minutes a month on Free, 3,000 on Pro and Team, and 50,000 on Enterprise Cloud, reset monthly. If your Actions line item is already zero, no per-minute vendor improves it. * **Teams far below the concurrency caps.** If your peak is 15 concurrent jobs against a 60-job cap, the caps are not your problem. * **Workloads needing GitHub-only classes.** The larger runner classes carrying a Tesla T4 and the `windows-11-arm` images are GitHub-only among the options on this page, per the [larger runners reference](https://docs.github.com/en/actions/reference/runners/larger-runners) and [actions/runner-images](https://github.com/actions/runner-images). * **A single small repository with no platform owner.** Any managed provider adds an account, an app install, and a bill. If nobody owns GitHub Actions, that overhead outweighs the minutes saved. ## [How to Run the Evaluation](#how-to-run-the-evaluation)Copy Anchor Link A week is usually enough, because a runner swap is reversible in one commit. 1. Write down the constraint that sent you looking: a cap you hit, a bill line you cannot defend, a machine size you cannot get, or a residency requirement. One constraint, in one sentence. 2. Shortlist against the criteria above using each vendor's own published pages. Discard anything that fails on platform coverage or deployment model; those two cannot be worked around later. 3. Pick your slowest real workflow, not a synthetic one, and duplicate the job so one copy runs on GitHub-hosted runners and one on the candidate. 4. Read outcomes first, then wall-clock duration, then queue time. GitHub's [Actions performance metrics](https://docs.github.com/en/actions/concepts/metrics) break job run time, queue time, and failure rate down by runner type, which makes them the neutral place to read the result. 5. Model the monthly cost at a matched shape, including cache storage and any other separately metered add-ons, rather than comparing headline per-minute rates. 6. Decide, then revert the branch or roll the rest of the workflows over. The numbers that matter to your decision are the ones your own duplicated job produces. The WarpBuild path through that loop starts with the [migration page](/migrate/github-hosted-to-warpbuild), which has the label mapping, the cache swap, and the rollback steps. ## FAQ ### What is a GitHub Actions larger runner alternative? A runner provider that registers machines against your GitHub organization so a runs-on label sends the job to their fleet instead of GitHub's. GitHub documents its own larger runners at https://docs.github.com/en/actions/reference/runners/larger-runners; the alternatives on this page replace that fleet without changing the rest of your workflow. ### Do I have to leave GitHub Actions to use one? No. Every managed option on this page runs your existing GitHub Actions workflows. The runner label is the only required change. Actions Runner Controller is the self-managed option and also keeps you on GitHub Actions, running the runners as pods in your own Kubernetes cluster. ### Which alternatives run inside my own cloud account? WarpBuild runs managed runners in your AWS, GCP, or Azure account, documented at https://www.warpbuild.com/docs/ci/byoc. Depot Managed deploys into your AWS account on their Business plan, per https://depot.dev/docs/managed/overview. Actions Runner Controller runs entirely in your own Kubernetes cluster. Blacksmith and Namespace run all compute on their own fleets, verified on 2026-08-13. ### How should I compare prices between them? Compare at a matched shape (same vCPU and memory) rather than comparing headline rates, and check the billing unit, the plan floor, and whether add-ons such as cache storage are metered separately. Each vendor publishes rates on its own pricing page; the WarpBuild rates are at https://www.warpbuild.com/pricing. ### Is it worth switching if my GitHub Actions bill is already small? Often not. Standard GitHub-hosted runners are free on public repositories and every plan includes monthly Actions minutes, per https://docs.github.com/en/billing/concepts/product-billing/github-actions. The switching argument gets strong when you hit concurrency caps, pay for larger runners, run macOS, or need runners inside your own cloud. --- # Namespace Alternatives for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/alternatives/namespace Description: Five real alternatives to Namespace for GitHub Actions runners, with cited facts on coverage, concurrency, bring your own cloud, pricing structure and support. # Namespace Alternatives for GitHub Actions Five real alternatives to Namespace for GitHub Actions runners, with cited facts on coverage, concurrency, bring your own cloud, pricing structure and support. Last updated: 2026-08-13 Namespace is a strong product with a real hardware investment behind it, so most teams evaluating alternatives are not looking for a cheaper clone. They are looking for a provider that clears one specific constraint: runners inside their own cloud account, no hard concurrency ceiling, a named region for data residency, or a bill they can predict without a multiplier table. This page lists the alternatives that a GitHub Actions team actually shortlists, with every vendor fact linked to the vendor's own page and checked on 2026-08-13\. Head to head detail is on [WarpBuild vs Namespace](/compare/namespace), and the switching steps are on [migrating from Namespace to WarpBuild](/migrate/namespace-to-warpbuild). ## [Why Teams Look](#why-teams-look)Copy Anchor Link **Compute stays on the vendor fleet.** Namespace has run more than 95 percent of its platform on its own infrastructure since early 2024 ([their datacenter post](https://namespace.so/blog/so-you-want-to-build-your-own-datacenter)). Their federation documentation covers workload identity federation with AWS, GCP, OpenID and GitHub Actions rather than running compute in a customer account ([federation docs](https://namespace.so/docs/federation)), and a bring your own cloud option was verified absent on their [pricing page](https://namespace.so/pricing) and docs index on 2026-08-13\. Teams with a data boundary, a security review that asks where the build machine sits, or a committed cloud spend agreement run into this first. **Concurrency is capped per plan.** Their pricing page publishes vCPU denominated caps: 32 vCPU Linux and 12 vCPU macOS on Developer, 64 and 24 on Team, 160 and 48 on Business, and Custom on Enterprise, with maximum instance durations of 3 hours, 5 hours and up to 24 hours ([pricing](https://namespace.so/pricing)). Their runner configuration docs ship a `job.priority` control that, in their own words, only affects scheduling while running into concurrency limits ([runner configuration](https://namespace.so/docs/reference/github-actions/runner-configuration)). A wide test matrix on the entry plan meets that ceiling quickly. **Data residency runs through support.** Their residency page states that workspace data may be processed across their global infrastructure by default, that a regional preference is set by contacting their support team, and that region exclusive residency requires an enterprise plan, with workflow metadata replicated globally either way ([data residency docs](https://namespace.so/docs/workspaces/data-residency)). No region is named on that page. **The bill has many parts.** Compute is billed in units where one unit is 1 vCPU and 2 GB of RAM for a minute times a platform multiplier, published as 1 for Linux, 2 for Windows, 10 for macOS and 7 for Linux on Apple Silicon, with a further 1.25x unit multiplier on 64 vCPU instances and a `max(vCPU count, GB RAM divided by 2)` formula for non-standard shapes ([pricing](https://namespace.so/pricing), [machine shapes](https://namespace.so/docs/architecture/compute/machine-shapes)). Their invoice line item list runs to twelve meters and storage is billed in four different units ([billing and limits](https://namespace.so/docs/workspaces/billing-and-limits)). **Single sign-on is enterprise gated with no published price.** SAML is available for enterprise customers, and their pricing matrix marks Enterprise SSO as an Enterprise row with no figure ([workspace access docs](https://namespace.so/docs/workspaces/access), [pricing](https://namespace.so/pricing)). **The free tier is a trial.** Namespace offers a 30-day free trial of the Developer plan with no credit card required. A permanent free allowance was verified absent on their [pricing page](https://namespace.so/pricing) on 2026-08-13. ## [What to Evaluate](#what-to-evaluate)Copy Anchor Link Score a shortlist on the dimensions that change the answer, in this order: 1. **Where the runners run.** Vendor fleet, your cloud account, or your own Kubernetes cluster. This is the hardest constraint to work around later. 2. **Concurrency policy.** Published caps, and whether they are denominated in jobs or vCPU. Multiply your peak fan-out by your runner size and compare. 3. **Platform coverage.** Linux x64, Linux ARM64, macOS and Windows, plus which plans gate which platform. 4. **macOS depth if you ship Apple software.** Count the Xcode versions and SDKs your app targets first, then the simulator runtimes your tests need, then host OS versions, machine shapes and Xcode selection mechanics. Check both sides before believing any vendor's summary, including this one. 5. **Pricing shape.** Base fee, seat fee, number of meters, and whether a per minute rate is published per machine label. 6. **Data residency.** Which regions are nameable, whether residency is self-serve, and what metadata leaves the region. 7. **Cache architecture.** Local disk volumes, a network cache API, or the GitHub Actions cache protocol, and what the first cold week looks like. 8. **Exit cost.** How many workflow lines change on the way in, and how many change on the way out. ## [The Alternatives](#the-alternatives)Copy Anchor Link The first evaluation question sorts this list faster than any feature grid. | Option | Where the runners run | Who operates them | Current details | | ------------------------- | -------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Namespace | Namespace-owned fleet | Namespace | [namespace.so/pricing](https://namespace.so/pricing) | | WarpBuild | WarpBuild fleet, or your own AWS, GCP or Azure account | WarpBuild | [WarpBuild pricing](/pricing) | | Blacksmith | Blacksmith-owned fleet | Blacksmith | [blacksmith.sh/pricing](https://www.blacksmith.sh/pricing) | | Depot | Depot fleet, with a documented managed deployment option | Depot | [depot.dev/pricing](https://depot.dev/pricing) | | GitHub larger runners | GitHub-hosted infrastructure | GitHub | [GitHub billing docs](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions) | | Actions Runner Controller | Your own Kubernetes cluster | You | [actions-runner-controller](https://github.com/actions/actions-runner-controller) | Vendor pages change. Each link above is where that vendor states its current terms, and every fact on this page was checked against them on 2026-08-13. ### [WarpBuild](#warpbuild)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS and Windows runners, plus remote Docker builders, snapshot runners, CI observability, an MCP server and the Action Debugger ([runner catalog](/docs/ci/cloud-runners), [docs](/docs/ci/what-is-warpbuild)). Bring your own cloud runs on AWS, GCP and Azure, with the runners in your account and your VPC, and Terraform support covers BYOC on AWS ([BYOC docs](/docs/ci/byoc), [Terraform docs](/docs/ci/api-keys/terraform)). US and EU are the published regions. Generally available Linux and Windows runners do not have plan-level concurrency caps. Pricing is purely usage based with no base subscription fee, no platform fee and no seat fee, and every runner label carries a published per minute rate ([pricing](/pricing)). Signup includes $10 in free credits. Single sign-on covers SAML 2.0 and OIDC with self-serve identity provider configuration ([SSO docs](/docs/sso)) for a flat $250 per month, whatever the user count ([pricing](/pricing)). Slack support channels are available on demand, and the compliance posture is SOC 2 Type 2 with evidence at [trust.warpbuild.com](https://trust.warpbuild.com/). On the enterprise tier, runners pull large images and artifacts from ECR, S3 and similar stores with zero egress cost to you, whether your runners live in your cloud or ours. Pick WarpBuild when the runners have to sit in your own cloud account, when a concurrency ceiling would queue your jobs, or when you want one per minute rate per label. ### [Blacksmith](#blacksmith)Copy Anchor Link [Blacksmith](https://www.blacksmith.sh/) runs managed GitHub Actions runners on its own bare metal fleet, adopted with a `runs-on` label change. They publish usage based pricing and a free monthly minute allowance on their [pricing page](https://www.blacksmith.sh/pricing) and document their runner types and features in their [docs](https://docs.blacksmith.sh/). Their public proof inventory and content cadence are among the strongest in the category. Consider Blacksmith when you want a managed fleet with a fast adoption path and no interest in running anything in your own cloud. Detail is on [WarpBuild vs Blacksmith](/compare/blacksmith). ### [Depot](#depot)Copy Anchor Link [Depot](https://depot.dev/) started with remote container builds and added GitHub Actions runners alongside them. Their runner catalog and machine types are documented in [runner types](https://depot.dev/docs/github-actions/runner-types), their plans and meters on their [pricing page](https://depot.dev/pricing), and they publish per commit build benchmarks on their own site. Consider Depot when container image builds dominate your pipeline and you want the build service and the runners from the same vendor. Detail is on [WarpBuild vs Depot](/compare/depot). ### [GitHub larger runners](#github-larger-runners)Copy Anchor Link GitHub sells [larger hosted runners](https://docs.github.com/en/actions/using-github-hosted-runners/using-larger-runners) with more vCPU than the default hosted machines, configured at the organization or enterprise level and billed per minute at rates GitHub publishes on its [billing page](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions). Setup is the shortest of any option here, the integration is native, and public repositories keep their free minutes on standard hosted runners. Consider staying on GitHub when your pipeline is small, when procurement of another vendor costs more than the build time saves, or when you need a platform only GitHub offers. Detail is on [WarpBuild vs GitHub-hosted runners](/compare/github-actions). ### [Actions Runner Controller](#actions-runner-controller)Copy Anchor Link [Actions Runner Controller](https://github.com/actions/actions-runner-controller) is the open source Kubernetes controller that scales ephemeral self-hosted runners inside your own cluster, documented by GitHub in [their self-hosted runner docs](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners-with-actions-runner-controller/about-actions-runner-controller). The software is free. The cost is cluster operations, autoscaling configuration, runner image maintenance and upgrades, carried by your platform team. Consider ARC when you already operate Kubernetes at scale, or when policy forbids a third party running your builds. Detail is on [WarpBuild vs ARC](/compare/arc). ## [How WarpBuild Compares](#how-warpbuild-compares)Copy Anchor Link Against Namespace specifically, WarpBuild wins on the constraints that send teams looking in the first place. BYOC on AWS, GCP and Azure against a vendor-only fleet. No hard concurrency caps against published vCPU caps per plan. Named US and EU regions against a support ticket for regional preference. One usage based model with no base fee against compute units, platform multipliers and twelve invoice meters. $10 in free credits against a 30-day trial. Single sign-on at a flat $250 per month, whatever the user count, against an enterprise-gated SAML row with no published price. On macOS, the WarpBuild advantage is the current Apple toolchain in two sizes per chip. The macOS 26 image ships Xcode 27.0 with the macOS 27 SDKs and the iOS 27.0, tvOS 27.0, watchOS 27.0 and visionOS 27.0 simulator runtimes in 6 vCPU and 12 vCPU sizes ([runner catalog](/docs/ci/cloud-runners)), so the current Apple SDK targets compile today. GitHub's upstream macOS 27 runner image is in beta, and a dedicated WarpBuild macOS 27 image follows once that image is released. Namespace publishes the wider host catalog: four macOS versions and six machine shapes on bare metal Apple Silicon, with Xcode selectors including a beta channel ([macOS docs](https://namespace.so/docs/architecture/compute/macos)). Namespace also offers Linux on Apple Silicon, which nothing else in this set does, and a set of named cache products covering Bazel, Turborepo, Gradle and sccache. Both vendors publish SOC 2 Type II, both charge no seat fee, and both ship interactive debugging into a running job. Those dimensions are a tie, and a page that claimed otherwise would be worth less to you. WarpBuild macOS runners come in multiple sizes and configurations per chip, 6 vCPU with 14 GB and 12 vCPU with 28 GB, at $0.08 and $0.16 per minute ([runner catalog](/docs/ci/cloud-runners)). The price comparison that follows runs against GitHub-hosted runners. `warp-macos-latest-arm64-6x` at 6 vCPU and 14 GB costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14 GB, so 0.022 divided by 0.102 is 22 percent lower list price on a bigger shape. GitHub rate from [actions-minute-multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. Rates for the other providers on this page live on their own pricing pages, linked above, and are not restated here. Named case studies with numbers are on the [customers page](/customers). SonarQube runs tens of thousands of GitHub Actions jobs per day on WarpBuild BYOC runners in its own AWS account across Windows and Linux, Adaptive reports a 60 percent reduction in manual engineering intervention per feature after making WarpBuild snapshot runners the default environment for its AI code agents ([Adaptive case study](/customers/adaptive)), and Braintrust, Comcast, Sky and LanceDB are on the [homepage logo wall](https://www.warpbuild.com/). The cheapest way to settle an evaluation is to run one workflow on each shortlisted provider for a week. The label is the only line that changes, and the change reverses in the same pull request. ## FAQ ### Why do teams look for a Namespace alternative? The three reasons that show up in evaluations are the absence of a bring your own cloud option, the published per plan concurrency caps denominated in vCPU, and the compute unit pricing model with platform multipliers and twelve invoice meters. All three are stated on their own pages at namespace.so/pricing, namespace.so/docs/federation and namespace.so/docs/workspaces/billing-and-limits, checked 2026-08-13. ### Which alternatives run GitHub Actions runners inside my own cloud account? WarpBuild runs managed runners in your AWS, GCP or Azure account with Terraform support on AWS. Actions Runner Controller runs in your own Kubernetes cluster and you operate it yourself. Depot documents a managed deployment option, so check depot.dev/pricing for its current cloud and plan scope. Namespace runs every job on their own fleet, verified on namespace.so/pricing and namespace.so/docs/federation on 2026-08-13. ### Do any alternatives match Namespace on macOS? Not on the host catalog. Namespace publishes four macOS versions and six macOS machine shapes up to 12 vCPU and 56 GB on bare metal Apple Silicon at namespace.so/docs/architecture/compute/macos, checked 2026-08-13, which is the widest host catalog in this set. WarpBuild ships macOS 14, 15 and 26 in two sizes per chip, and its macOS 26 image carries Xcode 27.0 with the macOS 27 SDKs and iOS 27.0, tvOS 27.0, watchOS 27.0 and visionOS 27.0 simulator runtimes, so the current Apple SDK targets compile today (warpbuild.com/docs/ci/cloud-runners, checked 2026-08-13). GitHub's upstream macOS 27 runner image is in beta, and a dedicated WarpBuild macOS 27 image follows once that image is released. GitHub publishes its own hosted macOS runner list. ### Is there an alternative with no concurrency limits? Generally available WarpBuild Linux and Windows runners do not have plan-level concurrency caps. Namespace publishes vCPU caps per plan at namespace.so/pricing, checked 2026-08-13, and GitHub publishes per-plan concurrency limits for hosted runners in its own documentation. Check each vendor's current page before deciding, since caps change with plan structure. ### How long does switching providers take? The runs-on label is a one line change per job, so the mechanical switch is a single pull request. The real work is the vendor specific actions and cache steps a workflow accumulates. The Namespace inventory and its WarpBuild equivalents are listed at /migrate/namespace-to-warpbuild. --- # GitHub Actions Answers | WarpBuild URL: https://www.warpbuild.com/answers Description: Direct, sourced answers to common GitHub Actions questions about runners, caching, billing, and configuration. # GitHub Actions Answers Direct answers to common GitHub Actions questions, each backed by linked sources. * [Am I Billed When a Job Fails?Yes. A failed GitHub Actions job bills every minute its machine ran, so the lever is failing fast: matrix fail-fast, job timeouts, and cheap checks first.](/answers/am-i-billed-when-a-github-actions-job-fails) * [Are Build Secrets Visible to the Runner Platform?No. GitHub holds your secrets and injects them into the job environment, and WarpBuild documents that it does not access or store any build secrets.](/answers/are-build-secrets-visible-to-the-runner-platform) * [Are GitHub Actions Free for Open Source Repositories?Yes on standard GitHub-hosted runners: public repository minutes cost nothing. Managed runners work there once the Default runner group allows public repos.](/answers/are-github-actions-free-for-open-source) * [Are GitHub Actions Free for Private Repositories?No. Private repositories get a monthly GitHub Actions minute allowance per plan and pay per minute past it. WarpBuild Linux runners start at $0.004.](/answers/are-github-actions-free-for-private-repositories) * [Are Managed GitHub Actions Runners Secure?Yes, when each job gets its own ephemeral virtual machine. WarpBuild creates a VM per job, destroys it after the build, and holds SOC 2 Type 2 attestation.](/answers/are-github-actions-runners-secure) * [Are Snapshot Runners Safe for Public Repositories?Yes, with one rule: keep credentials off the snapshot disk. The documentation warns that a pull request run can boot from your alias on a public repository.](/answers/are-snapshot-runners-safe-for-public-repositories) * [Can AI Agents Run GitHub Actions Jobs?Yes. AI agents start GitHub Actions workflows like any other actor. What matters is the environment the job boots into and the feedback loop it returns.](/answers/can-ai-agents-run-github-actions-jobs) * [Can an AI Agent Query My GitHub Actions Metrics?Yes. WarpBuild hosts an MCP server at mcp.warpbuild.com/mcp that exposes the WarpBuild API to an editor assistant, so it reads job, runner, and cost data.](/answers/can-an-ai-agent-query-my-github-actions-metrics) * [Can BYOC Runners Reach Internal Services?Yes. BYOC runners are ephemeral EC2 instances inside your own VPC, so reaching an internal database, registry, or API follows your own routing rules.](/answers/can-byoc-runners-reach-my-internal-services) * [Can GitHub Actions Build iOS Apps Without a Mac?No. An iOS archive needs Xcode, and Xcode runs only on macOS, so the job lands on a macOS runner such as warp-macos-latest-arm64-6x at $0.08 a minute.](/answers/can-github-actions-build-ios-apps-without-a-mac) * [Can GitHub Actions Build Multi Architecture Images?Yes. GitHub Actions builds multi architecture images natively across x64 and ARM64 runners, or in one job on a builder profile with both enabled.](/answers/can-github-actions-build-multi-arch-images) * [Can GitHub Actions Run an Android Emulator?Yes. GitHub Actions runs a hardware-accelerated Android emulator on Linux x86-64 runners that expose /dev/kvm, once the KVM udev permission step is added.](/answers/can-github-actions-run-android-emulator) * [Can GitHub Actions Run on ARM64?Yes. GitHub Actions runs natively on ARM64 runners. Point runs-on at an ARM64 label such as warp-ubuntu-latest-arm64-4x, billed at $0.006 per minute.](/answers/can-github-actions-run-arm64) * [Can GitHub Actions Run Windows Builds?Yes. GitHub Actions runs Windows builds on x86-64 Windows runners. Point runs-on at a label such as warp-windows-latest-x64-8x, billed at $0.032 per minute.](/answers/can-github-actions-run-windows-builds) * [Can I Still Build on Windows Server 2022?Yes. Windows Server 2022 stays in the WarpBuild catalog on the warp-windows-latest-x64 and warp-windows-2022-x64 labels, priced from $0.016 per minute.](/answers/can-github-actions-run-windows-server-2022-builds) * [Can GitHub Actions Runners Reach My Private Network?Yes. The networking addon joins each WarpBuild runner to your Tailscale tailnet at job start as an ephemeral node that is removed when the job ends.](/answers/can-github-actions-runners-access-my-private-network) * [Can GitHub Actions Runners Run in My Own Cloud Account?Yes. WarpBuild BYOC runs GitHub Actions runners inside your own AWS, GCP, or Azure account, your cloud bills the compute, WarpBuild charges $0.002 per minute.](/answers/can-github-actions-runners-run-in-my-own-cloud) * [Can I Alert When a Runner Runs Out of Memory?Yes. Resource threshold alerts flag every runner whose max memory utilization reaches 80 percent, and the signal worth acting on is sustained pressure.](/answers/can-i-alert-when-a-runner-runs-out-of-memory) * [Can I Attach an IAM Role to a BYOC Runner?Yes. AWS BYOC runners take an IAM instance profile per runner class, so GitHub Actions jobs receive scoped AWS credentials without an access key in a secret.](/answers/can-i-attach-an-iam-role-to-a-byoc-runner) * [Can I Build ARM64 Images on an x64 Runner?Yes, through QEMU emulation of aarch64 instructions. A native ARM64 label such as warp-ubuntu-latest-arm64-8x at $0.012 per minute skips the translation.](/answers/can-i-build-arm64-docker-images-on-an-x64-runner) * [Can I Cache a Built Image Instead of Rebuilding It?Yes. Push the image once and pull it by digest in later jobs, which usually beats saving a tar through the cache. The byte math, the YAML, the exceptions.](/answers/can-i-cache-a-docker-image-instead-of-rebuilding-it) * [Can I Keep a Database Warm Between Jobs?Yes. Snapshot a WarpBuild Ubuntu runner once the database is seeded, and later jobs boot with the data directory already on disk instead of importing it.](/answers/can-i-keep-a-database-warm-between-github-actions-jobs) * [Can I Keep Builds in the EU Only?Yes for build data. US and EU are the regions WarpBuild names publicly, and BYOC puts the runner, its disk, and the cache bucket in an EU region you own.](/answers/can-i-keep-builds-in-the-eu-only) * [Can I Keep DerivedData Between Runs?Yes. Cache the DerivedData directory keyed on your lockfile and Xcode version, or keep prepared machine state. What restores safely and what breaks.](/answers/can-i-keep-derived-data-between-github-actions-runs) * [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.](/answers/can-i-mix-github-hosted-and-self-hosted-runners) * [Can I Restrict Outbound Traffic From Runners?Yes. Narrow the egress rules on your BYOC runner security group, as long as runners keep reaching the WarpBuild control plane, GitHub, and your registries.](/answers/can-i-restrict-outbound-traffic-from-byoc-runners) * [Can I Restrict Which Repositories Use My Runners?Yes. A GitHub runner group decides which repositories may claim a set of runners, and the WarpBuild connection list adds a second allowlist in front of it.](/answers/can-i-restrict-which-repositories-use-my-runners) * [Can I Build 32-bit ARM Targets on ARM64 Runners?Yes. Cross compile with an arm-linux-gnueabihf toolchain on an ARM64 runner such as warp-ubuntu-latest-arm64-4x at $0.006 per minute, then test on hardware.](/answers/can-i-run-32-bit-arm-builds-on-arm64-runners) * [Can Android and iOS Builds Share One Workflow?Yes. runs-on is a per-job setting, so an Android job on a Linux label and an iOS job on a macOS label live in the same GitHub Actions workflow file.](/answers/can-i-run-android-and-ios-builds-in-the-same-workflow) * [Can ARM64 and x64 Jobs Run in One Workflow?Yes. runs-on is set per job, so one matrix leg can run on warp-ubuntu-latest-arm64-8x at $0.012 per minute and another on an x64 label in the same run.](/answers/can-i-run-arm64-and-x64-jobs-in-the-same-workflow) * [Can I Run BYOC in More Than One Region?Yes. Create one BYOC stack per region under the same cloud connection, add custom runners to each stack, and the runs-on label picks the region a job lands in.](/answers/can-i-run-byoc-in-more-than-one-region) * [Can BYOC Runners Run in a Private Subnet?Yes. BYOC runners launch in private subnets when static IPs are on, provided route tables and security group rules let each instance reach its destinations.](/answers/can-i-run-byoc-runners-in-a-private-subnet) * [Can I Run Docker Compose in GitHub Actions?Yes, on a Linux runner. Docker Compose v2 ships in the Ubuntu runner images, and stacks fail on runner memory and CPU rather than on compose itself.](/answers/can-i-run-docker-compose-in-github-actions) * [Can I Run Docker on Windows Runners?Yes, for Windows containers. Windows GitHub Actions runners run a Windows Docker daemon, so Linux images need a Linux warp- runner or a remote Docker builder.](/answers/can-i-run-docker-on-windows-github-actions-runners) * [Can I Run macOS Runners in the EU?US and EU are the regions WarpBuild names publicly, and region selection applies to the runner fleet your org is assigned. macOS instances sit outside it.](/answers/can-i-run-macos-runners-in-the-eu) * [Can I Run More Jobs Than My Plan Allows?Yes. The concurrent job ceiling on your GitHub plan counts jobs on GitHub-hosted labels only, so jobs routed to warp- labels queue on WarpBuild capacity.](/answers/can-i-run-more-jobs-than-my-github-plan-allows) * [Can I Run Unit Tests in Parallel on Windows Runners?Yes. Raise the test runner's worker count on one larger Windows runner, or shard the suite across several jobs. The two shapes carry different cost profiles.](/answers/can-i-run-unit-tests-in-parallel-on-windows-runners) * [Can I Set a Spending Limit on GitHub Actions Runners?A spending limit on GitHub Actions runners lives in the workflow file: runner labels, timeout-minutes, concurrency groups, and path filters cap billed minutes.](/answers/can-i-set-a-spending-limit-on-github-actions-runners) * [Can I Share a Cache Between GitHub Actions Workflows?Yes. Cache entries are addressed by key, version, and branch rather than by workflow, so any workflow in the same repository can restore what another saved.](/answers/can-i-share-a-cache-between-github-actions-workflows) * [Can I Snapshot a Runner on a Feature Branch?Yes. Publish a clean snapshot on the default branch and let feature branches boot from it, or give a branch its own alias and sweep it on close.](/answers/can-i-snapshot-a-runner-on-a-feature-branch) * [Can I SSH Into a GitHub Actions Runner?Yes. Add the open-source Action Debugger step to a job and the workflow pauses, an SSH session opens on the runner, and the URL lands in the logs.](/answers/can-i-ssh-into-a-github-actions-runner) * [Can You Run Docker on macOS GitHub Actions Runners?No. macOS GitHub Actions runners cannot run Docker and have no nested virtualization. Send container steps to a Linux warp- label or a remote Docker builder.](/answers/can-i-use-docker-on-macos-runners) * [Can I Use My Existing VPC With BYOC Runners?Yes. Import mode on AWS builds the BYOC stack against your existing VPC, subnets, and security group once they meet the documented network prerequisites.](/answers/can-i-use-my-own-vpc-with-byoc) * [Can I Use Podman Instead of Docker in GitHub Actions?Yes, on Linux runners, for building and running OCI images. The tradeoff is that Buildx cache backends and remote builders stay Docker-side features.](/answers/can-i-use-podman-instead-of-docker-in-github-actions) * [Can Two Jobs Share One Docker Builder?Yes. Jobs that name the same builder profile land on one builder VM, share its layer cache, and bill as one session. Concurrency, sizing, and cost.](/answers/can-two-jobs-share-one-docker-builder) * [Do ARM64 Runners Support Docker Buildx?Yes. Buildx runs on ARM64 GitHub Actions runners and builds linux/arm64 natively, and an ARM64 job can attach a remote Docker builder just as an x64 job does.](/answers/do-arm64-runners-support-docker-buildx) * [Do ARM64 Runners Use a Different Work Directory?Yes. Ubuntu 24.04 ARM64 runners set the work directory to /runner/\_work instead of the /home/runner/work path GitHub-hosted runners use. Read GITHUB\_WORKSPACE.](/answers/do-arm64-runners-use-a-different-work-directory) * [Do BYOC Runners Need a NAT Gateway?Only when the runners sit in private subnets. BYOC runners need an outbound route to the documented destinations, and on AWS NAT gateways provide it.](/answers/do-byoc-runners-need-a-nat-gateway) * [Do Caches Carry Across Branches?Caches carry in one direction. A run reads entries from its own branch and from the default branch, so a feature branch starts from whatever main last wrote.](/answers/do-caches-carry-across-branches-in-github-actions) * [Do GitHub Actions Caches Work Across Architectures?No. An x64 cache entry restored into an ARM64 job hands that job binaries it cannot run. Put runner.arch in the key and in every restore-keys prefix.](/answers/do-github-actions-caches-work-across-architectures) * [Do Caches Work on Windows GitHub Actions Runners?No. WarpBuild caching is documented as unsupported on Windows runners. Windows jobs cache through actions/cache@v4 or a build-tool cache in the workspace.](/answers/do-github-actions-caches-work-on-windows-runners) * [How Much Disk Space Do GitHub Actions Runners Have?WarpBuild runners carry 150GB SSD on every Linux x64 and ARM64 label, 256GB SSD on Windows labels, and 120GB or 270GB on macOS. Builder profiles reach 2TB.](/answers/do-github-actions-runners-have-large-disks) * [Do GitHub Actions Runners Keep State Between Jobs?No. Each job gets a fresh ephemeral VM whose storage is destroyed when the runner terminates, unless a snapshot runner boots the job from saved VM state.](/answers/do-github-actions-runners-keep-state-between-jobs) * [Do GitHub Actions Runners Store My Source Code?No. A WarpBuild runner holds your checkout on an encrypted volume destroyed after the build. Cache entries, BYOC bucket objects, and snapshots are what persist.](/answers/do-github-actions-runners-store-my-source-code) * [Do GitHub Actions Runners Support macOS?Yes. GitHub Actions runs macOS jobs on ARM64 runners such as warp-macos-15-arm64-6x, which carries 6 vCPU and 14GB of memory at $0.08 per minute.](/answers/do-github-actions-runners-support-macos) * [Do I Need Buildx in GitHub Actions?Yes for multi-platform images and cross-run cache backends, no for a single-platform build. What buildx adds, and what a remote builder profile removes.](/answers/do-i-need-buildx-in-github-actions) * [Do I Need to Rewrite My Workflow for Faster Runners?No. The runs-on label is the only required change, because WarpBuild runner images carry the same tooling as GitHub-hosted runners and steps run as written.](/answers/do-i-need-to-change-my-workflow-to-use-faster-runners) * [Do I Pay a Seat Fee for GitHub Actions Runners?No. WarpBuild GitHub Actions runners bill purely by usage, with no base subscription fee, no platform fee, and no seat fee at any engineering headcount.](/answers/do-i-pay-a-seat-fee-for-github-actions-runners) * [Do I Pay for Both the Runner and the Builder?Yes. The GitHub Actions runner bills per minute and the remote Docker builder bills per session, on two separate lines. Both rate tables and a worked job.](/answers/do-i-pay-for-both-the-runner-and-the-docker-builder) * [Do I Pay for Queue Time on GitHub Actions Runners?No. Billing starts when a job begins running on a machine, so GitHub Actions queue time never reaches the invoice and shows up as engineer wait time.](/answers/do-i-pay-for-queue-time-on-github-actions-runners) * [Is Xcode Preinstalled on macOS Runners?Yes. Xcode ships inside the macOS runner image, so a job compiles with no download step. What else is preinstalled, and what still installs per job.](/answers/do-macos-github-actions-runners-have-xcode-preinstalled) * [Do Public Repositories Pay for Runners?Public repositories pay nothing for standard GitHub-hosted minutes and pay a per-minute rate for managed runners, once an owner allows public repos.](/answers/do-public-repositories-pay-for-runners) * [Do Remote Docker Builders Support Multi Platform Builds?Yes. Each platform gets its own builder instance joined into one Buildx instance, so amd64 and arm64 compile natively and each bills as a separate session.](/answers/do-remote-docker-builders-support-multi-platform-builds) * [Do Runner Groups Limit Concurrency?No. A runner group decides which repositories may claim a set of runners, so a bad group leaves jobs unclaimed with an empty log rather than merely slow.](/answers/do-runner-groups-limit-concurrency) * [Do Snapshot Runners Replace the Cache?No. A cache action moves selected directories over the network and a snapshot boots a prepared machine. When each one fits, what each costs, how to run both.](/answers/do-snapshot-runners-replace-actions-cache) * [Do Snapshot Runners Work on macOS?No. Snapshot runners are documented for Linux x64 and Linux ARM64 only, so a macOS job keeps warm state through cached DerivedData and package directories.](/answers/do-snapshots-work-on-macos-runners) * [Do Windows Runners Support Nested Virtualization?No. WarpBuild Windows runners have no /dev/kvm. Nested virtualization is documented for cloud Linux x86-64 runners through a dynamic runs-on label.](/answers/do-windows-github-actions-runners-support-nested-virtualization) * [Does BYOC Support Custom VM Images?Yes. WarpBuild BYOC runs your own VM images on AWS, GCP, and Azure once they meet the documented requirements, and a custom runner selects the image.](/answers/does-byoc-support-custom-vm-images) * [Does BYOC on GCP Support Local SSD?Yes. GCP BYOC runners detect and mount bundled Local SSDs at boot on Linux images, so the Compute Engine machine type you pick decides whether a job gets one.](/answers/does-byoc-support-gcp-local-ssd) * [Does BYOC Support Windows Runners?Yes. WarpBuild BYOC runs Windows Server 2022 x86-64 runners on AWS and Azure, and the AWS docs set higher instance and disk minimums for Windows than Linux.](/answers/does-byoc-support-windows-runners) * [Does BYOC Work With IMDSv2 Required?Yes. Require IMDSv2 is a documented per-runner setting on AWS BYOC custom runners, so GitHub Actions jobs read instance credentials only with a session token.](/answers/does-byoc-work-with-imdsv2) * [Does Caching Work Inside a Container Job?Yes, with wget and zstd in the image and the verification token passed into the container. The usual failure is a cache path that resolves to the wrong home.](/answers/does-caching-work-inside-a-container-job) * [Does GitHub Actions Support Apple Silicon Runners?Yes. GitHub Actions macOS runners are ARM64 on Apple Silicon. A label such as warp-macos-15-arm64-6x gives 6 vCPU and 14GB of memory at $0.08 a minute.](/answers/does-github-actions-support-apple-silicon-runners) * [Do Managed Runners Change My GitHub Permissions Model?No. Managed runners install as a GitHub App with a scoped permission set, and job level access stays governed by GITHUB\_TOKEN and your permissions block.](/answers/does-using-managed-runners-change-my-github-permissions-model) * [Is Visual Studio Preinstalled on Windows Runners?Yes. Every WarpBuild Windows runner image ships Visual Studio Enterprise. The vs2026 labels carry the 2026 edition, the other Windows labels carry 2022.](/answers/does-visual-studio-come-preinstalled-on-github-actions-runners) * [How Are Snapshot Storage Costs Billed?Snapshot storage bills $0.025 per snapshot-hour for every hour a snapshot exists, so the count of live aliases and the 15 day lifetime set the monthly total.](/answers/how-are-snapshot-runner-storage-costs-billed) * [How Do I Attach Build Provenance to an Image?Set provenance mode=max on the build and push step so the builder attaches the attestation, then sign the pushed digest from the workflow with actions/attest.](/answers/how-do-i-attach-build-provenance-in-github-actions) * [How Do I Audit Runner Configuration Changes?WarpBuild records every runner configuration change in the organization's audit log, with the user or API key owner that made it, so dashboard and API changes are both attributable.](/answers/how-do-i-audit-who-changed-runner-settings) * [How Do I Authenticate to a Private Registry?Run one login step in the job before anything pulls or pushes an image, using a registry token from secrets or short lived credentials from an OIDC exchange.](/answers/how-do-i-authenticate-to-a-private-registry-in-github-actions) * [How Do I Authenticate to AWS With OIDC?Grant id-token write in the job, exchange the signed token with AWS STS for temporary credentials, and pin the role trust policy to one repository and branch.](/answers/how-do-i-authenticate-to-aws-from-github-actions-with-oidc) * [How Do I Build a Rust Binary for Multiple Targets?Use one GitHub Actions matrix pairing each Rust target triple with a runner label, building natively where a runner exists and cross compiling where none does.](/answers/how-do-i-build-a-rust-binary-for-multiple-targets) * [How Do I Build and Push in One GitHub Actions Step?Set push to true on the build and push action and feed it tags computed earlier in the same job, so BuildKit exports each layer straight to the registry.](/answers/how-do-i-build-and-push-in-one-github-actions-step) * [How Do I Cache Bundler Gems?Point bundler at vendor/bundle, cache that directory keyed on Gemfile.lock plus the Ruby version, and install in deployment mode. Workflow YAML and costs.](/answers/how-do-i-cache-bundler-gems-in-github-actions) * [How Do I Cache Cargo Dependencies?Cache the registry and git directories under Cargo home plus the target directory, keyed on Cargo.lock and the toolchain version. Paths, YAML, and costs.](/answers/how-do-i-cache-cargo-dependencies-in-github-actions) * [How Do I Cache CocoaPods in GitHub Actions?Cache the Pods directory and the CocoaPods download caches with a key from Podfile.lock, then run pod install --deployment so a stale lockfile fails the job.](/answers/how-do-i-cache-cocoapods-in-github-actions) * [How Do I Cache Composer Dependencies?Cache the Composer files directory, keyed on composer.lock, so composer install copies archives off local disk instead of downloading them. Paths, YAML, costs.](/answers/how-do-i-cache-composer-dependencies-in-github-actions) * [How Do I Cache Docker Layers in GitHub Actions?Point the build at a remote Docker builder whose layer cache persists on its own disk, or export layers to a registry cache backend if you stay on the runner.](/answers/how-do-i-cache-docker-layers-in-github-actions) * [How Do I Cache Go Modules in GitHub Actions?Cache \~/go/pkg/mod under a key hashed from go.sum, and cache \~/.cache/go-build under a key that rolls every commit with restore-keys, as two steps.](/answers/how-do-i-cache-go-modules-in-github-actions) * [How Do I Cache Gradle Builds in GitHub Actions?Run the Gradle setup action so Gradle User Home, wrapper distributions and build caches restore on every job. Configuration, cache keys, limits and costs.](/answers/how-do-i-cache-gradle-builds-in-github-actions) * [How Do I Cache Maven Dependencies?Cache \~/.m2/repository with a key from hashFiles over every pom.xml, then pin resolution with -nsu and -C so a partial restore cannot change versions.](/answers/how-do-i-cache-maven-dependencies-in-github-actions) * [How Do I Cache node\_modules in GitHub Actions?Cache the package manager store instead of node\_modules: point WarpBuilds/setup-node@v6 at your lockfile with the cache input and let the action key it.](/answers/how-do-i-cache-node-modules-in-github-actions) * [How Do I Cache pip Dependencies in GitHub Actions?Caching is off by default on the Python setup action. Set cache to pip, pipenv, or poetry and point cache-dependency-path at the file that changes.](/answers/how-do-i-cache-pip-dependencies-in-github-actions) * [How Do I Cache Playwright Browsers?Cache the Playwright browser download directory with a key built from the installed Playwright version, and install the system libraries in a separate step.](/answers/how-do-i-cache-playwright-browsers-in-github-actions) * [How Do I Cache Swift Package Manager Dependencies?Cache .build and the shared SwiftPM directory with a key from hashFiles on Package.resolved, on the same macOS job that runs swift build. YAML and costs.](/answers/how-do-i-cache-swift-package-manager-dependencies) * [How Do I Cache uv or Poetry Environments?Cache the uv or Poetry cache directory keyed on the lockfile and let the tool rebuild the virtual environment from it. A cached .venv breaks on upgrades.](/answers/how-do-i-cache-uv-or-poetry-environments) * [How Do I Cache Yarn Berry Installs?Cache the folder yarn config get cacheFolder prints, key it on yarn.lock, and run yarn install --immutable. Zero-install repos ship the cache in the checkout.](/answers/how-do-i-cache-yarn-berry-in-github-actions) * [How Do I Cancel In-Progress Runs on a New Push?Declare a concurrency group keyed on the branch ref and set cancel-in-progress to true, so each new push replaces the run already going instead of adding to it.](/answers/how-do-i-cancel-in-progress-runs-on-a-new-push) * [How Do I Change the Runner in a GitHub Actions Workflow?Change the label on the runs-on line. Swapping ubuntu-latest for warp-ubuntu-latest-x64-8x is the only edit a managed runner needs in a workflow file.](/answers/how-do-i-change-the-runner-in-a-github-actions-workflow) * [How Do I Choose a GitHub Actions Runner Size?Size a GitHub Actions runner from measured utilization: start most builds on 4 vCPU, move up a label when sustained CPU or memory reaches 80 percent.](/answers/how-do-i-choose-a-github-actions-runner-size) * [How Do I Clear a GitHub Actions Cache?Change the cache key to walk away from a stale entry, and delete entries only when the stored bytes are wrong. The key bump, the CLI, the API, and the cost.](/answers/how-do-i-clear-a-github-actions-cache) * [How Do I Debug a Failed GitHub Actions Job?Reproduce the failure on the runner itself with an SSH session gated on failure, then read the runner telemetry for that job beside the GitHub Actions logs.](/answers/how-do-i-debug-a-failed-github-actions-job) * [How Do I Delete a BYOC Stack?Delete the custom runner configurations first, then the stack, then the cloud connection. WarpBuild blocks each delete until its dependents are gone.](/answers/how-do-i-delete-a-byoc-stack) * [How Do I Detect an Undersized Runner?A GitHub Actions runner is undersized when max sustained CPU or memory utilization holds at 80 percent or higher across a job's runs. Read both signals.](/answers/how-do-i-detect-an-undersized-runner) * [How Do I Estimate My GitHub Actions Bill?Estimate a GitHub Actions bill by multiplying billed minutes per runner label by its rate, with the minutes pulled from the jobs report rather than guessed.](/answers/how-do-i-estimate-my-github-actions-bill) * [How Do I Export GitHub Actions Job Data to CSV?Every WarpBuild report tab exports CSV with all filtered rows, and the same five reports return CSV from the API with format=csv for a scheduled pull.](/answers/how-do-i-export-github-actions-job-data-to-csv) * [How Do I Find Which Workflow Got Slower?Rank every workflow by the change in duration P90 across two date ranges. The WarpBuild Jobs report shows both periods per job and exports each one to CSV.](/answers/how-do-i-find-which-workflow-got-slower-this-month) * [How Do I Forecast Runner Spend?Forecast runner spend by pricing last month's billed minutes per label at the published rate, then scaling by planned job growth rather than by headcount.](/answers/how-do-i-forecast-runner-spend-for-next-quarter) * [How Do I Generate an SBOM During a Build?Set sbom true on the build step so BuildKit attaches an SPDX document to the image it just produced, then read it back by digest or upload it as an artifact.](/answers/how-do-i-generate-an-sbom-during-a-github-actions-build) * [How Do I Install Windows SDK Components in a Job?Add the SDK component with the Visual Studio installer in a quiet step, or pick a Windows runner image that already ships the SDK build your project pins.](/answers/how-do-i-install-windows-sdk-components-in-github-actions) * [How Do I Invalidate a Snapshot?Change the alias in the snapshot.key label so the next GitHub Actions job boots from the base image, or delete the snapshot through the WarpBuild API.](/answers/how-do-i-invalidate-a-runner-snapshot) * [How Do I Keep a Docker Layer Cache Across Pull Requests?GitHub Actions caches written by one pull request cannot be restored by another. Keep Docker layers on a builder that outlives the job instead.](/answers/how-do-i-keep-a-docker-layer-cache-between-pull-requests) * [How Do I Limit a Workflow to One Run at a Time?Declare a concurrency group with a fixed name and leave cancel-in-progress off, so runs queue instead of overlapping. Each group holds one pending run.](/answers/how-do-i-limit-a-workflow-to-one-run-at-a-time) * [How Do I Measure Cache Hit Rate in GitHub Actions?Read the cache-hit output of the restore step on every run, write it to the job summary, and price the count against the cache line in the billing report.](/answers/how-do-i-measure-cache-hit-rate-in-github-actions) * [How Do I Publish a Package From a Workflow?Gate the publish job behind a release event, authenticate with a credential scoped to the registry, and publish the artifact the test job already built.](/answers/how-do-i-publish-a-package-from-github-actions) * [How Do I Push a Manifest List from GitHub Actions?Build each architecture on its own native runner, push both images by digest, then run docker buildx imagetools create in a final job to publish one tag.](/answers/how-do-i-push-a-manifest-list-from-github-actions) * [How Do I Reduce Docker Image Pull Time in GitHub Actions?Shrink the image, keep the registry in the same Region as the runners, and stop re-pulling layers a remote builder or a snapshot runner already holds on disk.](/answers/how-do-i-reduce-docker-image-pull-time-in-github-actions) * [How Do I Reproduce a CI Failure Locally?Start on the runner: open a debug session on the machine that failed, then move locally once you know whether toolchain, work directory, or memory differs.](/answers/how-do-i-reproduce-a-ci-failure-locally) * [How Do I Reset a Docker Builder Cache?Reset the persistent layer cache on a WarpBuild builder profile with one API call. The request shape, when a cheaper fix works, and what the next build costs.](/answers/how-do-i-reset-a-docker-builder-cache) * [How Do I Retry a Failed Step in GitHub Actions?GitHub Actions has no per-step retry. Wrap the command in a shell loop with backoff or use a retry action, and only for transient network and registry errors.](/answers/how-do-i-retry-a-failed-step-in-github-actions) * [How Do I Reuse a Workflow Across Repositories?Publish it once as a reusable workflow with workflow\_call, then call it from each repository with uses, a pinned tag, and explicit inputs and secrets.](/answers/how-do-i-reuse-a-workflow-across-repositories) * [How Do I Run a Job Only When Files Change?Gate a workflow with a paths filter on the trigger, and use a change detection job feeding job-level if conditions when one workflow serves many projects.](/answers/how-do-i-run-a-job-only-when-files-change) * [How Do I Run a Workflow Manually?Add a workflow\_dispatch trigger with typed inputs, then start the run from the Actions tab or with one API call. Branch rules and a working example below.](/answers/how-do-i-run-a-workflow-manually) * [How Do I Run ARM64 Integration Tests?Run the test job on a native ARM64 runner so the service under test and harness share the target architecture. warp-ubuntu-latest-arm64-8x is $0.012 a minute.](/answers/how-do-i-run-arm64-integration-tests-in-github-actions) * [How Do I Run Browser Tests Headless in GitHub Actions?Run browser tests headless in GitHub Actions by installing the browser system libraries, restoring the browser cache, and sizing worker count to runner memory.](/answers/how-do-i-run-browser-tests-headless-in-github-actions) * [How Do I Run Database Services in GitHub Actions?Declare the database as a service container, gate the first step on its health check, and size the runner for the database and test workers together.](/answers/how-do-i-run-database-services-in-github-actions) * [How Do I Run Go Race Detector Tests in GitHub Actions?Run go test -race on a job sized for the overhead, since race detection raises memory and run time. Split a fast unit job from a slower race job.](/answers/how-do-i-run-go-race-detector-tests-in-github-actions) * [How Do I Run Jobs on a Schedule?Add a schedule trigger with a cron expression. Scheduled runs fire on the default branch, in UTC, and GitHub gives no guarantee of an exact start minute.](/answers/how-do-i-run-jobs-on-a-schedule) * [How Do I Run PowerShell Scripts in GitHub Actions?Set shell: pwsh on the step. GitHub Actions defaults to pwsh on Windows runners and bash on Linux, and pwsh and powershell are two different interpreters.](/answers/how-do-i-run-powershell-scripts-in-github-actions) * [How Do I See Cost per Runner Label?The CI tab of the WarpBuild Billing report groups cost by runner label, so an oversized label doing small work shows up as minutes billed at a high rate.](/answers/how-do-i-see-cost-per-runner-label) * [How Do I See Cost per Workflow?Cost is recorded per job execution, so cost per workflow is a grouping job: price the CI billing rows, attach the workflow from the run id, then sum.](/answers/how-do-i-see-cost-per-workflow) * [How Do I See CPU and Memory Usage for a Job?The runner agent emits system metrics in OpenTelemetry format, and the observability Usage view draws them against the same job's GitHub Actions log timeline.](/answers/how-do-i-see-cpu-and-memory-usage-for-a-github-actions-job) * [How Do I Select an Xcode Version in GitHub Actions?Pick the runner image that carries the Xcode version you want, then point the job at that toolchain with DEVELOPER\_DIR or xcode-select and log xcodebuild.](/answers/how-do-i-select-an-xcode-version-in-github-actions) * [How Do I Set a Timeout on a Job?Set timeout-minutes on the job and a tighter one on any step that can hang, so a stuck GitHub Actions job fails in minutes instead of billing for hours.](/answers/how-do-i-set-a-timeout-on-a-github-actions-job) * [How Do I Set Permissions for GITHUB\_TOKEN?Declare a permissions block at workflow level with the minimum scopes every job needs, then widen it inside the one job that needs write access.](/answers/how-do-i-set-permissions-for-the-github-token) * [How Do I Skip a Workflow for a Commit?Put \[skip ci\] in the commit message to skip the push and pull request runs that commit would trigger, or gate one job with an if expression on the message.](/answers/how-do-i-skip-ci-on-a-commit) * [How Do I Stop a Workflow From Blocking Deploys?Give deploys a concurrency group of their own and leave cancel-in-progress off it, so test runs cannot queue ahead of a release or cancel one mid-flight.](/answers/how-do-i-stop-a-workflow-from-blocking-deploys) * [How Do I Tag Runner Resources for Cost Allocation?A BYOC stack tags every resource it creates, and those default keys plus custom stack tags make GitHub Actions runner spend separable in your own cloud bill.](/answers/how-do-i-tag-byoc-resources-for-cost-allocation) * [How Do I Use include and exclude in a Matrix?exclude drops combinations from the matrix product and include merges keys into existing combinations or appends new ones. GitHub applies exclude first.](/answers/how-do-i-use-a-matrix-with-include-and-exclude) * [How Do I Use Docker Bake in GitHub Actions?Define each image as a target in a bake file, then call the bake action once. One invocation builds every target against one builder and one layer cache.](/answers/how-do-i-use-docker-bake-in-github-actions) * [How Do I Write a Composite Action?Create an action.yml that sets runs.using to composite, list the steps, then call it with uses. Full example, the shell rule, and when to pick one.](/answers/how-do-i-write-a-composite-action) * [How Do I Write a Good Cache Key in GitHub Actions?Build the key from the runner platform, the toolchain version, and a hash of the lockfile, then list shorter prefixes of that same string in restore-keys.](/answers/how-do-i-write-a-good-cache-key-in-github-actions) * [How Do I Write a GitHub Actions Job Summary?Append GitHub flavored markdown to the file named by GITHUB\_STEP\_SUMMARY and the run page renders it under that job. The table pattern and the limits.](/answers/how-do-i-write-a-job-summary) * [How Does BYOC Billing Work?BYOC billing runs on two lines. WarpBuild charges $0.002 per runner minute, and the instances, disks, and data transfer land on your own cloud bill.](/answers/how-does-byoc-billing-work) * [How Fast Does a Runner Start After Queueing?Start time is the gap between a job entering the queue and its first step running. Read it per runner label in the Queue Timings report, then cut it.](/answers/how-fast-do-runners-start-after-a-job-is-queued) * [How Is a Cancelled GitHub Actions Job Billed?A cancelled GitHub Actions job bills the minutes it ran before termination and nothing after, which is why cancel-in-progress on a busy branch lowers the bill.](/answers/how-is-a-cancelled-github-actions-job-billed) * [How Is a Remote Docker Builder Billed?A remote Docker builder bills per minute for the session it stays up, from $0.06 per minute, separate from the GitHub Actions runner minutes a job spends.](/answers/how-is-a-remote-docker-builder-billed) * [How Long Are Artifacts Kept?GitHub keeps workflow artifacts for 90 days by default. Public repositories allow 1 to 90 days, private repositories 1 to 400, and each upload can set less.](/answers/how-long-are-github-actions-artifacts-kept) * [How Long Do GitHub Actions Caches Last?A WarpBuild cache entry expires 7 days after its last use, and every restore restarts that window. Key, cache version, and branch scope decide what survives.](/answers/how-long-do-github-actions-caches-last) * [How Long Do Runner Snapshots Last?Runner snapshots are deleted after 15 days, so an alias no workflow refreshes stops resolving and the next job that asks for it boots from the base image.](/answers/how-long-do-runner-snapshots-last) * [How Many GitHub Actions Jobs Can Run at Once?Three ceilings decide it: the concurrency key you wrote, the concurrent job limit on your GitHub plan of 20 to 500, and the runner capacity behind runs-on.](/answers/how-many-github-actions-jobs-can-run-at-once) * [How Many Jobs Can a Matrix Fan Out To?A single matrix generates at most 256 jobs per workflow run. Before that cap binds, runner capacity behind runs-on and billed minutes set the usable width.](/answers/how-many-jobs-can-a-matrix-fan-out-to) * [How Much Disk Do macOS Runners Have?WarpBuild macOS runners carry a 120GB SSD on the 6 vCPU labels and a 270GB SSD on the 12 vCPU labels, and that volume is deleted when the runner terminates.](/answers/how-much-disk-do-macos-github-actions-runners-have) * [How Much Do GitHub Actions Runners Cost?GitHub Actions runners bill per minute. WarpBuild Linux runners start at $0.003 per minute and macOS at $0.08, with no base fee, platform fee, or seat fee.](/answers/how-much-do-github-actions-runners-cost) * [Are ARM64 GitHub Actions Runners Cheaper Than x64?Yes. ARM64 runners bill 25 percent below x64 at every size, from $0.003 against $0.004 per minute at 2 vCPU to $0.048 against $0.064 at 32 vCPU.](/answers/is-arm64-cheaper-than-x64-for-github-actions) * [Is It Cheaper to Run Fewer, Larger Runners?Only when the work parallelizes. Each size step doubles the per-minute rate, so a larger runner has to cut GitHub Actions wall clock in half to break even.](/answers/is-it-cheaper-to-run-fewer-larger-runners) * [Is There a Minimum Billing Increment?WarpBuild bills GitHub Actions runners in one-minute increments, with no minimum block and no per-job fee, and the reports view shows billed minutes per job.](/answers/is-there-a-minimum-billing-increment-for-runners) * [Is WarpBuild SOC 2 Compliant?Yes. WarpBuild is SOC 2 Type 2 certified with three Trust Services Criteria, Security, Availability, and Confidentiality, requested at trust.warpbuild.com.](/answers/is-warpbuild-soc-2-compliant) * [What Are restore-keys in GitHub Actions?restore-keys are ordered prefix fallbacks for a cache miss: the action restores the newest entry whose key starts with the first prefix that matches.](/answers/what-are-restore-keys-in-github-actions) * [What Are Snapshot Runners?A snapshot runner boots a GitHub Actions job from a saved image of an earlier run's VM instead of a clean base image. What persists, what resets, what it costs.](/answers/what-are-snapshot-runners) * [Which ARM64 Runner Sizes Are Available?Linux ARM64 GitHub Actions runners come in five sizes, 2x through 32x, from 2 vCPU and 8GB at $0.003 per minute to 32 vCPU and 128GB at $0.048 per minute.](/answers/what-arm64-runner-sizes-are-available-on-github-actions) * [What Carries Over in a Runner Snapshot?A runner snapshot carries the VM filesystem as it stood at capture, including installed packages, warmed caches, and pulled images. Here is what resets.](/answers/what-carries-over-in-a-runner-snapshot) * [What Changes When a Workflow Moves to ARM64?The runs-on label changes. After that, check three things: binaries downloaded with an architecture suffix, native modules, and hardcoded absolute paths.](/answers/what-changes-when-i-move-a-workflow-from-x64-to-arm64) * [What Does GitHub Actions Cache Storage Cost?Cache for GitHub Actions costs $0.20 per GB-month of storage plus $0.0001 per write, restore, or list operation on WarpBuild hosted runners, free on BYOC.](/answers/what-does-github-actions-cache-storage-cost) * [What Gets Created in My Cloud Account?WarpBuild BYOC creates a CloudFormation stack in your AWS account holding a VPC, a security group, and an S3 bucket, plus per-job EC2 instances and volumes.](/answers/what-does-warpbuild-create-in-my-cloud-account) * [What Happens to Runner Disks After a Job?The disk is destroyed with the job. Each WarpBuild runner gets its own encrypted volume, created on demand and destroyed after the build, with one exception.](/answers/what-happens-to-runner-disks-after-a-job) * [What Happens When My Free Credits Run Out?Signup includes $10 free credits. Once they are spent, usage keeps billing per minute at the rate for each runner label, with no subscription underneath it.](/answers/what-happens-when-my-free-credits-run-out) * [What Happens When Two Workflows Share a Concurrency Group?Two workflows naming the same concurrency group serialize against each other, because a group is a repository-wide lane rather than a per-workflow setting.](/answers/what-happens-when-two-workflows-use-the-same-concurrency-group) * [What Is a Remote Docker Builder?A remote Docker builder is a dedicated build VM outside the GitHub Actions runner that keeps a layer cache on its own disk, so repeat builds reuse layers.](/answers/what-is-a-remote-docker-builder) * [What Is the Cheapest Runner Size?The 2 vCPU Linux ARM64 label at $0.003 per minute holds the lowest rate in the catalog, and here is the full ladder plus when a larger label bills less.](/answers/what-is-the-cheapest-github-actions-runner-size) * [What Is the Difference Between a Job and a Step?A job gets its own runner and its own billed minutes, while steps run in order on that one machine, so runner size, parallelism, and cost are set per job.](/answers/what-is-the-difference-between-a-job-and-a-step) * [What Is the Difference Between cache-from and cache-to?cache-from tells a Docker build where to read cache and cache-to where to write it. Set one without the other and the build reads a cache nothing refreshes.](/answers/what-is-the-difference-between-cache-from-and-cache-to) * [GitHub-Hosted vs Self-Hosted RunnersGitHub operates the machine for GitHub-hosted runners and you operate it for self-hosted runners. Managed runners are a third shape. Full comparison here.](/answers/what-is-the-difference-between-github-hosted-and-self-hosted-runners) * [What Is the Smallest Windows Runner Size?The smallest Windows GitHub Actions runner is 4 vCPU with 16GB of memory at $0.016 per minute. The 2 vCPU Windows tier was removed on June 8, 2026.](/answers/what-is-the-smallest-windows-runner-size-available) * [Which macOS Versions Do Runners Support?WarpBuild macOS runners cover macOS 14, macOS 15, and macOS 26, all on ARM64 across five labels from $0.08 per minute. macOS 13 was removed June 8, 2026.](/answers/what-macos-versions-do-github-actions-runners-support) * [Which Metrics Does the API Expose for GitHub Actions?The reports endpoints cover CI billing, Docker builder billing, cache billing, per-job percentiles, and queue timings, in JSON or CSV, from one API key.](/answers/what-metrics-does-the-api-expose-for-github-actions) * [Which Operating Systems Can GitHub Actions Runners Use?GitHub Actions runners cover four platforms: Linux x64, Linux ARM64, macOS, and Windows. WarpBuild ships Ubuntu, macOS, and Windows Server images for each one.](/answers/what-operating-systems-can-github-actions-run) * [What Permissions Does BYOC Need in My AWS Account?A cloud connection creates one IAM role in your AWS account covering EC2, spot, network, storage, and CloudFormation. Runner workloads carry a separate profile.](/answers/what-permissions-does-byoc-need-in-my-aws-account) * [Which Simulator Runtimes Ship on macOS Runners?WarpBuild macOS 26 runners carry the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes with Xcode 27.0\. Which runtimes each image holds and how to check.](/answers/what-simulator-runtimes-ship-on-macos-runners) * [Which Windows Versions Do Runners Support?WarpBuild Windows GitHub Actions runners ship Windows Server 2022 and 2025 images on x86-64, in four sizes from 4 to 32 vCPU, starting at $0.016 per minute.](/answers/what-windows-versions-do-github-actions-runners-support) * [What Xcode Versions Are on GitHub Actions Runners?The Xcode version comes from the macOS runner image. WarpBuild macOS 26 labels ship Xcode 27.0 (build 27A266a) plus the 27.0 simulator runtimes.](/answers/what-xcode-versions-are-available-on-github-actions-runners) * [Where Do GitHub Actions Runners Run?WarpBuild GitHub Actions runners run on ephemeral VMs in WarpBuild infrastructure in the US or EU, or inside your own AWS, GCP, or Azure account on BYOC.](/answers/where-do-github-actions-runners-run) * [Where Is My Build Data Stored?Build work happens on the runner, on an encrypted volume destroyed with the machine. US and EU are the regions WarpBuild names, and BYOC keeps storage yours.](/answers/where-is-my-build-data-stored) * [Why Are My GitHub Actions Builds Slow?GitHub Actions builds are slow for four reasons: queue wait before the job starts, cold caches, undersized runners, and emulated builds. Measure each one.](/answers/why-are-github-actions-slow) * [Why Do Windows Runners Take Longer to Start?Windows GitHub Actions runners start slower because the image is larger and the boot path does more work, so every job pays a higher fixed cost.](/answers/why-are-windows-github-actions-runners-slower-to-start) * [Why Does a Base Image Update Invalidate My Cache?Every layer after FROM is chained to the base image, so a new base digest invalidates all of them. Pin the digest and schedule the rebuild on your terms.](/answers/why-do-my-docker-builds-lose-cache-after-a-base-image-update) * [Why Do My GitHub Actions Jobs Queue Behind Each Other?Three things serialize jobs: a concurrency group holding runs in one lane, an account-level parallel job ceiling, and a runner pool with no free capacity.](/answers/why-do-my-github-actions-jobs-queue-behind-each-other) * [Why Do Python Tests Run Slower in CI?Usually three causes stack up: the runner has fewer cores than your laptop, every job starts with a cold cache, and pytest runs the suite in one process.](/answers/why-do-my-python-tests-run-slower-in-ci-than-locally) * [Why Does Cargo Rebuild Everything in GitHub Actions?Cargo rebuilds because a fresh runner has no target directory, and a changed toolchain, feature set, or RUSTFLAGS invalidates a restored one. Keys and costs.](/answers/why-does-cargo-rebuild-everything-in-github-actions) * [Why Does Docker Build Run Out of Disk?One runner volume holds the checkout, the pulled base images, every intermediate stage, and the exported cache at once. Move the build to a remote builder.](/answers/why-does-docker-build-run-out-of-disk-in-github-actions) * [Why Does My GitHub Actions Cache Miss Every Run?A GitHub Actions cache misses every run for three reasons: the key string changed, the version hash changed, or the entry was written on another branch.](/answers/why-does-my-github-actions-cache-miss-every-run) * [Why Does My Job Show High IO Wait?A job shows high IO wait when the processor sits idle while storage catches up. Read disk I/O against CPU for the same window, then move the work to local disk.](/answers/why-does-my-job-show-high-io-wait) * [Why Does the Gradle Daemon Start on Every Run?A GitHub Actions job gets a new virtual machine, so no Gradle daemon survives to reuse. The startup cost breakdown, the settings that cut it, and the numbers.](/answers/why-does-the-gradle-daemon-start-every-run) * [Why Is My Cache Restore Slow?Cache restore time tracks archive size and the link between runner and store. Break-even arithmetic, three trims that shrink the entry, and how to measure it.](/answers/why-is-my-cache-restore-slow-in-github-actions) * [Why Is My Docker Build Slow in GitHub Actions?Usually three causes: no persistent layer cache, QEMU emulation for a second architecture, or an undersized runner. Move the build to a remote builder.](/answers/why-is-my-docker-build-slow-in-github-actions) * [Why Is My GitHub Actions Bill So High?A high GitHub Actions bill usually comes from four drivers: oversized runners, macOS minutes, re-run storms, and cache misses that force full rebuilds.](/answers/why-is-my-github-actions-bill-so-high) * [Why Is npm install Slow in GitHub Actions?Most of a cold npm install is registry fetches plus native module compilation. A dependency cache removes the fetches, and only more vCPU touches the builds.](/answers/why-is-npm-install-slow-in-github-actions) --- # Am I Billed When a Job Fails? | WarpBuild URL: https://www.warpbuild.com/answers/am-i-billed-when-a-github-actions-job-fails Description: Yes. A failed GitHub Actions job bills every minute its machine ran, so the lever is failing fast: matrix fail-fast, job timeouts, and cheap checks first. # Am I Billed When a Job Fails? Yes. A failed GitHub Actions job bills every minute its machine ran, so the lever is failing fast: matrix fail-fast, job timeouts, and cheap checks first. Yes. A GitHub Actions job that fails held a real machine for the minutes it ran, and those minutes bill at the same rate as the minutes of a job that passes, because the meter reads execution time and the red or green conclusion is metadata written afterwards. No refund path exists for a failed job, so the lever that moves the bill is making failures land early: matrix fail-fast, job timeouts, and cheap checks ordered ahead of expensive ones. ## [Answer](#answer)Copy Anchor Link Billing follows minutes rather than outcomes. GitHub "rounds the minutes and partial minutes each job uses up to the nearest whole minute" ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13), and WarpBuild bills GitHub Actions runners per minute at the rates on the [pricing page](/pricing) and in the [cloud runners documentation](/docs/ci/cloud-runners). A 9 minute job that fails on its last step bills 9 minutes on both platforms. Here is what a single failed 9 minute attempt costs on each WarpBuild runner type in the table. | Runner label | Shape | Rate | Cost of one failed 9 minute attempt | | --------------------------- | ------------- | ---------- | ----------------------------------- | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008/min | $0.072 | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006/min | $0.054 | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016/min | $0.144 | | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08/min | $0.720 | Rates from the [pricing page](/pricing), checked on 2026-08-13\. The platform column is the reason a failing iOS job deserves more attention than a failing Linux job: the same 9 wasted minutes bill $0.720 on the macOS label against $0.072 on the 4 vCPU Linux x64 label. Two things do escape the meter. A job that never starts bills nothing, because GitHub documents that "if a job fails or is skipped, all jobs that need it are skipped" ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidneeds), checked on 2026-08-13), and a skipped job claims no machine. A job that stops partway bills only the minutes it ran, which is how [a cancelled GitHub Actions job is billed](/answers/how-is-a-cancelled-github-actions-job-billed). Every technique below is one of those two mechanics applied on purpose. ## [Detail](#detail)Copy Anchor Link ### [The assumptions behind the numbers on this page](#the-assumptions-behind-the-numbers-on-this-page)Copy Anchor Link * 900 pull request workflow runs a month on one repository. * 18 percent of those runs fail on the first attempt, which is 162 failing runs and 738 passing ones. * The expensive job is a 12 leg test matrix on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, each leg 9 minutes, so a full run of the matrix bills 108 minutes and $0.864. * When a run fails, the first red leg surfaces at minute 2, and cancellation reaches the other 11 legs by minute 3. * 70 percent of failing runs get re-run in full, which is 113 re-runs. * 60 percent of first-attempt failures are lint or type errors, which is 97 runs a 2 minute gate would catch. * The gate runs on `warp-ubuntu-latest-x64-2x` at $0.004 per minute, so it costs $0.008 per run. * Three macOS jobs a month hang and sit until a timeout stops them, on `warp-macos-latest-arm64-6x` at $0.08 per minute. Replace the run count, the failure rate, and the leg duration with your own before trusting the totals. The reports section below is where those three numbers come from. ### [The three levers and the minutes each one removes](#the-three-levers-and-the-minutes-each-one-removes)Copy Anchor Link | Lever | Minutes it removes per month | Effect on the bill | | ---------------------------------------- | ------------------------------------------------------ | ------------------ | | fail-fast on the test matrix | 11,826 runner minutes (162 failures x 73 minutes) | $94.61 | | timeout-minutes on the macOS job | 945 macOS minutes (3 hangs x 315 minutes) | $75.60 | | Lint and type checks ahead of the matrix | 3,395 matrix minutes removed, 1,800 gate minutes added | $19.96 | **Matrix fail-fast.** The setting defaults to `true`, and GitHub documents the behavior plainly: with `fail-fast` on, GitHub will "cancel all in-progress and queued jobs in the matrix if any job in the matrix fails" ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstrategyfail-fast), checked on 2026-08-13). A failing run bills 35 minutes with the setting on, 2 minutes for the failing leg plus 3 minutes each for 11 cancelled siblings, against 108 with it off, a difference of 73 minutes and $0.584 per failing run. Teams that set `fail-fast: false` to collect the full failure list are buying that list with those 73 minutes, which earns its cost on a nightly compatibility matrix and wastes it on a pull request matrix where the first red leg is enough. The [fail-fast definition](/glossary/fail-fast) covers the cases where keeping every leg alive is worth paying for. **Job timeouts.** `timeout-minutes` has a default of 360 ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidtimeout-minutes), checked on 2026-08-13), so a job that hangs on a network call holds its machine for six hours before anything stops it. On `warp-macos-latest-arm64-6x` at $0.08 per minute, one hang bills $28.80\. Setting `timeout-minutes: 45` on a job whose P90 duration is 20 minutes caps the same hang at $3.60\. Set the value from the duration percentiles in the reports view rather than guessing, and set it on every job, since the default applies per job. **Cheap checks first.** Lint and type checks finish in about 2 minutes on a 2 vCPU runner and catch a large share of first-attempt failures on most repositories. Putting them in their own job and pointing the matrix at it with `needs` means a broken lint run skips 108 minutes of test matrix instead of running it. The gate costs $0.008 on every run, including the 738 that pass, and removes $0.28 of matrix time on each of the 97 runs it catches. ### [Ordering cheap checks ahead of the expensive job](#ordering-cheap-checks-ahead-of-the-expensive-job)Copy Anchor Link Copy Text ``` name: ci on: pull_request: branches: [main] jobs: checks: runs-on: warp-ubuntu-latest-x64-2x timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run lint - run: npm run typecheck tests: needs: checks runs-on: warp-ubuntu-latest-x64-4x timeout-minutes: 25 strategy: fail-fast: true max-parallel: 12 matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/12 ``` The `needs: checks` line is what turns a lint failure into 12 skipped jobs. The explicit `fail-fast: true` restates the default so the next person to edit the matrix sees the decision. The two `timeout-minutes` values sit above the P90 duration of each job, which keeps a hang bounded without cancelling a slow but healthy run. The gate adds about 2 minutes of wall clock to every run that passes, and that latency is the real price of the pattern. ### [A month of failure and re-run waste](#a-month-of-failure-and-re-run-waste)Copy Anchor Link Baseline, with no gate and `fail-fast: false` on the matrix. Passing runs are excluded from both tables, since the levers do not change what a green run costs. | Line | Runs | Minutes each | Minutes | Cost | | ------------------------------ | ---- | ------------ | ------- | ------- | | First-attempt failures | 162 | 108 | 17,496 | $139.97 | | Full re-runs of those failures | 113 | 108 | 12,204 | $97.63 | | Total | 275 | 29,700 | $237.60 | | The same month with the gate in front and `fail-fast` on. The gate line for passing runs is included because the gate is the one lever that adds minutes to a green run. | Line | Runs | Minutes each | Minutes | Cost | | ------------------------------ | ------ | ------------------- | ------- | ------ | | Failures the gate catches | 97 | 2 gate | 194 | $0.78 | | Failures that reach the matrix | 65 | 2 gate + 35 matrix | 2,405 | $18.72 | | Full re-runs, gate included | 113 | 2 gate + 108 matrix | 12,430 | $98.54 | | Gate on the 738 runs that pass | 738 | 2 | 1,476 | $5.90 | | Total | 16,505 | $123.94 | | | The monthly difference is $113.66, and the shape of the second table matters more than the total. Re-runs are now about 80 percent of what failure costs, because a re-run that goes green bills the full 108 minute matrix whatever the levers do. Cutting that line means fewer re-runs rather than shorter ones, which is a flakiness and retry-policy problem; [what GitHub Actions re-run storms cost](/guides/rerun-storms-github-actions) works through it. The narrower re-run helps here too. GitHub allows re-running a whole run, all failed jobs, or one specific job for up to 30 days after the initial run ([re-running workflows and jobs](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs), checked on 2026-08-13), and re-running one 9 minute shard bills $0.072 against $0.864 for the full matrix. ### [Finding which jobs burn minutes on red](#finding-which-jobs-burn-minutes-on-red)Copy Anchor Link The [reports documentation](/docs/ci/features/reports) describes the two views that turn this into your own numbers. The Jobs section aggregates each unique repository, workflow, and job name combination with run count, success rate, and duration at P75 and P90, so sorting by success rate ascending and reading run count next to it ranks jobs by wasted attempts. The Billing section gives one row per job execution with execution time, billed time, and the runner and snapshot split, filterable by repository, runner label, and job name, and every table exports to CSV. Duration P90 is also the number to set `timeout-minutes` from. A job with a P90 of 18 minutes and the default 360 minute cap carries 342 minutes of exposure on every hang. ### [What the bill never adds](#what-the-bill-never-adds)Copy Anchor Link The full rate list is on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does a cancelled GitHub Actions job still cost money?](#does-a-cancelled-github-actions-job-still-cost-money)Copy Anchor Link Yes. A cancelled job bills the minutes its machine ran before the cancellation landed, so a matrix leg cancelled at minute 3 of a 9 minute run bills 3 minutes rather than 9\. The saving comes from the 6 minutes that never ran. See [how a cancelled GitHub Actions job is billed](/answers/how-is-a-cancelled-github-actions-job-billed) for the same arithmetic on manual cancels and superseded pushes. ### [Do skipped jobs cost anything?](#do-skipped-jobs-cost-anything)Copy Anchor Link No. GitHub skips every job that lists a failed job in `needs` ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidneeds), checked on 2026-08-13), and a skipped job never starts a machine, so it bills nothing. That is why a 2 minute lint gate in front of a 12 leg test matrix removes the whole matrix from the bill when lint is broken. ### [Is re-running only the failed jobs cheaper than re-running the whole workflow?](#is-re-running-only-the-failed-jobs-cheaper-than-re-running-the-whole-workflow)Copy Anchor Link Yes. Re-running the 12 leg matrix in full bills 108 minutes, which is $0.864 at the $0.008 per minute rate on the [pricing page](/pricing). Re-running the single failed shard bills 9 minutes, or $0.072\. GitHub exposes the narrower option as Re-run failed jobs in the run view and as `gh run rerun RUN_ID --failed` on the command line, for up to 30 days after the initial run ([re-running workflows and jobs](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs), checked on 2026-08-13). Habitual full re-runs are the pattern behind [re-run storms](/guides/rerun-storms-github-actions). --- # Are Build Secrets Visible to the Runner Platform? | WarpBuild URL: https://www.warpbuild.com/answers/are-build-secrets-visible-to-the-runner-platform Description: No. GitHub holds your secrets and injects them into the job environment, and WarpBuild documents that it does not access or store any build secrets. # Are Build Secrets Visible to the Runner Platform? No. GitHub holds your secrets and injects them into the job environment, and WarpBuild documents that it does not access or store any build secrets. ## [Answer](#answer)Copy Anchor Link No. GitHub stores your secrets and delivers the ones a workflow references into the job environment on the runner, and the runner platform documents that it does not access or store them. The sentence a reviewer should record comes from the [WarpBuild runner security documentation](/docs/ci/security): WarpBuild does not access or store any build secrets, and secrets are stored in your source code repository and are only accessible to your runner environment. The second half of the question is the one worth spending time on. Inside a running job the secret is readable by every process that job starts, on any runner, because that is how GitHub Actions delivers a secret to a build step ([GitHub documentation on using secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets), checked on 2026-08-13). A review therefore has two separate questions: what the platform holds outside your job, and what code inside your job can reach. | Location | What holds the value | Who can read it | How long it lives | | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | | GitHub secret store, repository or organization or environment scope | GitHub | Workflows that reference it, plus admins who can overwrite it. The value cannot be read back through the UI ([GitHub docs](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets)) | Until you rotate or delete it | | The job environment on the runner | Environment variables and step inputs on the runner machine | Every process the job starts, including third party actions in the same job ([GitHub docs](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets)) | The length of the job | | The run log | GitHub masks exact matches of the value | Anyone with read access to the run, if a step transforms the value before printing it ([GitHub docs](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets)) | Your log retention setting | | The runner platform control plane | Nothing. WarpBuild does not access or store any build secrets ([runner security documentation](/docs/ci/security)) | No one | Not applicable | | The runner disk | Whatever a step writes there, on an encrypted volume ([runner security documentation](/docs/ci/security)) | That job only | Destroyed with the virtual machine after the build | | A BYOC instance in your cloud account | The same job environment, on an instance you own ([BYOC security hardening](/docs/ci/byoc/aws/security)) | Bounded by the security groups and IAM instance profile you attach | Terminated when the job finishes | WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and every label in that catalog gets the handling in the table above. ## [Detail](#detail)Copy Anchor Link ### [What the platform documents](#what-the-platform-documents)Copy Anchor Link Three statements on the [runner security page](/docs/ci/security) carry the whole vendor side of the answer, and each is short enough to paste into a questionnaire. * **Compute isolation.** Each runner runs in its own virtual machine. The virtual machines are created on demand and destroyed after each build, and they are not reused. * **Storage protection.** Each runner has its own encrypted storage volume, created on demand and destroyed after each build. When caching is enabled, the cache is encrypted and stored in a location that is only accessible to your runner. * **Secrets.** WarpBuild does not access or store any build secrets. Secrets are stored in your source code repository and are only accessible to your runner environment. The attestation behind those statements is SOC 2 Type 2 with three Trust Services Criteria: Security, Availability, and Confidentiality. Request the report and the supporting security documentation at [trust.warpbuild.com](https://trust.warpbuild.com/). The [security review checklist](/enterprise/ci-security-review) lists the five questions reviewers ask in the order they ask them. ### [The threat boundary a job actually has](#the-threat-boundary-a-job-actually-has)Copy Anchor Link GitHub exposes a secret only when a workflow names it through the `secrets` context, in an `env` block or a `with` input. Once named, the value becomes an environment variable or a process argument on the runner, and four consequences follow from that. 1. **Scope is set by where you write the `env` block.** A secret declared at job level sits in the environment of every step in that job. Declared on a single step, it reaches that step alone. 2. **Third party actions share the machine.** An action running in the same job reads the same environment and the same filesystem as your build script. Pinning each action to a full length commit SHA is what stops a moved tag from changing the code that runs next to your credential. 3. **Log masking is a literal string match.** GitHub redacts the exact value when it appears in a log. A step that base64 encodes the value, splits it, or embeds it in structured output can print something the mask does not catch, which is why GitHub advises against storing structured data as a secret ([GitHub documentation on using secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets), checked on 2026-08-13). 4. **Fork pull requests are excluded.** GitHub withholds repository secrets from a workflow triggered by a pull request from a fork, so an untrusted contribution runs with only the job's own read scoped token available to it ([GitHub documentation on using secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets), checked on 2026-08-13). None of that is specific to a runner vendor. What the runner model changes is the exposure window after the job: on a machine that is destroyed after each build, whatever a step wrote to disk goes with the volume, so there is no host left holding this morning's credential file when an unrelated job runs this afternoon. ### [Hardening one: exchange an identity token instead of storing a key](#hardening-one-exchange-an-identity-token-instead-of-storing-a-key)Copy Anchor Link The strongest version of this answer removes the stored secret. A job with the `id-token: write` grant asks GitHub for a signed identity token, hands it to the cloud provider, and gets back credentials that expire on their own. Copy Text ``` name: deploy on: push: branches: [main] permissions: {} jobs: deploy: runs-on: warp-ubuntu-latest-x64-4x environment: production permissions: contents: read id-token: write steps: - uses: actions/checkout@v5 with: persist-credentials: false - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy aws-region: us-east-1 - run: aws s3 sync ./dist s3://example-artifacts/site/ --delete ``` `permissions: {}` at the top of the file sets every scope to `none` for any job that stays silent, and the `deploy` job restates the two grants it needs. The AWS role's trust policy pins the `sub` claim to `repo:example-org/example-repo:ref:refs/heads/main`, so a push to any other branch produces a different subject and the exchange fails at the cloud provider rather than in the workflow file ([GitHub documentation on security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect), checked on 2026-08-13). The credential step is [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials), a public repository you can read before you pin it. [OIDC token exchange](/glossary/oidc-token-exchange) covers the claims and the trust policy in full. `persist-credentials: false` is worth keeping in the same job. It stops the checkout action from writing a token into the local git config, so a build script cannot read it back out of `.git/config`. ### [Hardening two: scope what is left to an environment and a step](#hardening-two-scope-what-is-left-to-an-environment-and-a-step)Copy Anchor Link Some credentials have no token exchange available, such as a package registry token. Those belong in an environment secret rather than a repository secret, and in a step level `env` block rather than a job level one. Copy Text ``` jobs: publish: runs-on: warp-ubuntu-latest-x64-2x environment: production steps: - uses: actions/checkout@v5 - name: Build the package run: npm ci && npm run build - name: Publish env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish ``` An environment secret resolves only for a job that declares that environment, and the environment can require reviewers or restrict which branches may deploy to it ([GitHub documentation on managing environments](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments), checked on 2026-08-13). Only the publish step carries the token, so a compromised dependency pulled in during `npm ci` runs in an environment that leaves it out. [Managing secrets in GitHub Actions workflows](/guides/github-actions-secrets-management) covers the three secret scopes and when each one fits. ### [On BYOC the boundary sits inside your account](#on-byoc-the-boundary-sits-inside-your-account)Copy Anchor Link Teams whose policy puts the machine itself under their own control run BYOC. Each job runs on a distinct, freshly provisioned instance that is terminated when the job completes, and the permissions available to that instance are exactly what you provide through security groups and IAM instance profiles. WarpBuild does not inject additional permissions into runner workloads ([BYOC security hardening](/docs/ci/byoc/aws/security)). Two settings on that page apply directly to credential exposure. Attach a least privilege instance profile per runner, scoped to the one registry or bucket that workload touches, so a leaked job never reaches more than its own resources. Set IMDSv2 to required on the runner, which blocks the request shape an SSRF uses to read instance credentials out of the metadata service. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can WarpBuild read the secrets our workflows use?](#can-warpbuild-read-the-secrets-our-workflows-use)Copy Anchor Link No. The [runner security documentation](/docs/ci/security) states that WarpBuild does not access or store any build secrets, and that secrets are stored in your source code repository and are only accessible to your runner environment. That environment is a virtual machine created on demand for one job and destroyed after the build, along with its encrypted storage volume. The [security review checklist](/enterprise/ci-security-review) points at the documentation page behind each statement, so a reviewer reads the source rather than a summary. ### [What can read a secret while the job is running?](#what-can-read-a-secret-while-the-job-is-running)Copy Anchor Link Every process the job starts. A secret referenced in a job level `env` block sits in the environment of every step, including third party actions, so a compromised action in the same job reaches the same value ([GitHub documentation on using secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets)). Scope each secret to the one step that needs it, pin actions to a full length commit SHA, and prefer a short lived credential from an [OIDC token exchange](/glossary/oidc-token-exchange) over a stored key. [Managing secrets in GitHub Actions workflows](/guides/github-actions-secrets-management) works through the scoping rules step by step. ### [Does the answer change on BYOC runners?](#does-the-answer-change-on-byoc-runners)Copy Anchor Link The boundary moves into your own cloud account. Each job runs on a freshly provisioned instance that is terminated when the job finishes, and the permissions it carries are the ones you attach through your own security groups and IAM instance profiles, since WarpBuild does not inject additional permissions into runner workloads ([BYOC security hardening](/docs/ci/byoc/aws/security)). Require IMDSv2 and attach a least privilege instance profile per runner, and the evidence a reviewer wants comes out of your own cloud audit log. The [security review checklist](/enterprise/ci-security-review) covers both deployment shapes side by side. --- # Are GitHub Actions Free for Open Source Repositories? | WarpBuild URL: https://www.warpbuild.com/answers/are-github-actions-free-for-open-source Description: Yes on standard GitHub-hosted runners: public repository minutes cost nothing. Managed runners work there once the Default runner group allows public repos. # Are GitHub Actions Free for Open Source Repositories? Yes on standard GitHub-hosted runners: public repository minutes cost nothing. Managed runners work there once the Default runner group allows public repos. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Yes on the default path: GitHub Actions minutes are free on public repositories when the job runs on a standard GitHub-hosted runner, and artifact and cache storage on those repositories is free with them. Managed runners including WarpBuild run public repository jobs as well, once an organization owner checks `Allow public repositories` on the Default runner group, because GitHub blocks self-hosted and managed runners in public repositories by default. Three separate questions hide inside "are GitHub Actions free for open source". Answer them in order and the billing picture stops being confusing. **1\. Standard GitHub-hosted runners on a public repository.** Free. GitHub charges no minutes and no storage for public repositories on the standard `ubuntu-latest`, `windows-latest`, and `macos-latest` labels. GitHub also gives public repositories a larger free shape than private ones get: the standard Linux runner on a public repository is 4 vCPU with 16 GB of RAM, against 2 vCPU and 8 GB on a private repository ([GitHub-hosted runner specs](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13). **2\. Larger GitHub-hosted runners on a public repository.** Billed. The free allowance covers standard runners only. A public repository that reaches for an 8-core or 16-core GitHub-hosted label pays the same per-minute rate a private repository pays ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). **3\. Self-hosted and managed runners on a public repository.** GitHub bills nothing for the job itself, because the machine is not theirs. You pay whoever supplies the machine, which is your own cloud bill for a self-hosted fleet or a per-minute rate for a managed fleet. Before any of that, the runner group has to allow public repositories. That third case is where most maintainers get stuck. WarpBuild registers itself as a self-hosted runner in the `Default` runner group, id `1`, for your GitHub organization, and GitHub disables self-hosted runners in public repositories by default. An organization owner enables them in two steps, documented on the [public repositories setup page](/docs/ci/public-repos): 1. Open `https://github.com/organizations/YOUR_ORG/settings/actions/runner-groups/1`. 2. Check the box for `Allow public repositories`. Until that box is checked, a job pointed at a `warp-` label in a public repository stays queued with no runner assigned. That is the first thing to check in the [common issues guide](/docs/ci/common-issues) when runners look connected and nothing picks up work. Pricing is purely usage based. ## [Detail](#detail)Copy Anchor Link ### [What a public repository gets at no charge](#what-a-public-repository-gets-at-no-charge)Copy Anchor Link The free allowance for public repositories covers the parts of GitHub Actions most open source projects actually use: minutes on standard runners, artifact storage, and Actions cache storage. There is no monthly minute budget to exhaust and no spending limit to raise, which is why a busy project with a hundred contributors can run a full test matrix on every pull request without a payment method on file. Two limits still apply, and they are not billing limits. The first is concurrency. Standard GitHub-hosted runners draw from shared pools with a per-account ceiling, and macOS carries a lower sub-ceiling than Linux and Windows ([GitHub usage limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). A public repository on the Free plan shares that ceiling with every other repository on the account, so a large matrix in one project delays workflows in the others. The second is job runtime. A single job is capped at 6 hours and a workflow run at 35 days, from the same reference. Long integration suites hit the job cap before they hit any billing wall. ### [Where the free path ends](#where-the-free-path-ends)Copy Anchor Link Three situations push an open source project off free minutes. **The build outgrew the standard shape.** Compile-heavy projects in Rust, C++, and Go, and monorepos with large dependency graphs, saturate 4 vCPU and stay saturated. The GitHub answer is a larger runner label, which is billed on public repositories. **The project needs hardware the free pool does not offer.** Linux ARM64 test legs, nested virtualization for Android emulator jobs, and macOS runners with more than the standard core count all sit outside the free standard set. **Queue time is the bottleneck rather than build time.** When a release train fans out sixty jobs and the account ceiling admits twenty, the fix is capacity behind a label rather than a faster machine, and self-hosted or managed labels sit outside the GitHub plan ceiling. Each of those pushes the maintainer toward a runner group decision, which is where the enablement step comes back. The [runner groups guide](/guides/github-actions-runner-groups) covers how group membership and repository access interact, and the [runner group glossary entry](/glossary/runner-group) defines the term. ### [Why isolated VMs make public repository builds viable](#why-isolated-vms-make-public-repository-builds-viable)Copy Anchor Link GitHub's own documentation recommends disabling self-hosted runners on public repositories, and the reason is specific: a pull request from any contributor can execute arbitrary code in the workflow, which reaches whatever the runner machine can reach ([self-hosted runner security](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#self-hosted-runner-security)). A persistent runner registered on a corporate network, holding cloud credentials in its instance profile, is a bad place to execute a stranger's build script. The risk sharpens with container-based fleets. [actions-runner-controller](https://github.com/actions/actions-runner-controller) runs workflows in containers on a Kubernetes cluster, and containers share a kernel with the host, so the isolation boundary is thinner than a virtual machine boundary and node-level credentials sit close to the workload. WarpBuild runners answer the objection with the machine lifecycle. Each runner runs in its own virtual machine, created on demand and destroyed after the build, with an encrypted storage volume that is created and destroyed on the same cycle. The VMs are ephemeral and never reused, so one contributor's job cannot read state left behind by another and cannot outlive its own workflow run. The runners also carry the same tools and versions as GitHub-hosted runners, which keeps a public repository's workflow file portable between the two. Compliance evidence for that posture is SOC 2 Type 2, with the report and current control status published at [trust.warpbuild.com](https://trust.warpbuild.com/). ### [Dependabot is the one caveat to plan around](#dependabot-is-the-one-caveat-to-plan-around)Copy Anchor Link GitHub does not run Dependabot updates on public repositories when self-hosted runners are in use. Managed runners register as self-hosted runners, so the restriction applies to them. This is a GitHub security decision documented in [managing Dependabot on self-hosted runners](https://docs.github.com/en/enterprise-cloud@latest/code-security/dependabot/maintain-dependencies/managing-dependabot-on-self-hosted-runners). The practical answer is to leave Dependabot workflows on GitHub-hosted labels. Dependency update jobs are short and light, so the standard free public repository runner handles them, and the rest of the matrix can point at `warp-` labels. ### [A workflow that keeps fork pull requests on GitHub-hosted runners](#a-workflow-that-keeps-fork-pull-requests-on-github-hosted-runners)Copy Anchor Link Many maintainers want the faster machines for trusted work and the free pool for pull requests from forks. One job resolves the label and the rest of the workflow consumes it. Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: pick-runner: runs-on: ubuntu-latest outputs: linux: ${{ steps.choose.outputs.linux }} steps: - id: choose env: IS_FORK: ${{ github.event.pull_request.head.repo.fork }} run: | if [ "$IS_FORK" = "true" ]; then echo "linux=ubuntu-latest" >> "$GITHUB_OUTPUT" else echo "linux=warp-ubuntu-latest-x64-8x" >> "$GITHUB_OUTPUT" fi test: needs: pick-runner runs-on: ${{ needs.pick-runner.outputs.linux }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test macos-test: if: github.event_name == 'push' runs-on: warp-macos-latest-arm64-6x steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'platform=macOS' test ``` The `pick-runner` job costs a few seconds on a free standard runner. Fork pull requests stay on `ubuntu-latest`, branch pushes from maintainers land on `warp-ubuntu-latest-x64-8x`, and the macOS leg runs only after merge. ### [What the paid path costs on a public repository](#what-the-paid-path-costs-on-a-public-repository)Copy Anchor Link The comparison below only matters for jobs that outgrew the free standard runner. WarpBuild rates come from the [pricing page](/pricing). GitHub rates come from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and shapes from the [runner specs page](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), both checked on 2026-08-13. | WarpBuild label | Shape | WarpBuild per minute | GitHub-hosted larger runner | GitHub per minute | List price difference | | --------------------------- | -------------- | -------------------- | ---------------------------------------------------------- | ----------------- | --------------------------- | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent lower list price | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent lower list price | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent lower list price | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | 4-core Linux ARM64 larger runner | $0.008 | 25 percent lower list price | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016 | 4-core Windows larger runner | $0.022 | 27 percent lower list price | | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08 | largest GitHub-hosted macOS ARM64 runner, 5 vCPU and 14 GB | $0.102 | 22 percent lower list price | The macOS row compares the nearest GitHub ARM64 macOS shape. The WarpBuild runner in that row carries one more vCPU and the same memory at the lower rate. **Worked model: an open source project that outgrew the free pool.** The repository merges enough work to run 300 builds a month. Each build runs one Linux job of 8 minutes on an 8 vCPU machine and one macOS job of 10 minutes. * Linux: 300 x 8 = 2,400 minutes. GitHub 8-core Linux larger runner at $0.022 per minute is **$52.80 a month**. `warp-ubuntu-latest-x64-8x` at $0.016 per minute is **$38.40 a month**. * macOS: 300 x 10 = 3,000 minutes. The largest GitHub-hosted macOS ARM64 runner at $0.102 per minute is **$306.00 a month**. `warp-macos-latest-arm64-6x` at $0.08 per minute is **$240.00 a month**. * Monthly total: **$358.80** against **$278.40**, a difference of $80.40 a month and $964.80 over twelve months at list prices checked on 2026-08-13. Jobs that stayed on standard GitHub-hosted runners still cost nothing, so a mixed workflow like the one above pays only for the legs that moved. ## [Related Questions](#related-questions)Copy Anchor Link **Are GitHub Actions minutes free on public repositories?** Minutes on standard GitHub-hosted runners are free on public repositories, and so is artifact and cache storage. Larger GitHub-hosted runners are billed on public repositories at the same per-minute rates as private ones, so the free path ends the moment a job asks for more cores. The [private repository answer](/answers/are-github-actions-free-for-private-repositories) covers how the included-minutes allowance works on the other side. **Why do managed runners not pick up jobs in my public repository?** GitHub disables self-hosted and managed runners in public repositories by default. An organization owner has to check `Allow public repositories` on the Default runner group, id `1`, at `github.com/organizations/YOUR_ORG/settings/actions/runner-groups/1`. Until that box is checked the job sits queued. The [public repositories documentation](/docs/ci/public-repos) has the screenshot, and the [runner groups guide](/guides/github-actions-runner-groups) covers non-default groups and workflow restrictions. **Is it safe to run pull requests from strangers on managed runners?** WarpBuild jobs run inside a virtual machine created for that job and destroyed when the job finishes, with an encrypted storage volume that is destroyed with it. Nothing from one contributor's pull request survives into the next job, which is what makes public repository use viable. The [runner security answer](/answers/are-github-actions-runners-secure) goes through the threat model in more depth. **Does Dependabot work on public repositories with self-hosted runners?** No. GitHub does not run Dependabot updates on public repositories when self-hosted runners are in use, and managed runners register as self-hosted runners. Keep Dependabot workflows on GitHub-hosted labels in public repositories and point the rest of the matrix at `warp-` labels. Enable the Default runner group for public repositories with the steps in the [public repositories documentation](/docs/ci/public-repos), price your own job minutes against the table above on the [pricing page](/pricing), and start with $10 in free credits. --- # Are GitHub Actions Free for Private Repositories? | WarpBuild URL: https://www.warpbuild.com/answers/are-github-actions-free-for-private-repositories Description: No. Private repositories get a monthly GitHub Actions minute allowance per plan and pay per minute past it. WarpBuild Linux runners start at $0.004. # Are GitHub Actions Free for Private Repositories? No. Private repositories get a monthly GitHub Actions minute allowance per plan and pay per minute past it. WarpBuild Linux runners start at $0.004. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link GitHub Actions is free on private repositories only up to a monthly minute allowance that comes with your GitHub plan, and every minute past that allowance bills at GitHub's published per-minute rate. GitHub lists the allowance as 2,000 minutes a month on Free, 3,000 on Pro and Team, and 50,000 on Enterprise Cloud, per the [GitHub Actions billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13. | GitHub plan | Included Actions minutes per month | Included Actions storage | What happens past the allowance | | ---------------- | ---------------------------------- | ------------------------ | ---------------------------------------------------------------- | | Free | 2,000 | 500 MB | Jobs stop until the cycle resets or the spending limit is raised | | Pro | 3,000 | 1 GB | Per-minute billing at the published rate | | Team | 3,000 | 2 GB | Per-minute billing at the published rate | | Enterprise Cloud | 50,000 | 50 GB | Per-minute billing at the published rate | Allowances and rates read from the [GitHub Actions billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-actions) and the [GitHub Actions per-minute rate list](https://docs.github.com/en/billing/reference/actions-minute-multipliers), both checked on 2026-08-13. Two rules narrow the free part further. The allowance covers standard GitHub-hosted runner labels; GitHub excludes larger runners from included minutes, so a private repository that moved a job to a 4-core or 8-core label pays for every minute of it from the first run. And the allowance is denominated in Linux minutes: on a private repository, one Windows minute consumes two included minutes and one macOS minute consumes ten, per the same rate list. What changes on WarpBuild is the shape of the bill rather than the amount of free work. Rates for every label are on the [WarpBuild pricing page](/pricing) and in the [cloud runners documentation](/docs/ci/cloud-runners). Here is the same-shape row that most private repositories care about first. `warp-ubuntu-latest-x64-2x` gives 2 vCPU and 8 GB at $0.004 per minute. GitHub-hosted `ubuntu-latest` on a private repository is the same 2 vCPU, 8 GB shape at $0.006 per minute. The arithmetic: $0.006 minus $0.004 is $0.002 per minute, and $0.002 divided by $0.006 is 33 percent lower list price (GitHub pricing, checked on 2026-08-13). Over 10,000 minutes that is $60.00 against $40.00. ## [Detail](#detail)Copy Anchor Link ### [How fast a private repository burns the allowance](#how-fast-a-private-repository-burns-the-allowance)Copy Anchor Link Take a Team plan organization with 3,000 included minutes and this workflow on every pull request. Copy Text ``` name: ci on: pull_request: branches: [main] jobs: lint: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run lint test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm test build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - run: ./scripts/build.sh ``` Assume lint takes 3 minutes, test takes 9, and build takes 6, so one pull request run bills 18 minutes. A team merging 20 pull requests a day produces 360 minutes a day. On GitHub standard runners, a 3,000 minute allowance covers 8 days of that. Day 9 through day 30 is invoiced. That is the pattern behind most surprise Actions bills on private repositories. The allowance is sized for a repository with a handful of daily runs, and job count grows faster than anyone budgets for, because matrices and required checks multiply per commit rather than per engineer. Two habits make the burn worse. A matrix of short jobs bills a full minute per job, so twelve 35 second lint shards bill 12 minutes where one 7 minute job would bill 7\. And jobs that spend most of their wall clock reinstalling dependencies bill for the download rather than for the test. ### [What the allowance never covers](#what-the-allowance-never-covers)Copy Anchor Link Larger runners are the first exclusion. GitHub excludes larger runners from included minutes, on public and private repositories alike, so the moment a private repository pins a 4-core or 8-core GitHub-hosted label, the allowance stops applying to that job entirely (checked on 2026-08-13). Storage is the second. Artifacts and logs draw on the storage allowance in the table above and bill by the gigabyte per day past it, which is a separate line from runner minutes on the same invoice. The third is timing. GitHub's default Actions spending limit is $0, so on a private repository the end of the allowance is a hard stop rather than a slow accrual. Queued jobs fail until someone raises the limit or the cycle rolls over, which usually happens during a release week. ### [Linux x64 rates at matched shapes](#linux-x64-rates-at-matched-shapes)Copy Anchor Link Private repositories pay the same published rate on all of them. The Linux x64 line is the direct replacement for `ubuntu-latest` and the Linux larger runners. | WarpBuild runner | Shape | WarpBuild rate | GitHub-hosted equivalent | GitHub rate | Difference | | -------------------------- | --------------- | -------------- | ------------------------------------- | ----------- | --------------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004/min | ubuntu-latest on private repositories | $0.006/min | 33 percent lower list price | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008/min | 4-core Linux larger runner | $0.012/min | 33 percent lower list price | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016/min | 8-core Linux larger runner | $0.022/min | 27 percent lower list price | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032/min | 16-core Linux larger runner | $0.042/min | 24 percent lower list price | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064/min | 32-core Linux larger runner | $0.082/min | 22 percent lower list price | GitHub list prices checked on 2026-08-13 against the [GitHub Actions per-minute rate list](https://docs.github.com/en/billing/reference/actions-minute-multipliers). WarpBuild rates come from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), verified the same day. Every label carries 150GB of SSD storage, and the Ubuntu 22.04 and Ubuntu 26.04 label families price identically, so pinning `warp-ubuntu-2604-x64-4x` changes the image and leaves the rate at $0.008 per minute. ### [A worked month on a private repository](#a-worked-month-on-a-private-repository)Copy Anchor Link Run the workflow above 600 times in a month on a Team plan, once per pull request run, and the two invoices look like this. The lint job is the only one on a standard GitHub-hosted label, so it is the only one the allowance touches: 600 runs at 3 minutes is 1,800 minutes, inside the 3,000 included. | Job | Minutes per month | GitHub-hosted rate | GitHub cost | WarpBuild runner | WarpBuild rate | WarpBuild cost | | ----- | ----------------- | -------------------------------- | ----------- | ------------------------- | -------------- | -------------- | | lint | 1,800 | ubuntu-latest, inside allowance | $0.00 | warp-ubuntu-latest-x64-2x | $0.004/min | $7.20 | | test | 5,400 | 4-core larger runner, $0.012/min | $64.80 | warp-ubuntu-latest-x64-4x | $0.008/min | $43.20 | | build | 3,600 | 8-core larger runner, $0.022/min | $79.20 | warp-ubuntu-latest-x64-8x | $0.016/min | $57.60 | | Total | 10,800 | $144.00 | $108.00 | | | | The difference is $36.00 a month, 25 percent lower on list prices checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The allowance earned GitHub $0.00 of margin on the lint job and nothing at all on the other two, which is the usual outcome once a private repository outgrows 2 vCPU. The table above compares one workload at fixed duration. Substitute your own job times and use the [WarpBuild pricing page](/pricing) for the full rate list before estimating a fleet total. ### [What a private repository never pays for](#what-a-private-repository-never-pays-for)Copy Anchor Link Seats are the item finance teams ask about first. A team of 20 engineers and a team of 200 pay the same rate for the same minute of the same runner, because there is no seat fee. Private repository count works the same way: archiving a repository removes its minutes from the next invoice with no plan change. Concurrency carries no price either. A merge queue that fans out to 200 parallel jobs pays for 200 jobs worth of minutes and nothing beyond them, so a private monorepo does not need a plan upgrade to run its full required-check set on every commit. ### [Seeing where the minutes go](#seeing-where-the-minutes-go)Copy Anchor Link Once minutes are the only variable, the reporting question becomes which repository and which job own them. The [reports documentation](/docs/ci/features/reports) covers the billing view that breaks cost down per job, per repository, and per runner label, with execution time and billed time as separate columns and CSV export on every table. The Jobs tab adds duration, queue time, CPU, and memory percentiles per job, which is how you find jobs paying for vCPU they never touch. CPU and memory columns need CI observability enabled on the runner. ## [Related Questions](#related-questions)Copy Anchor Link ### [Are GitHub Actions free for private repositories?](#are-github-actions-free-for-private-repositories)Copy Anchor Link Only up to the monthly minute allowance that comes with your GitHub plan. GitHub publishes 2,000 included minutes a month on Free, 3,000 on Pro and Team, and 50,000 on Enterprise Cloud, and every minute past the allowance bills at GitHub's published per-minute rate (checked on 2026-08-13). Public repositories follow different rules, covered in [are GitHub Actions free for open source repositories](/answers/are-github-actions-free-for-open-source). ### [Do GitHub Actions larger runners use included minutes on a private repository?](#do-github-actions-larger-runners-use-included-minutes-on-a-private-repository)Copy Anchor Link No. GitHub excludes larger runners from included minutes, so every larger runner minute bills at the published per-minute rate on public and private repositories alike (checked on 2026-08-13). Only the standard runner labels draw down the allowance, which is why the first move to a 4-core label usually shows up on the next invoice. The per-minute figures for every size are in [what GitHub Actions runners cost per minute](/answers/how-much-do-github-actions-runners-cost). ### [What happens when a private repository runs out of GitHub Actions minutes?](#what-happens-when-a-private-repository-runs-out-of-github-actions-minutes)Copy Anchor Link Queued jobs stop until the billing cycle resets or someone raises the Actions spending limit, which GitHub sets to $0 by default. Raising it converts the stoppage into a per-minute invoice line. Cutting billed minutes through caching and job parallelism is the lever that helps in both states, and the [guide to reducing GitHub Actions costs](/guides/reduce-github-actions-costs) works through each one with configuration examples. ### [What does it cost to run private repository workflows on WarpBuild?](#what-does-it-cost-to-run-private-repository-workflows-on-warpbuild)Copy Anchor Link A 2 vCPU Linux x64 runner is $0.004 per minute and a 4 vCPU runner is $0.008 per minute. Sizes, images, and rates for the whole x64 line are on the [Linux x64 runners page](/runners/linux-x64), and the complete price list is on the [WarpBuild pricing page](/pricing). --- # Are Managed GitHub Actions Runners Secure? | WarpBuild URL: https://www.warpbuild.com/answers/are-github-actions-runners-secure Description: Yes, when each job gets its own ephemeral virtual machine. WarpBuild creates a VM per job, destroys it after the build, and holds SOC 2 Type 2 attestation. # Are Managed GitHub Actions Runners Secure? Yes, when each job gets its own ephemeral virtual machine. WarpBuild creates a VM per job, destroys it after the build, and holds SOC 2 Type 2 attestation. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Yes, when the runner is an ephemeral virtual machine rather than a long-lived host. On WarpBuild, each runner runs in its own virtual machine, the virtual machines are created on demand and destroyed after each build, and no machine is reused between jobs. That single property answers most of what a reviewer asks about a managed runner, because it removes the class of problem where one job reads what an earlier job left on disk. Two more documented facts sit next to it: each runner has its own encrypted storage volume that is created on demand and destroyed after each build, and WarpBuild does not access or store any build secrets, which stay in your source code repository and reach only your runner environment. All three are stated in the [WarpBuild runner security documentation](/docs/ci/security). The table below puts those properties against the two baselines reviewers usually already know. | Property | WarpBuild managed runner | GitHub-hosted runner | Self-hosted runner you operate | | ------------------ | ------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------- | | Machine per job | A virtual machine created on demand for that job | A virtual machine created for that job | Whatever you configure, and persistent hosts or container pods are common | | Reuse between jobs | None. The virtual machine is destroyed after the build | None | Possible whenever the host outlives the job | | Disk | Its own encrypted volume, created on demand and destroyed after the build | Ephemeral, tied to the job | Your storage under your retention rules | | Cache handling | Encrypted and stored where only your runner can reach it | Managed by GitHub | Yours to encrypt and scope | | Build secrets | Never accessed or stored by WarpBuild. They stay in the repository | Held by GitHub | Held by GitHub, then exposed to whatever persists on the host | | Vendor attestation | SOC 2 Type 2 covering Security, Availability, and Confidentiality | GitHub's own compliance program | None. You are the operator | Sources for the first column: the [runner security documentation](/docs/ci/security) and [trust.warpbuild.com](https://trust.warpbuild.com/), checked on 2026-08-13\. The third column is the shape GitHub's own [self-hosted runner security guidance](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access) warns about, and it is why container-based runner setups such as ARC need extra work before they can face untrusted code. Every label in that catalog resolves to a machine under the first column. ## [Detail](#detail)Copy Anchor Link ### [What a reviewer can record from the isolation model](#what-a-reviewer-can-record-from-the-isolation-model)Copy Anchor Link The lifetime of the machine equals the lifetime of the job. Nothing is pooled and nothing is warmed by keeping an earlier job's machine alive, so a reviewer can write three facts into a questionnaire without a follow-up call: * **Isolation unit.** One virtual machine per job, with no shared tenancy between builds. * **Storage lifetime.** One encrypted volume per runner, created on demand and destroyed when the build ends. * **Secret custody.** GitHub holds the secrets. WarpBuild does not access or store them. The practical consequence of the second point is that anything a job wants to keep has to be written somewhere deliberately, through the cache, an artifact upload, or a snapshot. When caching is enabled, the cache is encrypted and stored in a location that is only accessible to your runner. ### [The labels a review actually covers](#the-labels-a-review-actually-covers)Copy Anchor Link A review covers the runner labels your workflows can name, so the label list is worth attaching to the questionnaire. Each label below gets the same per-job virtual machine and the same encrypted volume, at the size and published rate on the [pricing page](/pricing), checked on 2026-08-13. | Runner label | OS | vCPU | RAM | Runner storage | USD per minute | | --------------------------- | ------------------- | ---- | ----- | -------------- | -------------- | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.006 | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14 GB | 120GB SSD | $0.08 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 | The storage column is the volume that gets destroyed with the machine. A reviewer asking "where does the source checkout live" is asking about that column, and the answer is that it lives there for the length of the build and then goes away with the volume. ### [Public repositories](#public-repositories)Copy Anchor Link Managed runners register as self-hosted runners in the `Default` runner group of your GitHub organization, and GitHub disables self-hosted runners on public repositories by default. Enabling them is one checkbox: open the Default runner group settings for your organization and check `Allow public repositories`, as described in the [public repositories documentation](/docs/ci/public-repos). The caution behind GitHub's default is real for long-lived runners. A pull request from a fork can carry code that runs on the machine, and on a host that outlives the job that code can leave something behind for the next job. A per-job virtual machine that is destroyed after the build removes that path, which is why the [public repositories documentation](/docs/ci/public-repos) states that WarpBuild runners provide the same safety as GitHub-hosted runners for this case. Two GitHub-side settings still carry weight on a public repository. GitHub does not pass repository secrets to a workflow triggered by a pull request from a fork, per the [GitHub secrets documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets), and a narrow `permissions` block limits what the job's `GITHUB_TOKEN` can do if the workflow is ever tricked into using it. Copy Text ``` name: pr-checks on: pull_request: permissions: contents: read jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - name: Checkout the pull request head uses: actions/checkout@v5 with: persist-credentials: false - name: Install dependencies run: npm ci - name: Test run: npm test ``` `persist-credentials: false` keeps the checkout action from writing a token into the local git config, so a build script cannot read it back out of `.git/config`. On a machine that is destroyed after the build the exposure window is one job, and the setting closes it inside that job as well. Fork pull requests that genuinely need a credential belong in a separate workflow on a separate trigger, gated behind an environment with required reviewers, so the approval is a human decision rather than a property of the runner. ### [Reaching private services](#reaching-private-services)Copy Anchor Link Jobs that need a private database or an internal API reach it through the Networking addon rather than through an inbound hole in your network. You create a network addon configuration, add `network.name=` to the `runs-on` label, and the runner joins your Tailscale tailnet using an ephemeral node key when the job starts. When the job finishes, the ephemeral node is removed from the tailnet automatically, which matches the lifetime of the machine itself. Copy Text ``` jobs: integration: runs-on: warp-ubuntu-latest-x64-8x;network.name=production-tailnet steps: - uses: actions/checkout@v5 - name: Run integration tests against the private API run: ./scripts/integration.sh ``` The [networking documentation](/docs/ci/features/networking) covers the Tailscale trust credential and the tag scoping, and the tags a runner may advertise are a subset of the tags you configure, so the blast radius of a compromised job is bounded by the tailnet policy you wrote. One control plane detail belongs on the same page of a review. The WarpBuild control plane calls GitHub from three static egress IP addresses. If your organization uses a GitHub IP allow list or restricts inbound traffic to a GitHub Enterprise Server instance, those addresses need to be on the allow list, and they are published in the [GitHub Enterprise documentation](/docs/ci/ghe#control-plane-egress-ips). Teams whose policy puts the runner instance itself inside their own account run BYOC instead, where the instance, its network, and the storage bucket are resources in your cloud account. The [AWS BYOC page](/byoc/aws) covers that setup and the hardening steps that come with it. ### [Compliance evidence](#compliance-evidence)Copy Anchor Link The report and the supporting security documentation are request-gated: ask for them through [trust.warpbuild.com](https://trust.warpbuild.com/), which is the linked evidence for the attestation and the place a reviewer goes rather than a sales thread. The [security review checklist](/enterprise/ci-security-review) collects the five questions in the order reviewers ask them. ## [Related Questions](#related-questions)Copy Anchor Link ### [Are managed runners safe to use on public repositories?](#are-managed-runners-safe-to-use-on-public-repositories)Copy Anchor Link Yes. Each job still runs in its own virtual machine that is created on demand and destroyed after the build, so a pull request from a fork gets a clean machine that nothing else reuses. GitHub also withholds repository secrets from workflows triggered by a fork pull request. Public repositories need one setting change first: check `Allow public repositories` on the Default runner group in your GitHub organization settings, per the [public repositories documentation](/docs/ci/public-repos). The [open source pricing answer](/answers/are-github-actions-free-for-open-source) covers what those runs cost. ### [Can a runner reach services on our private network?](#can-a-runner-reach-services-on-our-private-network)Copy Anchor Link Yes, through the Networking addon. You create a network addon configuration, add `network.name=` to the `runs-on` label, and the runner joins your Tailscale tailnet with an ephemeral node key at job start and is removed from the tailnet when the job finishes. Separately, if your organization uses a GitHub IP allow list, allowlist the three static WarpBuild control plane egress IPs. Teams that want the instance inside their own account use [BYOC on AWS](/byoc/aws). ### [Does WarpBuild access or store our build secrets?](#does-warpbuild-access-or-store-our-build-secrets)Copy Anchor Link No. WarpBuild does not access or store any build secrets. Secrets are stored in your source code repository and are only accessible to your runner environment, which lives for the length of the build and is then destroyed along with its encrypted storage volume. The [source code answer](/answers/do-github-actions-runners-store-my-source-code) covers the same question for the repository contents. ### [What does a security review need from a runner vendor?](#what-does-a-security-review-need-from-a-runner-vendor)Copy Anchor Link Five documented answers: how jobs are isolated, how long build storage lives, whether the vendor can read secrets, what compliance attestation exists, and where runner traffic goes. The [security review checklist](/enterprise/ci-security-review) lays out the full sequence. --- # Are Snapshot Runners Safe for Public Repositories? | WarpBuild URL: https://www.warpbuild.com/answers/are-snapshot-runners-safe-for-public-repositories Description: Yes, with one rule: keep credentials off the snapshot disk. The documentation warns that a pull request run can boot from your alias on a public repository. # Are Snapshot Runners Safe for Public Repositories? Yes, with one rule: keep credentials off the snapshot disk. The documentation warns that a pull request run can boot from your alias on a public repository. ## [Answer](#answer)Copy Anchor Link Yes, provided nothing sensitive is on the snapshot disk when it is saved. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) sets out separate handling for public and private repositories, and the public repository rule is explicit: ensure that no sensitive information such as cloud credentials is stored in the snapshot, because others may access the snapshot using the alias in a pull request workflow run. The isolation model is unchanged by snapshots. Each job still gets its own virtual machine that is created for that job and destroyed when the build ends, and the [public repositories documentation](/docs/ci/public-repos) states that workflows run inside isolated VMs and provide the same safety as GitHub-hosted runners, which is the property that container based self-hosted setups such as ARC have to build for themselves. What a snapshot changes is the starting disk. Instead of a clean base image, the job boots from the disk an earlier job left behind, so the question shifts from who can reach the machine to what an earlier job wrote on it. The two documented behaviors sit side by side like this. | Question | Public repository | Private repository | | -------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | Documented risk | Others may access the snapshot using the alias in a pull request workflow run | GitHub may allocate a runner intended for snapshot jobs to a different job in the organization | | Who is on the other side | Anyone who can open a pull request against the repository | Other members of your GitHub organization | | Prerequisite to run at all | Check Allow public repositories on the Default runner group | None. Managed runners serve private repositories already | | Repository secrets | Withheld by GitHub from a workflow triggered by a fork pull request | Available under your environment and branch rules | | Documented mitigation | Keep sensitive information out of the snapshot | Run the cleanup script before the save step | Sources for the first two rows and the last row: the [snapshot runners documentation](/docs/ci/features/snapshot-runners), checked on 2026-08-13\. The public repository prerequisite comes from the [public repositories documentation](/docs/ci/public-repos), and the secrets row from the [GitHub secrets documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets). ## [Detail](#detail)Copy Anchor Link ### [The threat model in plain terms](#the-threat-model-in-plain-terms)Copy Anchor Link An alias is a string in a workflow file, such as `snapshot.key=web-app-main`. Anyone who can read the repository can read that string, and on a public repository anyone can open a pull request whose job requests it. The exposure is therefore everything the saving job left on the disk: an AWS profile under `$HOME/.aws`, a registry token in `.npmrc` or `.docker/config.json`, an SSH key, a kubeconfig, a signing key, or a private dependency checked out into the workspace. Two things are outside that exposure. Repository secrets are held by GitHub rather than by the runner, and GitHub does not pass them to a workflow triggered by a pull request from a fork. WarpBuild does not access or store build secrets either, a control the [WarpBuild security documentation](/docs/ci/security) states directly, and separately WarpBuild is SOC 2 Type 2, with [trust.warpbuild.com](https://trust.warpbuild.com/) as the linked evidence. The gap a snapshot opens is the disk, and the disk is the part your workflow controls. GitHub's own [self-hosted runner guidance](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access) warns about untrusted code on machines that outlive a job. A snapshot brings back part of that shape by choice, for one named alias, with a save step you wrote. ### [The safe pattern: trusted branches save, fork pull requests run clean](#the-safe-pattern-trusted-branches-save-fork-pull-requests-run-clean)Copy Anchor Link Send fork pull requests to a plain runner label and keep snapshot restores for branches inside the repository. The label is an expression, so one job definition covers all three cases. Copy Text ``` name: build on: push: branches: [main] pull_request: permissions: contents: read jobs: build: runs-on: >- ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) && 'warp-ubuntu-latest-x64-4x' || (github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-4x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-4x;snapshot.key=web-app-main') }} steps: - name: Checkout uses: actions/checkout@v5 with: persist-credentials: false - name: Install dependencies run: npm ci - name: Test run: npm test - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "web-app-main" fail-on-error: false ``` A fork pull request gets `warp-ubuntu-latest-x64-4x` with no snapshot label at all, which is a clean base image. The default branch uses `snapshot.enabled=true`, which always boots from the base image, so the published disk is rebuilt from a known starting point on every merge. Branches pushed to the repository itself use `snapshot.key` and get the warm disk. `persist-credentials: false` keeps the checkout action from writing a token into `.git/config`, which is one fewer credential on any disk that later becomes an image. Maintainers who want the warm path on fork pull requests as well should first make the snapshot boring, meaning it contains dependency trees and compiler caches and nothing that authenticates to anything, and second gate any job that needs a credential behind an environment with required reviewers so approval is a human decision. The [open source projects guide](/guides/github-actions-for-open-source-projects) covers the wider set of fork trade-offs. ### [Cleanup before every save](#cleanup-before-every-save)Copy Anchor Link The documented cleanup is two commands, run in the step immediately before the save: Copy Text ``` rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx ``` `git clean -ffdx` removes untracked files, including directories and files ignored by `.gitignore`, so it also removes build output you may have wanted in the image. On repositories where the build directory is gitignored, replace it with targeted deletions of the paths that hold credentials. Setting `fail-on-error: false` on the save action means a failed capture leaves the pipeline green while the security-relevant cleanup has already run. ### [Labels, rates, and what this costs to try](#labels-rates-and-what-this-costs-to-try)Copy Anchor Link Snapshot labels apply to WarpBuild Cloud Ubuntu runners only, and labels on any other runner type are silently ignored. The Ubuntu part of that catalog is the part this page applies to. Snapshot runners sit alongside the rest of the product surface: remote Docker builders, CI observability, an MCP server, and the Action Debugger. | Runner label | vCPU | RAM | USD per minute | | --------------------------- | ---- | ----- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | Rates from the [pricing page](/pricing), checked on 2026-08-13\. Snapshot restore bills $0.04 per job and snapshot storage $0.025 per snapshot-hour on top of those rates, which is $18.00 for one alias kept alive through a 30-day month. The clean label a fork pull request lands on carries no snapshot line items at all. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can a fork pull request read a snapshot on a public repository?](#can-a-fork-pull-request-read-a-snapshot-on-a-public-repository)Copy Anchor Link Treat it as yes. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) warns that on public repositories others may access the snapshot using the alias in a pull request workflow run, so everything on that disk should be considered readable by anyone who can open a pull request. Repository secrets are a separate matter, since GitHub does not pass them to a workflow triggered by a fork pull request, per the [GitHub secrets documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets). ### [What should never be written into a snapshot?](#what-should-never-be-written-into-a-snapshot)Copy Anchor Link Cloud credential files, registry tokens, signing keys, SSH keys, and any token a login step wrote to disk. Run the documented cleanup, `rm -rf $HOME/.ssh $HOME/.aws` followed by `git clean -ffdx`, in the step immediately before `WarpBuilds/snapshot-save`. The [snapshot runners hub](/runners/snapshot) covers the save and restore patterns those commands sit inside. ### [Do private repositories need the same care?](#do-private-repositories-need-the-same-care)Copy Anchor Link Yes. WarpBuild provisions runners at the organization level, and GitHub may allocate a runner intended for snapshot jobs to a different job in the organization, which can expose snapshot contents to other members of the organization. The same cleanup step before the save covers both cases, and [are managed GitHub Actions runners secure](/answers/are-github-actions-runners-secure) covers the isolation model the snapshot sits on top of. --- # Can AI Agents Run GitHub Actions Jobs? | WarpBuild URL: https://www.warpbuild.com/answers/can-ai-agents-run-github-actions-jobs Description: Yes. AI agents start GitHub Actions workflows like any other actor. What matters is the environment the job boots into and the feedback loop it returns. # Can AI Agents Run GitHub Actions Jobs? Yes. AI agents start GitHub Actions workflows like any other actor. What matters is the environment the job boots into and the feedback loop it returns. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Yes. A GitHub Actions workflow run starts from an event, and an AI agent holding a token produces those events the same way a person does: it dispatches a workflow, pushes a branch, opens a pull request, or posts a comment that a trigger matches. The two questions worth spending time on are what environment the job boots into and what feedback loop the agent gets back, because those two decide whether an agent finishes a task or burns twenty attempts relearning the same setup. Nothing in GitHub Actions distinguishes an agent-authored run from a human-authored one. The run carries the identity of the token that created it, the same `permissions` block applies, the same secrets policy applies, and the exit code of each step is the signal that comes back. An agent reads that exit code and the job log exactly as a person reads a red check on a pull request. Here are the triggers an agent can reach, and what each one costs to wire up. The full event list is in the [GitHub Actions events reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13. | Trigger | What the agent does to start a run | Typical use | | -------------------- | ------------------------------------------- | ---------------------------------------- | | workflow\_dispatch | Calls the dispatch API with inputs | A named task handed to the agent | | repository\_dispatch | Posts a custom event type to the repository | An external system asking for an attempt | | push | Pushes a branch it created | Validating work the agent already wrote | | pull\_request | Opens or updates a pull request | The normal review loop | | issue\_comment | Comments a command such as /agent fix | A human handing off a task in the thread | The environment side is where the design work sits. Every job on an ephemeral runner boots from a clean base image, so an agent that attempts a task ten times pays for the same `apt-get install`, `npm ci`, container pull, and cold compile ten times. On the Ubuntu part of that catalog a snapshot runner boots the job from a disk an earlier job captured, so the packages and dependency trees are already present when the first step executes. Snapshot runners prepare the environment, while the MCP server gives an agent access to supported operational tools. ## [Detail](#detail)Copy Anchor Link ### [Giving the agent a prepared environment](#giving-the-agent-a-prepared-environment)Copy Anchor Link The mechanism is two labels and one action, documented in full on the [snapshot runners page](/docs/ci/features/snapshot-runners). * `snapshot.enabled=true` turns the feature on and always boots from the base image. Put it on the workflow that produces the environment, so the captured disk is built from a known starting point. * `snapshot.key=` turns the feature on and boots from the existing snapshot for that alias. When no snapshot exists yet, the runner boots from the base image and the job proceeds normally. Both are appended to the runner label after a semicolon, for example `warp-ubuntu-latest-x64-8x;snapshot.key=agent-env-main`. A runner created from a snapshot exposes the environment variable `WARPBUILD_SNAPSHOT_KEY` set to the alias it booted from, which is how a workflow step knows whether it is on a warm disk and can skip setup work. The producing workflow runs on the default branch and republishes the environment on every merge, so the image tracks the repository: Copy Text ``` name: agent-environment on: push: branches: [main] jobs: publish-environment: runs-on: warp-ubuntu-latest-x64-8x;snapshot.enabled=true steps: - uses: actions/checkout@v5 - name: Install system packages run: | sudo apt-get update sudo apt-get install -y libpq-dev protobuf-compiler - name: Install dependencies run: npm ci - name: Warm the build and test caches run: | npm run build npm test - name: Seed fixtures run: ./scripts/seed-fixtures.sh - name: Remove credentials before capture run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot uses: WarpBuilds/snapshot-save@v1 with: alias: "agent-env-main" fail-on-error: false wait-timeout-minutes: 60 ``` The agent's own workflow asks for that alias and skips the setup the disk already carries: Copy Text ``` name: agent-task on: workflow_dispatch: inputs: task: description: Task for the agent to attempt required: true issue_comment: types: [created] jobs: attempt: if: >- github.event_name == 'workflow_dispatch' || startsWith(github.event.comment.body, '/agent') runs-on: warp-ubuntu-latest-x64-8x;snapshot.key=agent-env-main timeout-minutes: 30 permissions: contents: write pull-requests: write steps: - uses: actions/checkout@v5 - name: Report the starting point run: | if [ -z "$WARPBUILD_SNAPSHOT_KEY" ]; then echo "cold boot, running full setup" npm ci else echo "booted from snapshot $WARPBUILD_SNAPSHOT_KEY" fi - name: Run the agent env: AGENT_TASK: ${{ inputs.task || github.event.comment.body }} run: ./scripts/run-agent.sh - name: Test run: npm test - name: Open a pull request if: success() run: ./scripts/open-pr.sh ``` Four operational facts belong with that configuration. Snapshots are deleted after 15 days, so an alias no workflow refreshes stops resolving and the next job boots from the base image and runs normally. The `/tmp` directory does not persist, because booting from a snapshot is a boot and that directory is cleaned on reboots. Credentials written to disk do persist, which is why the cleanup step runs immediately before the capture. And booting a snapshot runner takes 45 to 60 seconds, slower than a default runner, so the state the disk carries has to be worth more than that minute. Platform scope matters here more than usual, because agents get pointed at whatever label is in the file. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. On a Windows, macOS, or BYOC label the snapshot label is silently ignored and the job runs normally with no error, so the only way to confirm the feature is active is to check whether `WARPBUILD_SNAPSHOT_KEY` was set. ### [Giving the agent a feedback loop](#giving-the-agent-a-feedback-loop)Copy Anchor Link An agent that can only read exit codes is working with one bit per attempt. WarpBuild hosts a Model Context Protocol server at `https://mcp.warpbuild.com/mcp`, and an MCP host connected to it can interact with the WarpBuild API to create runners, create runner images, and read what already exists. The setup is a key and a URL, per the [MCP documentation](/docs/ci/mcp). Generate an API key from the WarpBuild dashboard at `app.warpbuild.com/settings/api-keys` with the CI box checked, then register the server with your host: Copy Text ``` { "mcpServers": { "warpbuild": { "url": "https://mcp.warpbuild.com/mcp", "headers": { "Authorization": "Bearer " } } } } ``` The documentation walks through Cursor step by step and names Antigravity as another host that connects the same way. Any host that supports a remote MCP server addressed by URL with custom headers will work. Treat the key as a production credential. A CI-scoped key can create and delete runners, so keep it in the host's secret storage rather than a committed file, and rotate it from the same API keys page. Handing an autonomous process a credential that provisions infrastructure deserves the same review as handing it a cloud key. ### [What the arithmetic looks like](#what-the-arithmetic-looks-like)Copy Anchor Link Snapshot runners are billed on top of the runner's per-minute rate. Both line items come from the [pricing page](/pricing), checked on 2026-08-13: snapshot restore at $0.04 per job, and snapshot storage at $0.025 per hour per snapshot. The break-even is easy to hold in your head. Let `r` be the label's per-minute rate and `S` the setup minutes the snapshot removes. A restore costs one extra boot minute plus the $0.04 fee, so it pays when `S > 1 + 0.04 / r`. | Runner label | vCPU | RAM | Price per minute | Setup minutes the snapshot must remove | | ---------------------------- | ---- | ------ | ---------------- | -------------------------------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | 11 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | 6 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | 3.5 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | 2.25 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 | 1.6 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | 4.3 | | warp-ubuntu-latest-arm64-16x | 16 | 64 GB | $0.024 | 2.7 | Rates are from the [cloud runners documentation](/docs/ci/cloud-runners) and the pricing page, checked on 2026-08-13\. The threshold falls as the label gets larger, because the fixed $0.04 fee buys fewer minutes at a higher rate. Now a month of agent work, with the assumptions stated so you can substitute your own: 50 agent attempts per weekday across 22 weekdays, which is 1,100 attempts; each attempt on `warp-ubuntu-latest-x64-8x` at $0.016 per minute; 14 minutes per attempt on a cold boot, of which 8 minutes is environment setup; one live alias refreshed on every merge and held for the full 720 hours. With the snapshot, setup drops to zero and boot adds a minute, so an attempt takes 7 minutes. | Line | Cold boot every attempt | Snapshot alias | | ------------------------ | ----------------------- | -------------- | | Rate per minute | $0.016 | $0.016 | | Minutes per attempt | 14 | 7 | | Runner minutes per month | 15,400 | 7,700 | | Runner cost per month | $246.40 | $123.20 | | Snapshot restore fees | $0 | $44.00 | | Snapshot storage | $0 | $18.00 | | Total per month | $246.40 | $185.20 | The 8 minutes of setup is the assumption doing the work. At 3 minutes of setup the snapshot column loses to the plain column, which is what the break-even table predicts for an 8 vCPU label. Storage is the second one: one alias is $18.00 per month, and ten per-branch aliases held the same way is $180.00, so counting aliases matters before adopting a per-branch scheme. ### [The named result](#the-named-result)Copy Anchor Link Adaptive reports a 60 percent reduction in manual engineering intervention per feature after making WarpBuild snapshot runners the default environment for its AI code agents. Adaptive builds financial infrastructure for construction, and its engineering team previously configured a CI environment by hand every time an agent needed to build and test. The write-up is on the [Adaptive customer page](/customers/adaptive). The [AI coding agents solution page](/solutions/ai-agents-github-actions) carries the longer version of this build, and the [MCP server guide](/guides/github-actions-mcp-server) covers the query side in depth. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can an AI agent trigger a GitHub Actions workflow on its own?](#can-an-ai-agent-trigger-a-github-actions-workflow-on-its-own)Copy Anchor Link Yes. A workflow run starts from an event, and an agent that holds a token can produce those events: it can call the `workflow_dispatch` or `repository_dispatch` API, push a branch, open a pull request, or post a comment that an `issue_comment` trigger matches. GitHub treats the run as it treats any other run, with the same permissions model and the same job logs. The [AI coding agents solution page](/solutions/ai-agents-github-actions) shows the full workflow shape. ### [How do I stop an agent from reinstalling the toolchain on every attempt?](#how-do-i-stop-an-agent-from-reinstalling-the-toolchain-on-every-attempt)Copy Anchor Link Boot the job from a snapshot. Append `snapshot.key=` to the `runs-on` label and the runner starts from a disk captured by an earlier job, with system packages, dependency trees, checkouts, and pulled container images already on it. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, and snapshot labels on Windows, macOS, or BYOC runners are silently ignored. The [snapshot runners hub](/runners/snapshot) lists the labels that accept the feature. ### [How does an agent read WarpBuild runner and image state directly?](#how-does-an-agent-read-warpbuild-runner-and-image-state-directly)Copy Anchor Link WarpBuild hosts a Model Context Protocol server at `https://mcp.warpbuild.com/mcp`. Generate an API key with the CI scope from the WarpBuild dashboard, add the server URL to your MCP host with an `Authorization` header of `Bearer `, and the host can interact with the WarpBuild API to create runners, create runner images, and read what already exists. The [MCP documentation](/docs/ci/mcp) has the configuration block, and the [MCP server guide](/guides/github-actions-mcp-server) has the worked prompts. ### [What does an agent-heavy GitHub Actions workload cost?](#what-does-an-agent-heavy-github-actions-workload-cost)Copy Anchor Link Runner minutes at the label rate, plus $0.04 for each job that boots from a snapshot and $0.025 per snapshot-hour of storage, checked on 2026-08-13\. On `warp-ubuntu-latest-x64-8x` at $0.016 per minute, a restore is worth 2.5 minutes of runner time, so a snapshot that removes more than 3.5 minutes of setup pays for its own fee. Every label rate is on the [pricing page](/pricing), and [GitHub Actions observability](/solutions/github-actions-observability) covers reading where the minutes actually go. --- # Can an AI Agent Query My GitHub Actions Metrics? | WarpBuild URL: https://www.warpbuild.com/answers/can-an-ai-agent-query-my-github-actions-metrics Description: Yes. WarpBuild hosts an MCP server at mcp.warpbuild.com/mcp that exposes the WarpBuild API to an editor assistant, so it reads job, runner, and cost data. # Can an AI Agent Query My GitHub Actions Metrics? Yes. WarpBuild hosts an MCP server at mcp.warpbuild.com/mcp that exposes the WarpBuild API to an editor assistant, so it reads job, runner, and cost data. ## [Answer](#answer)Copy Anchor Link Yes. WarpBuild hosts a Model Context Protocol server at `https://mcp.warpbuild.com/mcp` that exposes the WarpBuild API to an MCP host such as an editor assistant, so questions about job duration, queue time, runner utilization, and cost get answered where you type instead of across three dashboard tabs ([MCP support](/docs/ci/mcp)). Setup is two steps: generate an API key with the CI scope, then add the server URL and an `Authorization: Bearer` header to the host's MCP configuration. The reason the question comes up at all is that GitHub Actions on its own reports run duration, queue time, job counts, and conclusion ([GitHub Actions metrics](https://docs.github.com/en/actions/concepts/metrics)). Anything about the machine underneath comes from an agent on the runner. WarpBuild records both sides and exposes them through the API, and CI observability is one part of the product surface alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger. Here is what an assistant can pull, and which surface each field comes from. | Question asked in the editor | Fields that answer it | Documented in | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | | Which jobs got slower this month | Duration P75 and P90 per repository, workflow, and job name | [Reports](/docs/ci/features/reports) | | Are we waiting on capacity | Queue time P75 and P90 per runner label and stack | [Reports](/docs/ci/features/reports) | | Is this job on the right runner | Max CPU, memory, filesystem, disk I/O, and network utilization per instance | [Observability](/docs/ci/features/observability) | | What did that workflow cost | Per job execution rows with repository, job name, runner label, stack, execution time, billed time, and cost | [Reports](/docs/ci/features/reports) | | Which labels exist and what sits behind them | Runner name, labels, pool size, image id, and instance types | [Automation API](/docs/ci/api-keys/automation) | ## [Detail](#detail)Copy Anchor Link ### [The two setup steps](#the-two-setup-steps)Copy Anchor Link Generate the key at [app.warpbuild.com/settings/api-keys](https://app.warpbuild.com/settings/api-keys), set a name, and check CI. The key is displayed once, so copy it into your secret store at the moment it appears; the name and the scopes stay editable from the same page afterwards. Then register the server with your host: Copy Text ``` { "mcpServers": { "warpbuild": { "url": "https://mcp.warpbuild.com/mcp", "headers": { "Authorization": "Bearer " } } } } ``` The [MCP support documentation](/docs/ci/mcp) walks through Cursor and names Antigravity as another host that connects the same way. Any host that accepts a remote MCP server by URL with custom headers takes the same block. After a reload, ask a listing question first, so a wrong key fails on a read rather than partway through a write. ### [A worked question](#a-worked-question)Copy Anchor Link Take a real one: `integration` in `acme/api` used to finish in nine minutes and now finishes in thirteen, and someone wants to know whether that is a code change, a capacity problem, or an undersized runner, and what the extra minutes are costing. The assistant reads three things to answer it. Duration P75 and P90 for that repository, workflow, and job name over the two periods, which separates a slower job from a noisier one. Queue time P75 and P90 for the label, which separates waiting from running. CPU and memory percentiles for the same job, which say whether the machine is saturated. The answer that comes back looks like this, on `warp-ubuntu-latest-x64-8x` at $0.016 per minute with 1,200 runs per month. | Reading | Last month | This month | | -------------- | ---------- | ---------- | | Duration P75 | 9 min | 13 min | | Queue time P75 | 12 s | 14 s | | CPU P90 | 62 percent | 91 percent | | Memory P90 | 40 percent | 44 percent | | Runner minutes | 10,800 | 15,600 | | Runner cost | $172.80 | $249.60 | Queue time held, so capacity is not the story. CPU P90 crossed the threshold WarpBuild uses to mark an instance as under provisioned, which is [80 percent for max sustained CPU, max memory utilization, or max filesystem utilization](/docs/ci/features/observability). The bill moved $76.80 per month. Rates below come from the [pricing page](/pricing), checked on 2026-08-13, and they are what the assistant multiplies against. | Label | vCPU | RAM | Per minute | | --------------------------- | ---- | ----- | ---------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | Moving to `warp-ubuntu-latest-x64-16x` doubles the rate and only pays back if the job actually halves. That is a measurement, so run it on a branch and compare the same percentiles. ### [The API surface behind the answer](#the-api-surface-behind-the-answer)Copy Anchor Link The inventory endpoints are documented directly: `GET /api/v1/stacks`, `GET /api/v1/runner-images`, `GET /api/v1/runners` with `only_custom_runners` and `image` filters, and `GET /api/v1/runners/`. Calls carry `Authorization: Bearer wkey-...`, the same header the MCP block sets. Two practical notes. The host lists the server's tools when it connects, so read that list rather than guessing at names. And every [report tab exports CSV](/docs/ci/features/reports) with all rows matching the current filters, which is the fallback when you want the raw table in a spreadsheet next to the assistant's summary. ### [The permission boundary](#the-permission-boundary)Copy Anchor Link The key is the boundary. API keys are scoped to combinations of WarpBuild products, and the [scopes available today are CI, Cache, and Helios](/docs/ci/api-keys). A CI scoped key reaches CI runners, images, and stacks for your organization; a key without the Cache scope cannot touch cache endpoints. Nothing in the key grants repository access, so what the assistant knows about your workflow files is whatever the host already had open. Write access travels with read access on the same scope. A CI scoped key can create runners and delete them, and the documentation states that [deleting a runner is irreversible](/docs/ci/api-keys/automation), so keep deletes out of assistant hands during the first weeks and ask it to echo any request body before it posts. Rotate from the same API keys page. ### [What asking costs](#what-asking-costs)Copy Anchor Link The same reports cover all four. Full rates are on the [pricing page](/pricing), and the fleet wide picture is [GitHub Actions observability](/solutions/github-actions-observability). ## [Related Questions](#related-questions)Copy Anchor Link ### [Which GitHub Actions metrics can an assistant read through the MCP server?](#which-github-actions-metrics-can-an-assistant-read-through-the-mcp-server)Copy Anchor Link The ones WarpBuild records: run count, success rate, duration P75 and P90, queue time P75 and P90, and CPU and memory P75 and P90 per unique repository, workflow, and job name; per job cost rows from the Billing report; and per instance CPU, memory, filesystem, disk I/O, and network utilization from observability. The field lists are in the [reports reference](/docs/ci/features/reports), and the [MCP server guide](/guides/github-actions-mcp-server) has the worked prompts. ### [Does an API key give the assistant my source code or my secrets?](#does-an-api-key-give-the-assistant-my-source-code-or-my-secrets)Copy Anchor Link No. The key carries WarpBuild product scopes, and the scopes available today are CI, Cache, and Helios, so it reaches the WarpBuild API for your organization and nothing else. Repository contents reach the assistant only through the repository access the host already has. A CI scoped key can create and delete runners and a delete is irreversible, so treat it as a production credential and keep it in the host's secret storage. The scope list is on the [API keys page](/docs/ci/api-keys), and [running AI coding agents on GitHub Actions](/solutions/ai-agents-github-actions) covers the wider credential question. ### [Can an assistant answer questions about jobs shorter than a minute?](#can-an-assistant-answer-questions-about-jobs-shorter-than-a-minute)Copy Anchor Link Partly. Run count, success rate, duration, queue time, and cost are recorded for every job, so those questions answer normally. Observability collects metrics and logs only for jobs longer than about 1 minute, so CPU and memory render as a dash in the Jobs report for short jobs and the assistant has no utilization series to reason over. The collection rules are in the [observability reference](/docs/ci/features/observability), and the protocol itself is defined in [MCP server](/glossary/mcp-server). --- # Can BYOC Runners Reach Internal Services? | WarpBuild URL: https://www.warpbuild.com/answers/can-byoc-runners-reach-my-internal-services Description: Yes. BYOC runners are ephemeral EC2 instances inside your own VPC, so reaching an internal database, registry, or API follows your own routing rules. # Can BYOC Runners Reach Internal Services? Yes. BYOC runners are ephemeral EC2 instances inside your own VPC, so reaching an internal database, registry, or API follows your own routing rules. Yes. WarpBuild BYOC runners are ephemeral EC2 instances launched inside your own VPC with no tunnel or broker in the path, so an internal database, an internal package registry, or an internal API is reachable when your route tables, the runner security group, and the service's own inbound rules allow it, per the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config). ## [Answer](#answer)Copy Anchor Link Reachability on BYOC is a property of where the instance sits. The control plane calls the AWS API with the IAM role you granted and launches the instance in the subnets of your stack, and from that point the runner is an EC2 instance like any other in the account. Four things decide whether it can open a connection to an internal service, all of them yours: 1. Subnet placement. The stack runs in one region of your account, with public subnets and optional private subnets, per the [configuration prerequisites](/docs/ci/byoc/aws/config). 2. Route tables. The runner subnet needs a route to the service network. 3. The runner security group. The [security hardening guide](/docs/ci/byoc/aws/security) states that the default stack security group allows all egress and permits ingress from VPC subnet CIDRs only, and recommends removing inbound rules entirely. 4. The inbound rule on the service. An internal database accepts the connection when its own security group allows the runner security group or the runner subnet CIDR. The workflow file does not change. A job targets a custom runner by its runner ID, prefixed with `warp-custom-`, and reaches the internal endpoint by its private address: Copy Text ``` name: integration on: pull_request: jobs: migrate: runs-on: warp-custom-linux-ci-4x steps: - uses: actions/checkout@v4 - name: Check the internal API is reachable run: curl -fsS http://internal-api.svc.internal:8080/health - name: Run migrations against the internal database run: psql "postgres://db.internal:5432/app" -f migrations.sql - name: Publish to the internal package registry run: npm publish --registry https://registry.internal ``` BYOC Linux runners carry a WarpBuild fee of $0.002 per runner minute and your own AWS account bills the compute, storage, and data transfer ([pricing page](/pricing), checked on 2026-08-13). ## [Detail](#detail)Copy Anchor Link ### [Placement options and what each one reaches](#placement-options-and-what-each-one-reaches)Copy Anchor Link BYOC runs on AWS, GCP, and Azure. The placements below are the AWS ones, taken from the [configuration prerequisites](/docs/ci/byoc/aws/config) and the [security hardening guide](/docs/ci/byoc/aws/security), checked on 2026-08-13. | Placement | What the runner reaches | What has to be true | | ------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | Stack VPC holds the internal services | Anything in the VPC by private address | The service security group allows the runner security group or subnet CIDR | | Dedicated CI VPC in the same account | Services in the other VPC once the networks are connected | A peering connection or a shared transit hub between the two VPCs, plus routes on both sides | | Dedicated CI account | Services in the production account across the connected network | Cross-account network connectivity and the matching inbound rules on the service | | Private subnets with static IPs | Third-party endpoints behind an allowlist | Managed gateways serving the private subnets, and the partner allowlisting the gateway addresses | | Public subnets, the default | Public endpoints and anything routable from the stack VPC | Outbound rules for the required destinations | The security guide recommends the dedicated VPC or the dedicated account for CI, because it limits the blast radius if a runner is compromised and gives auditors a clean boundary. The subnet-level detail behind these rows, including address headroom and the recommendation of three subnets per tier across availability zones, is laid out in [BYOC networking on AWS](/byoc/aws/networking) and in the [BYOC on AWS overview](/byoc/aws). Two destination classes bypass the question entirely. The stack routes S3 cache traffic and ECR image pulls inside the AWS network for runners in both public and private subnets, so those calls do not depend on your own routing work, per the [configuration prerequisites](/docs/ci/byoc/aws/config). ### [Reaching AWS services rather than IP endpoints](#reaching-aws-services-rather-than-ip-endpoints)Copy Anchor Link An internal service fronted by an AWS API, such as an S3 bucket, a private ECR repository, or a database using IAM authentication, needs identity as well as reachability. That comes from an instance profile attached at the runner level in the WarpBuild dashboard, under Custom Runner configuration > Instance Profile ARN, documented in the [security hardening guide](/docs/ci/byoc/aws/security). Each runner configuration carries its own profile, so a deploy runner and a test runner can hold different permissions while sharing a stack. WarpBuild does not add permissions to runner workloads beyond what the profile you attach grants. ### [The alternative on WarpBuild-hosted runners](#the-alternative-on-warpbuild-hosted-runners)Copy Anchor Link Teams that do not run BYOC get private access from the [networking addon](/docs/ci/features/networking). You create a network addon configuration holding a Tailscale OIDC client ID, then append `network.name=` to the `runs-on` label. At job start WarpBuild authenticates the runner to your tailnet with a short-lived OIDC token and an ephemeral node key, the runner reaches whatever the ACL policy allows, and the node is removed when the job ends: Copy Text ``` jobs: deploy: runs-on: warp-ubuntu-latest-x64-4x;network.name=production-tailnet ``` The addon is documented as supported on all four. It also works on BYOC runners, so a fleet can use subnet placement for services inside the account and the addon for a tailnet that spans offices and other clouds. The full setup, the reserved flags, and the ACL shape are covered in [can GitHub Actions runners reach my private network](/answers/can-github-actions-runners-access-my-private-network). For hosted runners, US and EU are the regions WarpBuild names publicly. ### [What a network security review asks](#what-a-network-security-review-asks)Copy Anchor Link | Review question | Documented answer | | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Does WarpBuild open inbound connections to the instances? | No inbound path is required. The hardening guide recommends a security group with no inbound rules at all, since runners initiate every connection | | What outbound access do runners require? | api.warpbuild.com for registration and lifecycle, github.com and api.github.com for Git and the Actions API, package registries your builds use, and outbound HTTPS for runner telemetry | | What AWS permissions does a job hold? | Exactly what the attached instance profile grants | | Can runners talk to each other? | They have no need to. Network ACLs or a tighter security group block intra-subnet traffic | | What protects instance credentials from SSRF? | IMDSv2 can be required per runner under Runner Specs > Require IMDSv2 | | How long does an instance live? | Each job runs on a freshly provisioned instance that is terminated when the job completes | | How are the resources identified for audit? | Every stack resource carries the managed-by: warpbuild tag, plus stack, runner, and org tags | Rows come from the [security hardening guide](/docs/ci/byoc/aws/security) and the [configuration guide](/docs/ci/byoc/aws/config), checked on 2026-08-13\. The same table works as the answer sheet when a network team reviews the stack before approval. ### [What the internal-service path costs](#what-the-internal-service-path-costs)Copy Anchor Link Take 3,000 jobs a month at 12 minutes each on a BYOC Linux runner, every job reading from an internal database and publishing to an internal registry. That is 36,000 runner minutes. | Line item | Rate | Monthly | | ------------------------------------------------------- | ----------------- | ------------- | | WarpBuild BYOC fee, 36,000 runner minutes | $0.002 per minute | $72.00 | | EC2, EBS, and data transfer inside your account | Your AWS rates | Billed by AWS | | Same 36,000 minutes on hosted warp-ubuntu-latest-x64-4x | $0.008 per minute | $288.00 | WarpBuild rates come from the [pricing page](/pricing), checked on 2026-08-13\. The two runner lines cover different things: the hosted rate includes the compute, while the BYOC fee sits on top of an EC2 bill you already control. Private subnets add managed gateway charges from AWS on top of both, and that arithmetic is worked through in [BYOC networking on AWS](/byoc/aws/networking). ## [Related Questions](#related-questions)Copy Anchor Link **Do BYOC runners need a tunnel to reach an internal service?** No. The instances launch inside your own VPC, so reachability is decided by your route tables, the runner security group, and the inbound rules on the service. The wider design decision between joining a private network and placing the runners inside it is covered in [running GitHub Actions runners inside your own VPC](/guides/github-actions-runners-in-your-vpc). **Can a runner reach a service in a different VPC or account?** Yes, when the two networks are already connected and the routes exist on both sides. The [security hardening guide](/docs/ci/byoc/aws/security) recommends a dedicated CI VPC or a dedicated CI account for exactly this shape, which keeps CI away from production while leaving a controlled path to the services a build needs. **How does a runner authenticate to AWS services such as S3 or RDS?** Through an instance profile set at the runner level under Custom Runner configuration > Instance Profile ARN. Scope one profile per runner configuration, and see the [BYOC on AWS overview](/byoc/aws) for how the profile fits the rest of the IAM surface. **Can WarpBuild-hosted runners reach internal services too?** Yes, through the [networking addon](/docs/ci/features/networking), which joins the runner to your Tailscale tailnet as an ephemeral node for the life of the job and works on hosted and BYOC runners alike. Check your network against the [AWS BYOC configuration prerequisites](/docs/ci/byoc/aws/config), harden the stack with the [security guide](/docs/ci/byoc/aws/security), and price a fleet against your own minutes on the [pricing page](/pricing). --- # Can GitHub Actions Build iOS Apps Without a Mac? | WarpBuild URL: https://www.warpbuild.com/answers/can-github-actions-build-ios-apps-without-a-mac Description: No. An iOS archive needs Xcode, and Xcode runs only on macOS, so the job lands on a macOS runner such as warp-macos-latest-arm64-6x at $0.08 a minute. # Can GitHub Actions Build iOS Apps Without a Mac? No. An iOS archive needs Xcode, and Xcode runs only on macOS, so the job lands on a macOS runner such as warp-macos-latest-arm64-6x at $0.08 a minute. Last verified: 2026-08-13 No. An iOS archive is produced by Xcode, Apple publishes Xcode for macOS only ([Xcode minimum requirements](https://developer.apple.com/support/xcode/)), and no supported cross-platform path produces a signed `.ipa`, so the job that builds one has to land on a macOS runner. ## [Answer](#answer)Copy Anchor Link What you can skip is owning the hardware: a macOS runner is rented per minute by the workflow, starting at $0.08 per minute for `warp-macos-latest-arm64-6x` ([WarpBuild cloud runners](/docs/ci/cloud-runners), checked on 2026-08-13). Three of Apple's tools decide the platform of the job. `xcodebuild` drives the build and the archive, `xcrun simctl` boots the simulator runtimes, and `codesign` plus `notarytool` handle signing. All four ship inside Xcode and the macOS command line tools, and Apple does not distribute the iOS SDKs for any other operating system. The open source Swift toolchain does run on Linux ([swift.org install](https://www.swift.org/install/linux/)), which is why Swift server code compiles there, and that toolchain carries the standard library and Foundation rather than UIKit, SwiftUI, or the iOS SDKs. GitHub Actions answers this with macOS runners, and the runner is where the cost of an iOS pipeline concentrates. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the macOS fleet registers against your organization as five labels: | Runner label | OS | vCPU | Memory | Storage | Per minute | | ----------------------- | -------- | ---- | ------ | --------- | ---------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | Every row comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The macOS 15 labels also answer to the aliases `warp-macos-latest-arm64-6x` and `warp-macos-latest-arm64-12x`, so `latest` tracks macOS 15 today. Pin an explicit version when a release branch must stay on one image. Per-minute rates for every label are on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [What moves to Linux and what stays on macOS](#what-moves-to-linux-and-what-stays-on-macos)Copy Anchor Link A useful iOS workflow is a Linux job feeding a macOS job. Sorting the steps by which binary they call gives a clean split: | Pipeline step | Runner | Why it lands there | | ------------------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------- | | Workflow, YAML, and shell linting | Linux | Needs a shell and a linter binary | | Swift package dependency resolution | Linux | Manifest evaluation and Git fetches under the Linux Swift toolchain | | Ruby and Bundler install for tooling gems | Linux | Package installation, no Xcode call | | Backend or API contract tests for the app's services | Linux | Server code, already cross-platform | | Changelog, release notes, and screenshot metadata generation | Linux | Text and asset processing | | App Store Connect metadata and TestFlight group changes | Linux | REST over HTTPS ([App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi)) | | Unit and UI tests on a simulator | macOS | xcrun simctl and the simulator runtimes | | xcodebuild archive and \-exportArchive | macOS | Xcode and the iOS SDK | | Code signing and notarization | macOS | codesign and notarytool | | Binary upload to App Store Connect | macOS | The upload tool ships with Xcode, and the .ipa is produced there | One more constraint pushes work leftward: macOS runners do not support nested virtualization and cannot run Docker ([cloud runners documentation](/docs/ci/cloud-runners)). Any container step in an iOS pipeline, including a service container for integration tests or a Dockerized backend used by UI tests, belongs on a Linux runner regardless of cost. ### [A split pipeline](#a-split-pipeline)Copy Anchor Link Copy Text ``` name: ios on: pull_request: jobs: checks: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Lint workflows and project configuration run: | yamllint .github/workflows ./scripts/check-plist-keys.sh - name: Resolve Swift package dependencies run: swift package resolve - name: Generate release notes run: ./scripts/release-notes.sh > release-notes.md - uses: actions/upload-artifact@v4 with: name: release-notes path: release-notes.md archive: needs: checks runs-on: warp-macos-26-arm64-12x env: SIMULATOR_NAME: ${{ vars.SIMULATOR_NAME }} steps: - uses: actions/checkout@v4 - name: Select the Xcode version run: | sudo xcode-select -s /Applications/Xcode_27.0.app xcodebuild -version - name: List available simulators run: xcrun simctl list devices available - name: Test on a simulator run: | xcodebuild test \ -scheme AppScheme \ -destination "platform=iOS Simulator,OS=27.0,name=$SIMULATOR_NAME" - name: Archive and export run: | xcodebuild archive \ -scheme AppScheme \ -destination 'generic/platform=iOS' \ -archivePath build/App.xcarchive xcodebuild -exportArchive \ -archivePath build/App.xcarchive \ -exportOptionsPlist ExportOptions.plist \ -exportPath build/ipa - uses: actions/upload-artifact@v4 with: name: ipa path: build/ipa/*.ipa ``` `needs: checks` keeps the macOS job from starting when a lint failure has already sunk the run, which is the point of the split: a broken workflow file costs $0.008 per minute to discover instead of $0.16. ### [The macOS image and Xcode](#the-macos-image-and-xcode)Copy Anchor Link The `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` runners ship Xcode 27.0 (build 27A266a) with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes, on top of the Xcode versions present in the upstream GitHub macOS 26 image, while GitHub's upstream macOS 27 runner image is in beta ([cloud runners documentation](/docs/ci/cloud-runners)). A dedicated macOS 27 image follows once that image is released. Preinstalled tooling on the macOS 14, 15, and 26 images matches the corresponding GitHub-hosted images, with the package lists linked from the [preinstalled software documentation](/docs/ci/preinstalled-software). ### [What the macOS minutes cost](#what-the-macos-minutes-cost)Copy Anchor Link `warp-macos-latest-arm64-6x` is 6 vCPU with 14GB at $0.08 per minute. GitHub lists the largest macOS ARM64 runner, 5 vCPU with 14GB, at $0.102 per minute ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing), checked on 2026-08-13). That is about 22 percent lower list price on a machine carrying one more vCPU and the same memory: $0.0133 per vCPU-minute against $0.0204. **Worked model.** A repository runs 400 pull request workflows a month. Run everything on macOS and the pipeline takes 14 minutes. Split it and the macOS leg drops to 9 minutes with 5 minutes of linting, dependency resolution, and note generation moved to `warp-ubuntu-latest-x64-4x` at $0.008 per minute. * All-macOS on `warp-macos-26-arm64-12x`: 400 x 14 x $0.16 = **$896.00 per month**. * All-macOS on `warp-macos-latest-arm64-6x`: 400 x 14 x $0.08 = **$448.00 per month**. * Split across both runners: 400 x 9 x $0.08 = $288.00 on macOS, plus 400 x 5 x $0.008 = $16.00 on Linux, for **$304.00 per month**. * The same 5,600 macOS minutes at GitHub's $0.102 list price would be $571.20 (GitHub pricing, checked on 2026-08-13). Model your own minute counts with the [macOS runner cost guide](/guides/github-actions-macos-runner-cost). Around the runner sits the rest of the product surface: snapshot runners, remote Docker builders, GitHub Actions observability, an MCP server, and the Action Debugger. The Docker builders matter here because the container half of an iOS pipeline has to live off macOS anyway. ## [Related Questions](#related-questions)Copy Anchor Link **Do I need to own a Mac to ship an iOS app from GitHub Actions?** No. You need a macOS machine for the archive step, and a macOS runner is that machine. Point the job at a label such as `warp-macos-26-arm64-12x` and the workflow rents the machine per minute, billed at $0.16 a minute for the 12 vCPU size. Every label and size is in the [macOS runner catalog](/runners/macos). **Can xcodebuild run on a Linux runner?** No. `xcodebuild`, `xcrun simctl`, `codesign`, and `notarytool` ship inside Xcode and the macOS command line tools, and Apple publishes [Xcode for macOS only](https://developer.apple.com/support/xcode/). A Linux job can run the parts of the pipeline that never touch those binaries. **Which iOS pipeline steps are worth moving off macOS?** The steps that only need Git, a shell, and a language runtime. Workflow and configuration linting, changelog and release-note generation, backend contract tests, and [App Store Connect API](https://developer.apple.com/documentation/appstoreconnectapi) metadata calls all run on a Linux runner at a lower per-minute rate. The [iOS on GitHub Actions solution page](/solutions/ios-github-actions) walks through the full pipeline shape. **What do macOS runner minutes cost compared with GitHub-hosted macOS runners?** `warp-macos-latest-arm64-6x` is 6 vCPU with 14GB at $0.08 per minute against $0.102 per minute for GitHub's largest macOS ARM64 runner at 5 vCPU with 14GB ([GitHub Actions pricing](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13): about 22 percent lower list price on a larger machine. Rates for both macOS sizes are on the [pricing page](/pricing). --- # Can GitHub Actions Build Multi Architecture Images? | WarpBuild URL: https://www.warpbuild.com/answers/can-github-actions-build-multi-arch-images Description: Yes. GitHub Actions builds multi architecture images natively across x64 and ARM64 runners, or in one job on a builder profile with both enabled. # Can GitHub Actions Build Multi Architecture Images? Yes. GitHub Actions builds multi architecture images natively across x64 and ARM64 runners, or in one job on a builder profile with both enabled. Last verified: 2026-08-13 Yes. GitHub Actions builds multi architecture images two ways: one job per architecture on a runner of that architecture, joined afterwards by a manifest list, or a single job that hands the build to a remote Docker builder profile with both architectures enabled. Either route ends at the same line in the workflow file, `platforms: linux/amd64,linux/arm64`, and the difference between them is where the arm64 layers are actually assembled ([Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link The capability was never the hard part. A `docker buildx build --platform linux/amd64,linux/arm64` runs on stock GitHub Actions today. What decides whether it finishes in minutes or hours is which machine executes the aarch64 half. **Route one: a builder profile with both architectures.** The workflow stays on one runner and the build goes to a dedicated builder virtual machine that keeps a persistent layer cache between runs. Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push both architectures uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ghcr.io/acme/api:${{ github.sha }} profile-name: "multi-arch-builder" ``` `Warpbuilds/build-push-action` is a drop in replacement for `docker/build-push-action`, so the `with:` block keeps the same keys and gains `profile-name`. The `api-key` input is only needed when the job runs somewhere other than a WarpBuild runner, and `docker/setup-buildx-action` can come out of the workflow entirely when it was there only to create a builder. **Route two: native runners plus a manifest list.** Each architecture builds on hardware that matches it, pushes by digest, and a small final job stitches the digests into one tag. No emulation is involved at any point. Copy Text ``` name: image-native on: push: branches: [main] jobs: build: strategy: fail-fast: false matrix: include: - platform: linux/amd64 runner: warp-ubuntu-latest-x64-8x suffix: amd64 - platform: linux/arm64 runner: warp-ubuntu-latest-arm64-8x suffix: arm64 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: build uses: docker/build-push-action@v6 with: context: . platforms: ${{ matrix.platform }} outputs: type=image,name=ghcr.io/acme/api,push-by-digest=true,name-canonical=true,push=true - name: Save the digest run: | mkdir -p /tmp/digests echo "${{ steps.build.outputs.digest }}" > /tmp/digests/${{ matrix.suffix }} - uses: actions/upload-artifact@v4 with: name: digest-${{ matrix.suffix }} path: /tmp/digests/* manifest: needs: build runs-on: warp-ubuntu-latest-arm64-2x steps: - uses: actions/download-artifact@v4 with: pattern: digest-* path: /tmp/digests merge-multiple: true - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Create the manifest list run: | docker buildx imagetools create \ -t ghcr.io/acme/api:${{ github.sha }} \ $(printf 'ghcr.io/acme/api@%s ' $(cat /tmp/digests/*)) ``` A `docker pull ghcr.io/acme/api:` against that tag resolves to the variant matching the client architecture, which is the whole point of the manifest list. The [multi platform Docker build guide](/guides/multi-platform-docker-builds) walks through this split job by job. Pricing on WarpBuild is purely usage based. ## [Detail](#detail)Copy Anchor Link ### [The builder profile has to carry both architectures](#the-builder-profile-has-to-carry-both-architectures)Copy Anchor Link This is the configuration step teams skip, because nothing in the workflow file hints at it. The profile is created in the WarpBuild UI and the architecture selection lives there, on the profile, rather than in YAML. Setting `platforms: linux/amd64,linux/arm64` on a profile that was created amd64 only does not add the second architecture at run time. The symptom is `exec format error` during the arm64 stage of the build ([Docker builders documentation](/docs/ci/docker-builders)). The message reads like a Dockerfile problem, usually pointing at the first `RUN` in the arm64 leg, and it means the same thing in both routes: a binary compiled for one architecture was handed to a machine that cannot execute it. On a builder profile, the fix is enabling both architectures on the profile. On a single runner with `docker/setup-qemu-action`, the same error means the binfmt handlers were never registered in that job. Two more profile level facts change how the build behaves once it works. The builder cache has a TTL of 10 days, so a profile that goes unused for longer than that resets and the next build starts cold. And the recommended minimum for concurrent work is roughly 8 vCPU and 16 GB of memory per build job on the profile, which is the number to size against when several pull requests build at once. ### [Each architecture is its own builder instance, and its own session](#each-architecture-is-its-own-builder-instance-and-its-own-session)Copy Anchor Link Remote Docker builders bill per session, measured from when the builder action starts until the job completes. Multiple concurrent jobs on the same profile share one session, billed from the first job's start to the last job's completion. Multi architecture breaks that sharing along the architecture line. Each architecture runs on a separate builder instance, so a multi architecture build opens one session per architecture and the two sessions bill independently until their post action steps finish ([Docker builders documentation](/docs/ci/docker-builders)). The sizing consequence follows from the same split. Every size below comes from the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13. | Builder size | Disk | Per minute | Architectures | | ---------------- | ----- | ---------- | ------------------- | | 16 vCPU, 32 GB | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU, 64 GB | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU, 128 GB | 200GB | $0.24 | amd64, arm64, multi | | 96 vCPU, 192 GB | 600GB | $0.36 | amd64 only | | 96 vCPU, 192 GB | 2TB | $0.52 | amd64 only | | 192 vCPU, 384 GB | 600GB | $0.72 | amd64 only | | 192 vCPU, 384 GB | 2TB | $0.88 | amd64 only | The bottom four rows are amd64 only, so arm64 and multi architecture profiles top out at 64 vCPU, 128 GB, 200GB disk. A team that wants a 96 vCPU or 192 vCPU builder has to give up the second architecture on that profile and run arm64 somewhere else. At the 8 vCPU per job guideline, the 64 vCPU ceiling still absorbs about eight concurrent build jobs per architecture. Full sizes and rates are on the [remote Docker builder catalog](/runners/docker-builders) and the [pricing page](/pricing). ### [What the two routes cost](#what-the-two-routes-cost)Copy Anchor Link Take a repository that builds one multi architecture image per merge, 400 builds a month, 6 minutes of build time each. **Builder profile route.** The 16 vCPU profile at $0.06 per minute, billed once per architecture: 400 x 6 x $0.06 x 2 sessions = **$288.00 per month**. The orchestrating runner bills separately, because the GitHub Actions runner and the Docker builder are two independent resources: `warp-ubuntu-latest-x64-4x` at $0.008 per minute for the same 6 minutes adds 400 x 6 x $0.008 = $19.20, for **$307.20 per month**. **Native runner route.** Two parallel jobs, `warp-ubuntu-latest-x64-8x` at $0.016 and `warp-ubuntu-latest-arm64-8x` at $0.012: 400 x 6 x $0.028 = $67.20\. The manifest job runs 1 minute on `warp-ubuntu-latest-arm64-2x` at $0.003 and adds $1.20, for **$68.40 per month**. The gap is real, and so is what the higher number buys. The native route rebuilds layers on a fresh ephemeral machine each run unless you wire up registry cache or the Actions cache yourself. Dependency heavy Dockerfiles favor the builder; thin Dockerfiles over a fast base image favor the native split. ### [The ARM64 leg against GitHub list prices](#the-arm64-leg-against-github-list-prices)Copy Anchor Link The native route's arm64 job runs on the standard Linux ARM64 catalog, so it prices against GitHub's ARM64 larger runners directly. WarpBuild rates come from the [cloud runners documentation](/docs/ci/cloud-runners). GitHub rates come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | WarpBuild label | Shape | WarpBuild | GitHub-hosted ARM64 equivalent | GitHub | Difference | | ---------------------------- | --------------- | --------- | -------------------------------------- | ------ | --------------------------- | | warp-ubuntu-latest-arm64-2x | 2 vCPU, 8 GB | $0.003 | ubuntu-24.04-arm, private repositories | $0.005 | 40 percent lower list price | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | 4-core Linux ARM64 larger runner | $0.008 | 25 percent lower list price | | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | $0.012 | 8-core Linux ARM64 larger runner | $0.014 | 14 percent lower list price | | warp-ubuntu-latest-arm64-16x | 16 vCPU, 64 GB | $0.024 | 16-core Linux ARM64 larger runner | $0.026 | 8 percent lower list price | | warp-ubuntu-latest-arm64-32x | 32 vCPU, 128 GB | $0.048 | 32-core Linux ARM64 larger runner | $0.05 | 4 percent lower list price | Applied to the worked model, the arm64 leg alone is 2,400 minutes a month: $28.80 on `warp-ubuntu-latest-arm64-8x` against $33.60 on the 8-core Linux ARM64 larger runner, a difference of $4.80 a month and $57.60 over twelve months. ### [Choosing between the routes](#choosing-between-the-routes)Copy Anchor Link Three questions settle it in practice. How much of the build is dependency installation and compilation? The more layers that survive between runs, the more the persistent cache on a builder profile is worth, and the closer the two monthly numbers get once build minutes drop. Does the arm64 image need more than 64 vCPU? If yes, the multi architecture profile cannot serve it and the native split is the only route that scales past that ceiling. How many pull requests build at once? Concurrent jobs on one profile share a session per architecture, so heavy parallelism spreads the builder cost across more builds. Concurrent jobs on native runners each bill their own minutes with no sharing. Teams that want the emulation background before deciding will find it in the [QEMU compared with native ARM64 builds guide](/guides/qemu-vs-native-arm64-builds), and the label by label rates for the native route are in the [Linux ARM64 runner catalog](/runners/linux-arm64). ## [Related Questions](#related-questions)Copy Anchor Link **Can one GitHub Actions job build both amd64 and arm64 images?** Yes, on a remote Docker builder profile that has both architectures enabled. Set `platforms: linux/amd64,linux/arm64` on `Warpbuilds/build-push-action` and point `profile-name` at that profile. The alternative is one job per architecture on a matching runner, joined by a manifest list in a final job, which the [multi platform Docker build guide](/guides/multi-platform-docker-builds) covers end to end. **Why does my multi architecture build fail with exec format error?** The builder profile does not have both architectures selected. Enable amd64 and arm64 on the profile in the WarpBuild UI and rerun ([Docker builders documentation](/docs/ci/docker-builders)). The same error appears on a single x64 runner when a binfmt handler for aarch64 was never registered in that job. **How is a multi architecture Docker builder build billed?** Each architecture runs on a separate builder instance, so one multi architecture build opens one session per architecture and the two sessions bill independently. A 6 minute build on the 16 vCPU profile at $0.06 per minute therefore costs $0.72 rather than $0.36\. Sizes and rates are in the [remote Docker builder catalog](/runners/docker-builders). **What is the largest multi architecture builder profile?** 64 vCPU, 128 GB RAM, 200GB disk at $0.24 per minute. The 96 vCPU and 192 vCPU sizes are amd64 only, so arm64 and multi architecture profiles cap at 64 vCPU. Past that ceiling, run the arm64 half on the [Linux ARM64 runner catalog](/runners/linux-arm64) instead. --- # Can GitHub Actions Run an Android Emulator? | WarpBuild URL: https://www.warpbuild.com/answers/can-github-actions-run-android-emulator Description: Yes. GitHub Actions runs a hardware-accelerated Android emulator on Linux x86-64 runners that expose /dev/kvm, once the KVM udev permission step is added. # Can GitHub Actions Run an Android Emulator? Yes. GitHub Actions runs a hardware-accelerated Android emulator on Linux x86-64 runners that expose /dev/kvm, once the KVM udev permission step is added. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Yes. GitHub Actions runs an Android emulator on Linux x86-64 runners, and the emulator runs with hardware acceleration whenever the runner exposes `/dev/kvm` to the job. On WarpBuild cloud runners you enable that by appending `;nested-virtualization.enabled=true` to the `runs-on` label, then adding the documented KVM udev permission step before the emulator action ([nested virtualization documentation](/docs/ci/features/nested-virtualization), checked on 2026-08-13). The label form is exact. The runner size comes first, then a semicolon, then the flag: Copy Text ``` warp-ubuntu-latest-x64-4x;nested-virtualization.enabled=true ``` A complete instrumentation job looks like this: Copy Text ``` name: android-instrumentation on: pull_request: jobs: instrumentation-tests: runs-on: warp-ubuntu-latest-x64-4x;nested-virtualization.enabled=true steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Confirm KVM is visible to the job run: | ls -l /dev/kvm grep -c -E 'vmx|svm' /proc/cpuinfo - uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 arch: x86_64 target: google_apis disable-animations: true emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim script: ./gradlew connectedDebugAndroidTest ``` Two things in that file carry the whole answer. The `runs-on` line asks for a runner provisioned on hardware that passes the virtualization extensions through to the guest, which is what makes `/dev/kvm` appear. The `Enable KVM group perms` step changes the device permissions so the `runner` user can open it. Skipping the permission step is the most common failure, and it fails quietly. The device is present, the emulator still starts, and the suite still reports results, so the job looks healthy while every guest instruction is being emulated in software. Pricing on WarpBuild is purely usage based. ## [Detail](#detail)Copy Anchor Link ### [What the permission step actually fixes](#what-the-permission-step-actually-fixes)Copy Anchor Link Once nested virtualization is on, `/dev/kvm` exists on the runner with the default device permissions `crw-rw---- root:kvm`. The `runner` user that executes workflow steps is outside that group, so opening the device fails ([nested virtualization documentation](/docs/ci/features/nested-virtualization)). The udev rule in the step above sets mode `0666` on the `kvm` kernel device and reloads the rules so the change applies to the live device rather than to the next boot. The step is a requirement on every GitHub-compatible runner, including GitHub-hosted larger runners, so it is portable across whichever runner your matrix lands on. The failure signature is specific enough to grep for in a job log: 1. `ProbeKVM: This user doesn't have permissions to use KVM (/dev/kvm).` 2. `Disabling Linux hardware acceleration.` 3. The emulator command line contains `-accel off`. Those three lines mean the emulator has fallen back to full software emulation of the guest ARM or x86 instruction stream. The same signature shows up in the [common issues documentation](/docs/ci/common-issues) as the first thing to check when an Android job that used to finish in minutes starts running long. Add the permission step, rerun, and the three lines disappear. A useful hardening move is the `ls -l /dev/kvm` check in the example above: if the device is missing entirely, the label was ignored, which points at the runner class rather than at permissions. ### [Where nested virtualization is available](#where-nested-virtualization-is-available)Copy Anchor Link The support boundary matters before you write the label, because an unsupported runner class does not raise an error. The `nested-virtualization.enabled=true` label is silently ignored on runner classes that cannot provide it, and the job runs normally without hardware acceleration ([nested virtualization documentation](/docs/ci/features/nested-virtualization), checked on 2026-08-13). | Runner class | Nested virtualization | How it is enabled | | ------------------------- | --------------------- | -------------------------------------------------------- | | Cloud: Linux x86-64 | Yes | ;nested-virtualization.enabled=true on the runs-on label | | Cloud: Linux ARM64 | No | Label ignored | | Cloud: Windows | No | Label ignored | | Cloud: macOS | No | Label ignored | | BYOC: AWS, Linux x86-64 | Yes | Automatic when all instance types support it | | BYOC: GCP, Linux x86-64 | Yes | Automatic when all instance types support it | | BYOC: Azure, Linux x86-64 | Yes | Automatic on supported VM sizes | | BYOC: ARM64 or Windows | No | Not available | WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and emulator jobs use the Linux x64 line while the rest of a matrix stays wherever it already runs. A repository that builds an iOS target on macOS and an Android target on Linux keeps both legs in one workflow file and puts the flag on the Android leg only. On BYOC runner sets the flag is unnecessary. Nested virtualization enables itself when every instance type selected for that runner set supports it, and it stays off when the selection mixes supporting and non-supporting types, so that job behavior stays consistent across the set. If you widened the instance list for capacity reasons and emulator jobs slowed down afterwards, that condition is the first place to look. ### [The Linux x86-64 catalog for emulator jobs](#the-linux-x86-64-catalog-for-emulator-jobs)Copy Anchor Link Every row is from the [cloud runners documentation](/docs/ci/cloud-runners) and the WarpBuild rate table, checked on 2026-08-13\. The `latest` labels track Ubuntu 24.04; the same five sizes exist as `warp-ubuntu-2604-x64-*` on Ubuntu 26.04 at the same rates. | Runner label | vCPU | RAM | Storage | Per minute | Alias | | -------------------------- | ---- | ------ | --------- | ---------- | ------------------------ | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | warp-ubuntu-2404-x64-2x | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | warp-ubuntu-2404-x64-4x | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | warp-ubuntu-2404-x64-8x | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | warp-ubuntu-2404-x64-16x | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | warp-ubuntu-2404-x64-32x | The flag appends to any of them, so `warp-ubuntu-2604-x64-8x;nested-virtualization.enabled=true` is valid in the same way the 4x form is. Sizing for an emulator job is different from sizing a compile job, because three workloads share the machine: the emulator process, the Gradle daemon, and the app under test. A 2 vCPU runner leaves the emulator competing with Gradle for cores during the test run, which shows up as AVD boot timeouts rather than as slow compilation. The 4 vCPU size is the practical floor, and 8 vCPU is the step to take when a job boots more than one AVD or when the instrumentation suite is large. The Recommendations view in [runner observability](/docs/ci/features/observability) reports right-sizing suggestions per job from real utilization, which beats guessing from job duration alone. The [runner size answer page](/answers/how-do-i-choose-a-github-actions-runner-size) covers the general method. ### [What an emulator suite costs](#what-an-emulator-suite-costs)Copy Anchor Link WarpBuild rates below come from the [cloud runners documentation](/docs/ci/cloud-runners). GitHub rates come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub-hosted runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13. | WarpBuild label | Shape | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | List price difference | | -------------------------- | --------------- | -------------------- | ------------------------------------- | ----------------- | --------------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | ubuntu-latest on private repositories | $0.006 | 33 percent lower list price | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent lower list price | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent lower list price | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent lower list price | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064 | 32-core Linux larger runner | $0.082 | 22 percent lower list price | The 2 vCPU row compares GitHub's private-repository rate, which is the rate paying teams run against. GitHub gives public repositories a larger free shape. **Worked model: one Android repository.** A team merges enough pull requests to run 400 instrumentation jobs a month. Each job takes 14 minutes: roughly 3 minutes of Gradle work, 2 minutes of AVD boot, and 9 minutes of tests. That is 5,600 minutes on a 4 vCPU runner. * `warp-ubuntu-latest-x64-4x` at $0.008 per minute: 5,600 x $0.008 = **$44.80 per month**. * The GitHub 4-core Linux larger runner at $0.012 per minute: 5,600 x $0.012 = **$67.20 per month**. * Difference: $22.40 per month, $268.80 over twelve months, which is 33 percent lower list price (GitHub pricing, checked 2026-08-13). * The $10 in signup credits covers the first $10 of that bill, so month one lands at $34.80 out of pocket at this volume. Minutes are the other half of the bill, and this is where the permission step pays for itself twice. Software emulation stretches the AVD boot and the test phase of every one of those 400 jobs, so a missing four-line udev step inflates both the wall clock and the invoice on the same jobs. ### [Keeping emulator jobs stable once acceleration works](#keeping-emulator-jobs-stable-once-acceleration-works)Copy Anchor Link Hardware acceleration removes the largest source of Android job slowness and leaves a shorter list of flake sources behind. Four changes cover most of what remains. **Cache the AVD.** Creating the AVD and booting it cold on every run is the longest fixed cost in the job. Cache `~/.android/avd` and `~/.android/adb*` keyed by API level, then run the emulator action twice: once with `emulator-options: -no-snapshot-load` to generate and save the snapshot on a cache miss, and once for the test run. Copy Text ``` - uses: actions/cache@v4 id: avd-cache with: path: | ~/.android/avd/* ~/.android/adb* key: avd-34-x86_64 - name: Create AVD snapshot if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 arch: x86_64 target: google_apis force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim script: echo "snapshot generated" ``` **Turn animations off.** `disable-animations: true` stops Espresso from racing window transitions, which is the classic source of a test that passes locally and fails on a runner. **Pin the API level and the image target.** `api-level`, `arch`, and `target` together determine which system image is downloaded. Leaving any of them implicit lets the image drift under you between runs. **Split the matrix and keep both legs reporting.** Run one job per API level with `fail-fast: false` so a failure on API 30 still lets you see the API 34 result in the same run. Every leg carries the same label suffix, since the flag applies per job. If a job still fails intermittently after all of that, the failure is usually in the test code or in the app rather than in the runner. The [flaky jobs guide](/guides/flaky-github-actions-jobs) walks through isolating the two. Job-level observability data is the fastest way to tell a slow emulator boot apart from a slow test suite. ## [Related Questions](#related-questions)Copy Anchor Link **Why do my Android emulator tests still flake after KVM is enabled?** Check the emulator logs for `ProbeKVM` permission errors and for `-accel off` in the emulator command line, because those two signals mean the job fell back to software emulation. Once hardware acceleration is confirmed, the remaining flake sources are AVD boot timeouts, animations left enabled, and tests that race the package installer. The [flaky jobs guide](/guides/flaky-github-actions-jobs) covers how to separate a runner problem from a test problem. **Which runner size should I use for Android instrumentation tests?** Start at `warp-ubuntu-latest-x64-4x` with 4 vCPU and 16 GB at $0.008 per minute, because the emulator, the Gradle daemon, and the app under test share the machine. Move to `warp-ubuntu-latest-x64-8x` with 8 vCPU and 32 GB at $0.016 per minute when you boot more than one AVD per job or run a large instrumentation suite. Price both against your own job minutes on the [pricing page](/pricing). **Does nested virtualization work on BYOC runners?** Yes on AWS, GCP, and Azure Linux x86-64 runner sets, and it enables itself with no label when every instance type selected for the runner set supports it. Mixing supporting and non-supporting instance types leaves it off for the whole runner set, so pin the instance list to types that support it. The [nested virtualization documentation](/docs/ci/features/nested-virtualization) lists the per-cloud behavior. **Can I run the Android emulator on ARM64, Windows, or macOS runners?** No. Nested virtualization is a Linux x86-64 feature on cloud runners, and the `nested-virtualization.enabled=true` label is silently ignored on ARM64, Windows, and macOS. Route emulator jobs to an x86-64 label and keep the rest of the matrix wherever it already runs. Set up the job with the [Android emulator testing solution](/solutions/android-emulator-tests-github-actions), fit it into a full pipeline with the [Android on GitHub Actions solution](/solutions/android-github-actions), and price the runner minutes on the [pricing page](/pricing). --- # Can GitHub Actions Run on ARM64? | WarpBuild URL: https://www.warpbuild.com/answers/can-github-actions-run-arm64 Description: Yes. GitHub Actions runs natively on ARM64 runners. Point runs-on at an ARM64 label such as warp-ubuntu-latest-arm64-4x, billed at $0.006 per minute. # Can GitHub Actions Run on ARM64? Yes. GitHub Actions runs natively on ARM64 runners. Point runs-on at an ARM64 label such as warp-ubuntu-latest-arm64-4x, billed at $0.006 per minute. Last verified: 2026-08-13 Yes. GitHub Actions runs jobs on ARM64 hardware natively, and the switch is a one line change to `runs-on`: point the job at an ARM64 runner label such as `warp-ubuntu-latest-arm64-4x` and the steps execute on an aarch64 machine with no translation layer in between. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the Linux ARM64 line starts at $0.003 per minute for the 2 vCPU size ([WarpBuild cloud runners](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link There are three ways to get an ARM64 machine under a GitHub Actions job, and all three are native execution. **GitHub-hosted ARM64 runners.** GitHub publishes ARM64 labels such as `ubuntu-24.04-arm` and bills them per minute on private repositories. The rates live in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and on the [GitHub pricing page](https://github.com/pricing). **Self-hosted ARM64 machines.** The runner agent ships a `linux-arm64` release on [github.com/actions/runner](https://github.com/actions/runner), so any aarch64 box you own can register and advertise labels. You own the image, the scaling, and the patching. The [GitHub Actions runner glossary entry](/glossary/github-actions-runner) covers how registration and label routing work. **Managed ARM64 runners.** A provider registers ephemeral aarch64 virtual machines against your organization and you select them by label. WarpBuild runs this shape: the [Linux ARM64 runner catalog](/runners/linux-arm64) lists ten labels across Ubuntu 24.04 and Ubuntu 26.04, from 2 vCPU to 32 vCPU. The workflow change is the label and nothing else: Copy Text ``` name: build-and-test on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-arm64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.24" cache: true - name: Confirm the architecture run: | uname -m go env GOARCH - name: Build run: go build -o bin/api ./cmd/api - name: Test run: go test -race ./... - uses: actions/upload-artifact@v4 with: name: api-linux-arm64 path: bin/api ``` `uname -m` prints `aarch64` and `go env GOARCH` prints `arm64` on that runner. The rest of the workflow file is unchanged from the x86-64 version. Emulation is the fallback for teams that cannot get an aarch64 machine. QEMU under `docker/setup-qemu-action` lets an x86-64 host produce an arm64 image by translating guest instructions in user mode. That path works for producing artifacts and it is a poor place to run a test suite, because every compile step, every dependency install, and every assertion pays translation overhead on top of the work itself. Pricing on WarpBuild is purely usage based. ## [Detail](#detail)Copy Anchor Link ### [Native ARM64 and emulated ARM64 are different execution models](#native-arm64-and-emulated-arm64-are-different-execution-models)Copy Anchor Link A native ARM64 job runs an aarch64 kernel and an aarch64 userspace. The compiler emits aarch64 instructions directly, the linker resolves aarch64 objects, and the test binary you assert against is the binary you ship to ARM64 production hosts. Architecture-specific behavior shows up where it belongs: CPU feature detection through `/proc/cpuinfo`, Neon and SVE code paths, memory ordering differences under a weaker memory model, and page size assumptions. An emulated job runs an x86-64 kernel with binfmt handlers registered so aarch64 binaries get routed through QEMU user-mode emulation. Buildx uses this to produce `linux/arm64` layers on an x86-64 builder. Three consequences matter when you plan a workflow: 1. Every instruction in the guest binary is translated at run time, so the wall clock cost lands on compile-heavy and test-heavy steps. 2. Runtimes that generate machine code at run time behave differently under translation. JIT-heavy language runtimes, Go builds that link cgo, and Rust builds with procedural macros that pull native dependencies are the usual sources of surprise. 3. Host-level signals are wrong. Core counts, CPU flags, and hardware capability probes report the host, so any code that branches on them takes an untested path. Cross-compilation is the third model and it sits between the two. You build aarch64 artifacts on an x86-64 host with a cross toolchain, which keeps the build fast, then run the test suite on a native ARM64 runner so assertions execute on real hardware. The [multi-platform Docker build guide](/guides/multi-platform-docker-builds) walks through the split. ### [The WarpBuild Linux ARM64 catalog](#the-warpbuild-linux-arm64-catalog)Copy Anchor Link Ten labels, two Ubuntu releases, five sizes each. Every row comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Runner label | OS | vCPU | RAM | Storage | Per minute | Alias | | ---------------------------- | ------------ | ---- | ------ | --------- | ---------- | -------------------------- | | warp-ubuntu-latest-arm64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.003 | warp-ubuntu-2404-arm64-2x | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.006 | warp-ubuntu-2404-arm64-4x | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.012 | warp-ubuntu-2404-arm64-8x | | warp-ubuntu-latest-arm64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.024 | warp-ubuntu-2404-arm64-16x | | warp-ubuntu-latest-arm64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.048 | warp-ubuntu-2404-arm64-32x | | warp-ubuntu-2604-arm64-2x | Ubuntu 26.04 | 2 | 8 GB | 150GB SSD | $0.003 | none | | warp-ubuntu-2604-arm64-4x | Ubuntu 26.04 | 4 | 16 GB | 150GB SSD | $0.006 | none | | warp-ubuntu-2604-arm64-8x | Ubuntu 26.04 | 8 | 32 GB | 150GB SSD | $0.012 | none | | warp-ubuntu-2604-arm64-16x | Ubuntu 26.04 | 16 | 64 GB | 150GB SSD | $0.024 | none | | warp-ubuntu-2604-arm64-32x | Ubuntu 26.04 | 32 | 128 GB | 150GB SSD | $0.048 | none | Storage is ephemeral and is deleted when the runner terminates, so anything you want to keep goes to an artifact, a cache, or a registry. The `latest` labels track Ubuntu 24.04 today, so pin `warp-ubuntu-2404-arm64-4x` or `warp-ubuntu-2604-arm64-4x` when you want the release to stay fixed across a rollout. ### [The ARM64 image caveats worth knowing before you switch](#the-arm64-image-caveats-worth-knowing-before-you-switch)Copy Anchor Link Four documented differences account for most of the failures teams hit on the first ARM64 run. **The work directory moves.** The arm64 images for Ubuntu 24.04 set the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/` for the same instance ([cloud runners documentation](/docs/ci/cloud-runners)). Any step with a hardcoded `/home/runner/work/...` path breaks. Use `${{ github.workspace }}` in YAML and `$GITHUB_WORKSPACE` in shell steps, and check Docker `-v` mounts, coverage upload paths, and cache path globs for literals. **Ubuntu 22.04 on ARM64 is gone.** The arm64 images for ubuntu-22.04 were deprecated on March 31, 2025 ([cloud runners documentation](/docs/ci/cloud-runners)). Those images were also thinner than their x86-64 siblings: they were based on the upstream `ubuntu:22.04` image, the default user was `root` instead of `runner`, and tooling had to be installed with `apt` inside the workflow ([preinstalled software documentation](/docs/ci/preinstalled-software)). Ubuntu 24.04 and 26.04 ARM64 removed both of those gaps. **Tooling parity starts at 24.04.** The Ubuntu 24.04 ARM64 runners are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the package list is published at [actions/partner-runner-images](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md). Ubuntu 26.04 ARM64 tracks [actions/runner-images](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2604-Arm64-Readme.md). Read the list before you assume a language version is present; ARM64 images and x86-64 images do not always ship the same set. **Nested virtualization stays on x86-64.** Workloads that need `/dev/kvm`, including Android emulator jobs, are not supported on ARM64 runners ([cloud runners documentation](/docs/ci/cloud-runners)). Those jobs go to an x86-64 runner with the `nested-virtualization.enabled=true` label. ### [Running x64 and ARM64 side by side](#running-x64-and-arm64-side-by-side)Copy Anchor Link Most teams reach ARM64 through a matrix, because the point is proving the same test suite passes on both architectures before a release ships to aarch64 hosts. Copy Text ``` name: matrix-test on: pull_request: jobs: test: strategy: fail-fast: false matrix: include: - arch: amd64 runner: warp-ubuntu-latest-x64-4x - arch: arm64 runner: warp-ubuntu-latest-arm64-4x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test - name: Package for ${{ matrix.arch }} run: npm run build -- --target=${{ matrix.arch }} - uses: actions/upload-artifact@v4 with: name: dist-${{ matrix.arch }} path: dist/ ``` `fail-fast: false` keeps the x64 leg running when the ARM64 leg fails, which is what you want while you are working through architecture-specific breakage. Cache is enabled by default on Linux runners, including the ARM64 line, and cache keys are scoped per architecture by the setup actions, so the two legs do not fight over one entry. Snapshot runners list Linux ARM64 among their supported platforms ([snapshot runners documentation](https://www.warpbuild.com/docs/ci/features/snapshot-runners)). ### [Multi-architecture Docker images](#multi-architecture-docker-images)Copy Anchor Link Two shapes work, and they price differently. The native-runner shape runs one build job per architecture on a matching runner, pushes each image by digest, then merges them into one manifest list in a small final job. Every layer is built on real hardware for its target. The remote builder shape keeps the workflow on one runner and sends the build to a dedicated builder virtual machine with a persistent layer cache. Builder profiles carry amd64, arm64, and multi-architecture options, and a multi-architecture build runs each architecture on a separate builder instance, producing one session per architecture ([Docker builders documentation](https://www.warpbuild.com/docs/ci/docker-builders)). Enable both architectures on the profile before you set `platforms: linux/amd64,linux/arm64`. ### [What ARM64 costs on GitHub Actions](#what-arm64-costs-on-github-actions)Copy Anchor Link Every WarpBuild rate below comes from the [cloud runners documentation](/docs/ci/cloud-runners). Every GitHub rate comes from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing), checked on 2026-08-13. | WarpBuild ARM64 label | Shape | WarpBuild per minute | GitHub-hosted ARM64 equivalent | GitHub per minute | List price difference | | ---------------------------- | --------------- | -------------------- | --------------------------------- | ----------------- | --------------------------- | | warp-ubuntu-latest-arm64-2x | 2 vCPU, 8 GB | $0.003 | ubuntu-24.04-arm | $0.005 | 40 percent lower list price | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | 4-core Linux ARM64 larger runner | $0.008 | 25 percent lower list price | | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | $0.012 | 8-core Linux ARM64 larger runner | $0.014 | 14 percent lower list price | | warp-ubuntu-latest-arm64-16x | 16 vCPU, 64 GB | $0.024 | 16-core Linux ARM64 larger runner | $0.026 | 8 percent lower list price | | warp-ubuntu-latest-arm64-32x | 32 vCPU, 128 GB | $0.048 | 32-core Linux ARM64 larger runner | $0.05 | 4 percent lower list price | The 2 vCPU row compares GitHub's private-repository rate, which is the rate paying teams run against. **Worked model: one service repository.** A repository merges enough pull requests to queue 2,400 jobs a month, each running 6 minutes, on a 4 vCPU ARM64 runner. That is 14,400 minutes. * `warp-ubuntu-latest-arm64-4x` at $0.006 per minute: 14,400 x $0.006 = **$86.40 per month**. * GitHub 4-core Linux ARM64 larger runner at $0.008 per minute: 14,400 x $0.008 = **$115.20 per month**. * Difference: $28.80 per month, $345.60 over twelve months, which is 25 percent lower list price (GitHub pricing, checked 2026-08-13). * The same 14,400 minutes on `warp-ubuntu-latest-x64-4x` at $0.008 per minute is $115.20, so the architecture change is worth $28.80 a month on identical vCPU and RAM. List price is one input to a fleet bill and build time is the other. Use your measured duration with the per-size rates on the [pricing page](/pricing) rather than assuming a fixed saving from the architecture alone. **Worked model: multi-architecture image builds.** A repository builds one multi-architecture image per pull request, 300 builds a month, 5 minutes of build time each. * Remote Docker builder route on the 16 vCPU, 32 GB, 100GB disk profile at $0.06 per minute per session, billed once per architecture: 300 x 5 x $0.06 x 2 sessions = **$180.00 per month**, with a persistent layer cache shared across the jobs on that profile. * Native runner route with two parallel jobs, one on `warp-ubuntu-latest-x64-4x` at $0.008 and one on `warp-ubuntu-latest-arm64-4x` at $0.006: 300 x 5 x ($0.008 + $0.006) = $21.00, plus a 1 minute manifest merge job on `warp-ubuntu-latest-arm64-2x` at $0.003, which adds $0.90, for **$21.90 per month**. The two routes buy different things. The builder profile carries a persistent layer cache across every job that uses it, which is what dependency-heavy Dockerfiles want. The native runner route is cheaper per build and rebuilds layers unless you wire up registry cache or the Actions cache yourself. ### [Where ARM64 stops](#where-arm64-stops)Copy Anchor Link ARM64 coverage in the catalog is Linux and macOS. The Windows catalog is x86-64 only, from `warp-windows-latest-x64-4x` through `warp-windows-latest-x64-32x`, so a Windows job stays on an x86-64 label ([cloud runners documentation](/docs/ci/cloud-runners)). The macOS runners are already ARM64, so a macOS job needs no architecture decision. Inside Linux ARM64, the one hard limit is nested virtualization, which stays on the x86-64 line. Everything else in a normal workflow, including checkout, the language setup actions, caching, artifacts, service containers, and self-hosted registry pulls, behaves the same as it does on x86-64 once the work directory difference is handled. ## [Related Questions](#related-questions)Copy Anchor Link **Do I have to change my workflow to run a job on ARM64?** Only the runs-on line. Swap the label for an ARM64 label such as `warp-ubuntu-latest-arm64-4x`. Steps break only where they download an architecture-specific binary or pull a single-arch container image, so those URLs and tags need an aarch64 variant. The [ARM64 migration guide](/guides/arm64-github-actions-runners) lists the step types that need attention. **Does WarpBuild offer ARM64 runners for Windows?** No. The Windows catalog is x86-64 only. ARM64 runners cover Linux and macOS, so a Windows job stays on a `warp-windows` label. The [Linux ARM64 runner catalog](/runners/linux-arm64) covers the aarch64 side. **Can I run Android emulator tests on ARM64 runners?** No. Nested virtualization and `/dev/kvm` access are not available on ARM64 runners. Send emulator jobs to an x86-64 runner with the `nested-virtualization.enabled=true` label, documented in the [cloud runners reference](/docs/ci/cloud-runners). **Is Ubuntu 22.04 available on ARM64 runners?** No. The arm64 images for ubuntu-22.04 were deprecated on March 31, 2025\. Use the Ubuntu 24.04 or Ubuntu 26.04 ARM64 labels, which track GitHub's ARM64 images, and check the package list linked from the [preinstalled software documentation](/docs/ci/preinstalled-software) before you pin a language version. Start on ARM64 with the labels in the [Linux ARM64 runner catalog](/runners/linux-arm64), price the change against your own job minutes on the [pricing page](/pricing), and build images for both architectures with the [multi-platform Docker build guide](/guides/multi-platform-docker-builds). --- # Can GitHub Actions Run Windows Builds? | WarpBuild URL: https://www.warpbuild.com/answers/can-github-actions-run-windows-builds Description: Yes. GitHub Actions runs Windows builds on x86-64 Windows runners. Point runs-on at a label such as warp-windows-latest-x64-8x, billed at $0.032 per minute. # Can GitHub Actions Run Windows Builds? Yes. GitHub Actions runs Windows builds on x86-64 Windows runners. Point runs-on at a label such as warp-windows-latest-x64-8x, billed at $0.032 per minute. Last verified: 2026-08-13 Yes. GitHub Actions runs Windows builds on x86-64 Windows runners, and moving a job onto one is a single line change to `runs-on`: point it at a Windows label such as `warp-windows-latest-x64-8x` and the steps execute on a Windows Server machine with MSBuild, Visual Studio, and the rest of the standard Windows toolchain already installed. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the Windows line runs from 4 vCPU at $0.016 per minute to 32 vCPU at $0.128 per minute ([WarpBuild cloud runners](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link Windows is a supported GitHub Actions platform, and there are three ways to get a Windows machine under a job. **GitHub-hosted Windows runners.** GitHub publishes `windows-latest` and dated labels such as `windows-2022`, plus a set of larger Windows runners at 4, 8, 16, and 32 cores. Private-repository minutes bill per minute, and the rates live in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). **Self-hosted Windows machines.** The runner agent ships a `win-x64` release on [github.com/actions/runner](https://github.com/actions/runner), so any Windows box you own can register against a repository or organization and advertise labels. You own the image, the patching, the Visual Studio licensing, and the scaling. **Managed Windows runners.** A provider registers ephemeral Windows virtual machines against your organization and you select them by label. WarpBuild runs this shape: twelve Windows labels across Windows Server 2022, Windows Server 2025, and a Windows Server 2025 image carrying Visual Studio 2026, listed in the [Windows runner catalog](/runners/windows). The workflow file barely moves. The Windows x86-64 runner images carry the same tooling as GitHub-hosted runners ([cloud runners documentation](/docs/ci/cloud-runners)), so the label is the change: Copy Text ``` name: windows-build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-windows-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: "9.0.x" - name: Cache NuGet packages uses: actions/cache@v4 with: path: ~\.nuget\packages key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} restore-keys: | nuget-${{ runner.os }}- - name: Restore run: dotnet restore --locked-mode - name: Build run: dotnet build --configuration Release --no-restore - name: Test run: dotnet test --configuration Release --no-build --logger trx - uses: actions/upload-artifact@v4 with: name: test-results path: "**/TestResults/*.trx" ``` The default shell on a Windows runner is PowerShell, `runner.os` evaluates to `Windows`, and the checkout, setup, cache, and artifact actions behave the way they do on Linux. ## [Detail](#detail)Copy Anchor Link ### [The Windows catalog](#the-windows-catalog)Copy Anchor Link Twelve labels, three image families, four machine shapes. Every row below comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Runner label | Image | vCPU | Memory | Storage | Per minute | Alias | | -------------------------------- | ------------------------------------------- | ---- | ------ | --------- | ---------- | ------------------------- | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16GB | 256GB SSD | $0.016 | warp-windows-2022-x64-4x | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32GB | 256GB SSD | $0.032 | warp-windows-2022-x64-8x | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64GB | 256GB SSD | $0.064 | warp-windows-2022-x64-16x | | warp-windows-latest-x64-32x | Windows Server 2022 | 32 | 128GB | 256GB SSD | $0.128 | warp-windows-2022-x64-32x | | warp-windows-2025-x64-4x | Windows Server 2025 | 4 | 16GB | 256GB SSD | $0.016 | none | | warp-windows-2025-x64-8x | Windows Server 2025 | 8 | 32GB | 256GB SSD | $0.032 | none | | warp-windows-2025-x64-16x | Windows Server 2025 | 16 | 64GB | 256GB SSD | $0.064 | none | | warp-windows-2025-x64-32x | Windows Server 2025 | 32 | 128GB | 256GB SSD | $0.128 | none | | warp-windows-2025-vs2026-x64-4x | Windows Server 2025 with Visual Studio 2026 | 4 | 16GB | 256GB SSD | $0.016 | none | | warp-windows-2025-vs2026-x64-8x | Windows Server 2025 with Visual Studio 2026 | 8 | 32GB | 256GB SSD | $0.032 | none | | warp-windows-2025-vs2026-x64-16x | Windows Server 2025 with Visual Studio 2026 | 16 | 64GB | 256GB SSD | $0.064 | none | | warp-windows-2025-vs2026-x64-32x | Windows Server 2025 with Visual Studio 2026 | 32 | 128GB | 256GB SSD | $0.128 | none | Three properties hold across the whole Windows range. Memory scales at 4GB per vCPU. Storage is fixed at 256GB of SSD on every size rather than growing with the shape, which matters for solutions that check out a large repository and then fill `obj` and `bin` directories per project. Storage is also ephemeral and is deleted when the runner terminates, so anything worth keeping goes to an artifact, a cache, or a package feed. The `vs2026` labels are transitional. They carry Visual Studio 2026 while the other Windows Server 2025 labels carry Visual Studio 2022, and Visual Studio 2026 may become the default for the Windows Server 2025 runners in a later update ([cloud runners documentation](/docs/ci/cloud-runners)). Pin the explicit label while a migration is in progress, and read the [Visual Studio images page](/runners/windows/visual-studio-images) before moving a solution that depends on a specific toolset version. A matrix is the honest way to test an image move, because the compiler toolset changes underneath the same source tree: Copy Text ``` name: windows-image-matrix on: pull_request: jobs: build: strategy: fail-fast: false matrix: image: - warp-windows-latest-x64-8x - warp-windows-2025-x64-8x - warp-windows-2025-vs2026-x64-8x runs-on: ${{ matrix.image }} steps: - uses: actions/checkout@v4 - name: Record the image and toolset run: | [System.Environment]::OSVersion.Version & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property catalog_productDisplayVersion - name: Build the solution run: msbuild MySolution.sln /p:Configuration=Release /m - name: Run the test projects run: dotnet test --configuration Release --no-build ``` `fail-fast: false` keeps the passing legs running while you work through breakage on one image, and the `vswhere` step writes the toolset version into the log so a later regression can be traced to an image change rather than a code change. ### [What a Windows minute costs](#what-a-windows-minute-costs)Copy Anchor Link Every WarpBuild rate below comes from the [cloud runners documentation](/docs/ci/cloud-runners). Every GitHub rate comes from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the shapes from the [GitHub-hosted runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13. | WarpBuild Windows label | Shape | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | List price difference | | --------------------------- | --------------- | -------------------- | ----------------------------- | ----------------- | --------------------------- | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016 | 4-core Windows larger runner | $0.022 | 27 percent lower list price | | warp-windows-latest-x64-8x | 8 vCPU, 32 GB | $0.032 | 8-core Windows larger runner | $0.042 | 24 percent lower list price | | warp-windows-latest-x64-16x | 16 vCPU, 64 GB | $0.064 | 16-core Windows larger runner | $0.082 | 22 percent lower list price | | warp-windows-latest-x64-32x | 32 vCPU, 128 GB | $0.128 | 32-core Windows larger runner | $0.162 | 21 percent lower list price | **Worked model: one .NET service repository.** A team merges enough pull requests to queue 1,500 Windows jobs a month, each running 9 minutes. That is 13,500 Windows minutes. | Shape | WarpBuild per month | GitHub-hosted per month | Monthly difference | Twelve month difference | | ------- | --------------------------- | --------------------------- | ------------------ | ----------------------- | | 4 vCPU | 13,500 x $0.016 = $216.00 | 13,500 x $0.022 = $297.00 | $81.00 | $972.00 | | 8 vCPU | 13,500 x $0.032 = $432.00 | 13,500 x $0.042 = $567.00 | $135.00 | $1,620.00 | | 16 vCPU | 13,500 x $0.064 = $864.00 | 13,500 x $0.082 = $1,107.00 | $243.00 | $2,916.00 | | 32 vCPU | 13,500 x $0.128 = $1,728.00 | 13,500 x $0.162 = $2,187.00 | $459.00 | $5,508.00 | Two things to read out of that table before choosing a size. The absolute gap grows with the shape, so the largest Windows jobs are where a list price difference is worth the most in dollars. And the WarpBuild rate doubles exactly with vCPU count, which means a step up pays for itself whenever the job spends more than half its wall clock on work that actually parallelizes: MSBuild with `/m`, `dotnet test` across several test projects, or a solution with many independent project graphs. A job that spends most of its time on a single-threaded restore or a serialized packaging step gains nothing from a bigger machine, so measure before you resize. Run the same arithmetic against your own job minutes on the [pricing page](/pricing). ### [Two limits worth planning around](#two-limits-worth-planning-around)Copy Anchor Link **WarpBuild caching is unsupported on Windows runners.** The [caching documentation](/docs/ci/features/caching) records this under its limitations: WarpBuild caching is not supported for Windows-based runners. It is enabled by default on Linux runners, so a team that swaps `actions/cache@v4` for `WarpBuilds/cache@v1` across a repository has to leave the Windows jobs alone. Keep `actions/cache@v4` on Windows, which stores entries in GitHub's own Actions cache service, keeps working with no extra configuration, and is what the workflow above uses for the NuGet global packages folder at `%UserProfile%\.nuget\packages`. A matrix that spans Linux and Windows therefore runs two cache actions, selected per leg. The [Windows cache answer](/answers/do-github-actions-caches-work-on-windows-runners) covers the key design that survives that split. **The 2 vCPU Windows runners are gone.** Windows 2 vCPU runners were removed on June 8, 2026 ([cloud runners documentation](/docs/ci/cloud-runners)). Any workflow still pinned to a 2 vCPU Windows label fails to schedule and has to move to 4 vCPU or larger, which makes `warp-windows-latest-x64-4x` at $0.016 per minute the smallest Windows shape available. Grep the repository for Windows labels ending in `-2x` before a migration, including composite actions and reusable workflows, because a reusable workflow called from ten repositories carries the stale label into all of them. ### [What the Windows line does not cover](#what-the-windows-line-does-not-cover)Copy Anchor Link ARM64 is Linux and macOS only. The Windows catalog is x86-64 across all twelve labels, so a Windows job stays on an x86-64 label with no ARM64 alternative to move to. Snapshot runners are the other gap. They list Linux x64 and Linux ARM64 as their supported platforms ([snapshot runners documentation](/docs/ci/features/snapshot-runners)). A Windows job can still use per-job resource metrics and logs to show where a long MSBuild step spends its time. Everything else in a normal Windows workflow behaves the way it does on the GitHub-hosted equivalent: checkout, the setup actions, service containers, artifacts, matrix jobs, reusable workflows, and job-level concurrency, so a release day that fans out to fifty Windows jobs at once runs them the same way a five-job day does. ## [Related Questions](#related-questions)Copy Anchor Link **Which Windows image should a GitHub Actions job pick?** `warp-windows-latest-x64-` resolves to Windows Server 2022 and is the safe default for an existing workflow. Use `warp-windows-2025-x64-` for Windows Server 2025 with Visual Studio 2022, and `warp-windows-2025-vs2026-x64-` when the build needs Visual Studio 2026\. All three families share the same four shapes, the same 256GB SSD, and the same per-minute rates, so an image move changes the toolchain and leaves the bill alone. The [Visual Studio images page](/runners/windows/visual-studio-images) lists what each image carries. **What is the smallest Windows runner available?** 4 vCPU with 16GB of memory, at $0.016 per minute. The 2 vCPU Windows runners were removed on June 8, 2026, so a workflow pinned to a 2 vCPU Windows label needs to move to 4 vCPU or larger. Every shape is listed in the [Windows runner catalog](/runners/windows). **Does the WarpBuild cache work on Windows runners?** No. The [caching documentation](/docs/ci/features/caching) records WarpBuild caching as unsupported for Windows-based runners, and it is enabled by default on Linux runners only. On a Windows job, keep `actions/cache@v4`, which writes to GitHub's own Actions cache service and needs no extra configuration. **Can GitHub Actions run Windows builds on ARM64 hardware?** Not on WarpBuild. The Windows catalog is x86-64 only, from `warp-windows-latest-x64-4x` through `warp-windows-latest-x64-32x`. ARM64 runners cover Linux and macOS, so a Windows job stays on an x86-64 label. Move a Windows job with the labels in the [Windows runner catalog](/runners/windows), check the toolchain your solution needs on the [Visual Studio images page](/runners/windows/visual-studio-images), and see the surrounding pipeline in [running .NET builds on GitHub Actions](/solutions/dotnet-github-actions). Price the change against your own Windows minutes on the [pricing page](/pricing). --- # Can I Still Build on Windows Server 2022? | WarpBuild URL: https://www.warpbuild.com/answers/can-github-actions-run-windows-server-2022-builds Description: Yes. Windows Server 2022 stays in the WarpBuild catalog on the warp-windows-latest-x64 and warp-windows-2022-x64 labels, priced from $0.016 per minute. # Can I Still Build on Windows Server 2022? Yes. Windows Server 2022 stays in the WarpBuild catalog on the warp-windows-latest-x64 and warp-windows-2022-x64 labels, priced from $0.016 per minute. Yes. Windows Server 2022 is a current image in the WarpBuild catalog: `warp-windows-latest-x64-` resolves to Windows Server 2022 today and `warp-windows-2022-x64-` is the dated alias for the same machines ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). Windows Server 2025 ships beside it on its own labels, and the [preinstalled software documentation](/docs/ci/preinstalled-software) publishes the Windows Server 2022 x86-64 tooling list for teams mapping a pipeline across the two. ## [Answer](#answer)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Windows line is x86-64 and covers twelve labels across two Windows Server versions and two Visual Studio generations. | Runner label | Windows Server version | Visual Studio | Alias | | -------------------------------- | ---------------------- | ------------- | ------------------------- | | warp-windows-latest-x64-4x | 2022 | 2022 | warp-windows-2022-x64-4x | | warp-windows-latest-x64-8x | 2022 | 2022 | warp-windows-2022-x64-8x | | warp-windows-latest-x64-16x | 2022 | 2022 | warp-windows-2022-x64-16x | | warp-windows-latest-x64-32x | 2022 | 2022 | warp-windows-2022-x64-32x | | warp-windows-2025-x64-4x | 2025 | 2022 | none | | warp-windows-2025-x64-8x | 2025 | 2022 | none | | warp-windows-2025-x64-16x | 2025 | 2022 | none | | warp-windows-2025-x64-32x | 2025 | 2022 | none | | warp-windows-2025-vs2026-x64-4x | 2025 | 2026 | none | | warp-windows-2025-vs2026-x64-8x | 2025 | 2026 | none | | warp-windows-2025-vs2026-x64-16x | 2025 | 2026 | none | | warp-windows-2025-vs2026-x64-32x | 2025 | 2026 | none | Every row comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The [Windows runner catalog page](/runners/windows) carries the same rows with vCPU, memory, and storage columns. Staying on Windows Server 2022 is a one line decision in the workflow file: Copy Text ``` name: windows-build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-windows-2022-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: "8.0.x" - uses: actions/cache@v4 with: path: ~\.nuget\packages key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} - run: dotnet restore - run: dotnet build --configuration Release --no-restore - run: dotnet test --configuration Release --no-build ``` The rate depends on the machine shape rather than the image, so a Windows Server 2022 runner costs the same as a Windows Server 2025 runner of the same size: $0.016 per minute at 4 vCPU up to $0.128 per minute at 32 vCPU ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Detail](#detail)Copy Anchor Link ### [Pin the dated alias rather than latest](#pin-the-dated-alias-rather-than-latest)Copy Anchor Link `latest` is a pointer, and pointers move. Writing `warp-windows-2022-x64-16x` keeps a build on Windows Server 2022 through any future rollover of the `latest` selector, which matters most for release branches that have to reproduce an artifact months later. Two other Windows facts belong in the same review: the 2 vCPU Windows runners were removed on June 8, 2026, so 4 vCPU is the smallest shape available, and WarpBuild caches are not supported on Windows-based runners, so Windows jobs keep using `actions/cache` ([cloud runners documentation](/docs/ci/cloud-runners)). ### [Migration checklist for a pipeline pinned to an older image](#migration-checklist-for-a-pipeline-pinned-to-an-older-image)Copy Anchor Link The Windows images carry the same tooling as the GitHub-hosted equivalents, so the differences between generations are the ones GitHub publishes in its image readmes. These rows come from the [Windows Server 2022 image readme](https://github.com/actions/runner-images/blob/main/images/windows/Windows2022-Readme.md) and the [Windows Server 2025 image readme](https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md), checked on 2026-08-13. | Image detail | Windows Server 2022 | Windows Server 2025 | | ------------------------------- | ------------------------------------------------------------------- | ------------------------------------- | | OS version | 10.0.20348 | 10.0.26100 | | Default JAVA\_HOME | JDK 8.0.502+7 | JDK 17.0.20+8 | | PostgreSQL service | postgresql-x64-14, version 14.23 | postgresql-x64-17, version 17.10 | | WSL | WSLv1 enabled | WSLv1 enabled, WSLv2 2.7.11.0 default | | MinGW gcc | 14.2.0 | 15.2.0 | | Perl | 5.32.1 | 5.42.0 | | Absent on the newer image | Mercurial 6.3.1, Subversion 1.14.5, NSIS 3.10 | removed | | Cached Windows container images | nanoserver:ltsc2022, servercore:ltsc2022, .NET Framework 4.8 images | none listed | Work the checklist in three passes. **Toolchain paths.** Grep the workflow and every build script for literal `C:\` paths. Visual Studio sits at `C:\Program Files\Microsoft Visual Studio\2022\Enterprise` on both the 2022 and 2025 images, so that path survives the version move and then breaks on the `vs2026` labels, where Visual Studio Enterprise 2026 installs to `C:\Program Files\Microsoft Visual Studio\18\Enterprise`. Resolve the install root with `vswhere` instead of hardcoding it. PostgreSQL paths move with the major version, so `PGBIN`, `PGDATA`, and `PGROOT` all change. **SDK versions.** Pin every toolchain in the workflow rather than inheriting an image default. The default JDK moves from 8 to 17 between the two images, and PyPy 2.7, 3.7, and 3.8 are gone from the 2025 image in favor of PyPy 3.9 and 3.10\. On the Visual Studio side, `Component.Xamarin`, `Component.Unreal`, `Component.Unreal.Android`, and `Microsoft.Net.Component.4.7.2.SDK` are absent from the 2025 image, while the 4.7.2 targeting pack is still installed. A build that needs a missing component installs it in a step or stays on the 2022 label. **Service dependencies.** Any step that starts a service by name needs the new name, so `net start postgresql-x64-14` becomes `postgresql-x64-17`. Windows container builds are the sharper edge: process isolation ties the container base image to the host OS version, and the Windows Server 2022 image preloads the `ltsc2022` base layers while the 2025 image readme lists no cached container images. Budget for a pull, and check that your Dockerfile tags match the host. Run both labels in one matrix with `fail-fast: false` before you move the pin, so the old image keeps producing artifacts while the new one collects failures. Resource utilization metrics and logs are available on Windows runners for that comparison ([feature matrix](/docs/ci/feature-matrix)). Snapshot runners cover Linux x64 and Linux ARM64, so the Windows comparison runs on ordinary ephemeral runners. ### [When you need a specific base image](#when-you-need-a-specific-base-image)Copy Anchor Link Teams with a hardened base, a licensed toolchain, or a pinned Windows build number bring their own image through BYOC. That is the requirement worth stating plainly: custom VM images are a BYOC feature, and the cloud runner fleet does not accept a customer-supplied VM image ([feature matrix](/docs/ci/feature-matrix)). BYOC runs on AWS, GCP, and Azure, and Windows runners on BYOC are available on AWS and Azure with GCP marked in progress in the same matrix. A Windows AMI has to meet three documented requirements before WarpBuild can boot it ([custom VM images documentation](/docs/ci/byoc/custom-vm-images)): `aria2` present and reachable through the system `PATH`, because the default Windows download path is slow; the EC2 instance sysprepped before the AMI is captured; and machine-level environment variables rather than user-scoped ones, since jobs run as `runneradmin`, the same account GitHub's Windows runners use. There is no additional cost for using custom VM images. The [custom runner image guide](/guides/custom-runner-images-github-actions) covers the build loop, and [Windows runners on BYOC AWS](/byoc/aws/windows-runners) covers the account setup. ## [Related Questions](#related-questions)Copy Anchor Link **Which label gives me a Windows Server 2022 runner?** `warp-windows-latest-x64-` resolves to Windows Server 2022 today, and `warp-windows-2022-x64-` is the dated alias for the same machines. Write the dated alias when the version has to stay fixed through a future `latest` rollover. Full shapes and rates are on the [Windows runner catalog page](/runners/windows). **What breaks when a pinned pipeline moves to Windows Server 2025?** Default `JAVA_HOME` moves from JDK 8 to JDK 17, the PostgreSQL service goes from `postgresql-x64-14` to `postgresql-x64-17`, and the Xamarin, Unreal, and .NET Framework 4.7.2 SDK Visual Studio components are absent from the 2025 image. Mercurial, Subversion, and NSIS are absent as well. The table above lists the full set with links to both image readmes. **Can I bring my own Windows image instead?** Yes, through BYOC. Custom VM images are a BYOC feature rather than a cloud runner feature, so the image lives in your own AWS, GCP, or Azure account. A Windows AMI needs `aria2` on the system `PATH` and has to be sysprepped before capture, as described in the [custom VM images documentation](/docs/ci/byoc/custom-vm-images) and the [custom runner image guide](/guides/custom-runner-images-github-actions). **Do WarpBuild caches work on Windows Server 2022 runners?** No. WarpBuild caches are not supported on Windows-based runners ([caching documentation](/docs/ci/features/caching)). Use `actions/cache` in the workflow for NuGet packages and MSBuild output, the same way a GitHub-hosted Windows job would. Pick a label from the [Windows runner catalog](/runners/windows), price the change against your own job minutes on the [pricing page](/pricing), and move to your own base image with [Windows runners on BYOC AWS](/byoc/aws/windows-runners) when the fleet image is nearly right for your build. --- # Can GitHub Actions Runners Reach My Private Network? | WarpBuild URL: https://www.warpbuild.com/answers/can-github-actions-runners-access-my-private-network Description: Yes. The networking addon joins each WarpBuild runner to your Tailscale tailnet at job start as an ephemeral node that is removed when the job ends. # Can GitHub Actions Runners Reach My Private Network? Yes. The networking addon joins each WarpBuild runner to your Tailscale tailnet at job start as an ephemeral node that is removed when the job ends. Last verified: 2026-08-13 Yes. WarpBuild runners reach private services through the networking addon, which joins the runner to your Tailscale tailnet at job start as an ephemeral node and removes that node when the job finishes. Setup is two pieces: a network addon configuration holding your Tailscale OIDC client ID, then a `network.name=` modifier appended to the `runs-on` label, both documented in the [networking addon reference](/docs/ci/features/networking). ## [Answer](#answer)Copy Anchor Link A GitHub Actions job gets private network access when the machine running it is a member of the private network. The networking addon makes each runner a member for the lifetime of the job. The sequence, from the [networking addon reference](/docs/ci/features/networking): 1. You create a network addon configuration in the WarpBuild dashboard under Addons > Networking, naming it and pasting the OIDC client ID from a Tailscale trust credential. 2. Your workflow adds `network.name=` to the `runs-on` label. 3. At job start, WarpBuild authenticates the runner to your tailnet with a short-lived OIDC token and an ephemeral node key. 4. The runner joins the tailnet and reaches any device or service the ACL policy allows. 5. When the job finishes, the ephemeral node is removed from the tailnet. The workflow change is one line: Copy Text ``` name: deploy on: push: branches: [main] jobs: migrate: runs-on: warp-ubuntu-latest-x64-4x;network.name=production-tailnet steps: - uses: actions/checkout@v4 - name: Check the internal API is reachable run: curl -fsS https://internal-api.myorg.tailnet.ts.net/health - name: Run database migrations run: | psql "postgres://db.myorg.tailnet.ts.net:5432/mydb" -f migrations.sql - name: Publish to the internal package registry run: npm publish --registry https://registry.myorg.tailnet.ts.net ``` The runner label and the addon modifier are separated by a semicolon, so the same modifier attaches to any label in the catalog. The addon works on all four. Two properties matter for the security review of this setup. The runner joins as an ephemeral node, so nothing lingers in your tailnet device list after the job. Authentication uses a short-lived OIDC token signed by WarpBuild rather than a long-lived auth key stored in a secret, so there is no static credential to rotate or leak. ## [Detail](#detail)Copy Anchor Link ### [The addon configuration and what goes in it](#the-addon-configuration-and-what-goes-in-it)Copy Anchor Link The configuration lives in WarpBuild and holds three fields. **Name.** A descriptive string such as `production-tailnet`. This is the value you write after `network.name=` in the workflow label, so pick something short and stable. **Provider.** Tailscale. **OIDC client ID.** Generated by Tailscale when you add a trust credential of type OIDC. The issuer is the custom issuer `https://api.warpbuild.com`, and the subject is the wildcard `warp-*`, because WarpBuild issues tokens with `sub` set to the runner instance ID and every instance ID carries the `warp-` prefix. On the scopes step, the credential needs read and write on `auth_keys` under Keys > Auth Keys, which is what allows ephemeral auth key creation for node registration. Every other scope stays unchecked. The client ID is a public identifier used to start the OIDC flow, so it does not need to live in a secret. The security of the exchange comes from the signed token, not from the ID. **Additional arguments.** An optional string of flags passed through to `tailscale up`. | Flag | Effect | | ------------------------------- | -------------------------------------------------------------- | | \--advertise-tags=tag:ci | Applies ACL tags to the runner node | | \--accept-routes | Accepts subnet routes advertised by other nodes on the tailnet | | \--accept-dns=false | Turns off Tailscale DNS overrides on the runner | | \--advertise-routes=10.0.0.0/24 | Advertises routes from the runner into the tailnet | Flags combine in a single string, for example `--advertise-tags=tag:ci --accept-routes --accept-dns=false`. Any tag you pass through `--advertise-tags` has to be defined in your Tailscale ACL policy and listed under the trust credential's Tags section, otherwise registration fails. ### [Reserved flags](#reserved-flags)Copy Anchor Link Five flags are managed by WarpBuild and are rejected if you supply them in additional arguments: | Reserved flag | Why WarpBuild owns it | | ------------- | ---------------------------------------------------------------------- | | \--hostname | Derived from the runner instance ID so nodes stay identifiable per job | | \--auth-key | Created per job as an ephemeral key through the OIDC exchange | | \--client-id | Taken from the addon configuration | | \--id-token | The short-lived token WarpBuild signs for the runner | | \--state | Points at the state path the setup script controls | The practical read is that you control policy and routing, and WarpBuild controls identity and lifecycle. Attempts to override identity or lifecycle fail the configuration rather than silently taking effect. ### [Platform coverage](#platform-coverage)Copy Anchor Link | Runner platform | Networking addon | Example label | | ------------------- | ---------------- | --------------------------- | | Linux, Ubuntu x64 | Supported | warp-ubuntu-latest-x64-4x | | Linux, Ubuntu ARM64 | Supported | warp-ubuntu-latest-arm64-4x | | macOS, ARM64 | Supported | warp-macos-latest-arm64-6x | | Windows, x64 | Supported | warp-windows-latest-x64-4x | Rows come from the [networking addon reference](/docs/ci/features/networking), checked on 2026-08-13\. Tailscale is pre-installed on every WarpBuild runner image, and the setup script starts the daemon and authenticates on demand, so jobs that do not request the addon never start it. BYOC runners work the same way, including AWS, GCP, and Azure. If you build custom VM images for BYOC, install `jq` in the image; the setup script installs Tailscale itself when it is missing. ### [Failure behavior](#failure-behavior)Copy Anchor Link If network setup fails, the job fails. That is the documented behavior and it is the one you want: a deploy job that cannot reach the internal database should stop rather than run half its steps against whatever it can reach. Build the assumption into your workflow design by keeping the private-network work in jobs that carry the `network.name` modifier and leaving public work on plain labels. ### [Scoping what a runner can reach](#scoping-what-a-runner-can-reach)Copy Anchor Link Tailnet membership on its own is broad, so the access control belongs in your ACL policy. Tag the runner node, then write rules against the tag: Copy Text ``` { "tagOwners": { "tag:ci": ["autogroup:admin"] }, "acls": [ { "action": "accept", "src": ["tag:ci"], "dst": ["tag:internal-api:443", "tag:db:5432"] } ] } ``` With that policy in place, a job running under `--advertise-tags=tag:ci` reaches the internal API on 443 and the database on 5432 and nothing else on the tailnet. Different job classes can carry different tags by pointing at different addon configurations, which is how you keep a test job away from production data stores. ### [Combining the addon with other label modifiers](#combining-the-addon-with-other-label-modifiers)Copy Anchor Link Modifiers chain on one `runs-on` line, so private network access composes with snapshot restore: Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-4x;network.name=production-tailnet;snapshot.key=deps-v3 integration: runs-on: warp-ubuntu-latest-x64-8x;network.name=production-tailnet;snapshot.enabled=true ``` Infrastructure workflows are the other common pairing. A `terraform apply` job that talks to a private state backend or an internal provider API carries the same modifier as any other job, and the shape of those pipelines is covered in [Terraform workflows on GitHub Actions](/solutions/terraform-github-actions). ### [What the addon does not cover](#what-the-addon-does-not-cover)Copy Anchor Link The networking addon covers Tailscale tailnet membership. It does not hand a runner a fixed public egress address, and it is not an egress allowlist. Teams that need a stable source address for a partner firewall rule get it a different way: run BYOC runners inside your own cloud account and configure the subnets there, which is covered in the [BYOC AWS security hardening docs](/docs/ci/byoc/aws/security) and in the [BYOC on AWS overview](/byoc/aws). The two capabilities stack, since the addon works on BYOC runners as well. For the wider decision between joining a tailnet and placing the runners themselves inside your own network, see [running GitHub Actions runners inside your own VPC](/guides/github-actions-runners-in-your-vpc). ### [What a private-network workflow costs](#what-a-private-network-workflow-costs)Copy Anchor Link Take a deploy pipeline that runs 1,200 jobs a month at 8 minutes each on a 4 vCPU Linux runner, every job joining the tailnet to reach an internal database and an internal registry. That is 9,600 minutes. | Line item | Rate | Monthly | | ------------------------------------------------------------ | ----------------- | ------- | | warp-ubuntu-latest-x64-4x, 9,600 minutes | $0.008 per minute | $76.80 | | Networking addon | $0 per minute | $0.00 | | GitHub-hosted 4-core Linux larger runner, same 9,600 minutes | $0.012 per minute | $115.20 | WarpBuild rates come from the [pricing page](/pricing). The GitHub rate comes from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. The arithmetic is (0.012 - 0.008) / 0.012, which is 33 percent lower list price at the same 4 vCPU and 16 GB shape. Every cost claim on this page carries the number, the source link, and the checked-on date, and the same numbers appear on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link **Which runner platforms support the networking addon?** Linux x64, Linux ARM64, macOS, and Windows. Tailscale is pre-installed on every WarpBuild runner image, and the setup script starts the daemon only when a job requests the addon. Label shapes for all four platforms are in the [networking addon reference](/docs/ci/features/networking). **What happens if the Tailscale setup fails?** The job fails. Network setup runs before your steps, so a workflow never runs silently without the private access it expects. **Can different jobs join different tailnets?** Yes. Create one network addon configuration per tailnet and reference the one you want with `network.name=` on the `runs-on` label of each job. The same pattern separates production access from staging access. **Does the networking addon give my runners a static egress IP?** No. The addon covers Tailscale tailnet membership only. Static egress IPs come from subnet configuration inside your own cloud account on BYOC runners, which is a separate capability described in the [BYOC AWS security hardening docs](/docs/ci/byoc/aws/security). Wire up the addon with the steps in the [networking addon reference](/docs/ci/features/networking), size the jobs against your own minutes on the [pricing page](/pricing), and read the [security review material](/enterprise/ci-security-review) if a reviewer needs the runner isolation story in writing. --- # Can GitHub Actions Runners Run in My Own Cloud Account? | WarpBuild URL: https://www.warpbuild.com/answers/can-github-actions-runners-run-in-my-own-cloud Description: Yes. WarpBuild BYOC runs GitHub Actions runners inside your own AWS, GCP, or Azure account, your cloud bills the compute, WarpBuild charges $0.002 per minute. # Can GitHub Actions Runners Run in My Own Cloud Account? Yes. WarpBuild BYOC runs GitHub Actions runners inside your own AWS, GCP, or Azure account, your cloud bills the compute, WarpBuild charges $0.002 per minute. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Yes. WarpBuild BYOC runs your GitHub Actions runners as ordinary virtual machines inside your own AWS account, GCP project, or Azure subscription, so your cloud provider bills the compute directly and WarpBuild charges $0.002 per runner minute on top. Your workflows change by one `runs-on` label, and WarpBuild keeps operating the control plane that watches the GitHub Actions job queue and creates and destroys the machines. Three objects make up a BYOC setup, and the [BYOC documentation](/docs/ci/byoc) carries the click path for each one. 1. **A cloud connection.** This grants WarpBuild the permissions it needs to manage runners in your account: an IAM role on AWS, a service account on GCP, an enterprise application consent on Azure. Deleting the connection ends that access. 2. **A stack.** A stack is the group of infrastructure in one region of your account that runners need: a VPC with public and private subnets, and an object storage bucket used for the artifact cache, the container image layer cache, runner logs, and telemetry. The stack name, the storage location, and the region are fixed at creation. 3. **A custom runner.** This is a named machine configuration: one or more instance types in priority order, disk size and performance, IP configuration, standby disk pool size, and on AWS an instance profile. The workflow references it by Runner ID, which is the runner name with the `warp-custom-` prefix. Everything the stack creates lives in your account and stays there after you stop using it. Job workspaces, cache contents, logs, and build artifacts sit in your bucket and on your instances. The WarpBuild control plane holds configuration and queue state. The BYOC fee is $0.002 per runner minute for Linux runners and $0.002 per runner minute for Windows runners, listed with every other rate on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [What stays yours](#what-stays-yours)Copy Anchor Link Each GitHub Actions job runs on a freshly created instance that is terminated when the job finishes, so no runner survives to serve a second job and nothing accumulates between jobs. Autoscaling follows queue depth rather than a fixed pool size. The permissions your build steps hold are the ones you attach, and only those. On AWS, each custom runner takes an instance profile ARN, so a deploy runner and a unit-test runner under the same stack can carry different AWS authority. WarpBuild injects no permissions into runner workloads. Wiring a profile takes one `iam:PassRole` grant on the WarpBuild connection role, documented in the [instance profile guide](/docs/ci/byoc/aws/instance-profile). GCP uses a project service account for the same purpose. Cold starts are handled by standby disks rather than by keeping machines registered. A standby disk is a VM of the runner's instance type, booted once so networking and machine setup complete, then shut down. When GitHub requests a runner, WarpBuild boots one from the pool, which puts job start at roughly 15 seconds. WarpBuild does not charge for standby disks, and Spot instances cannot back them, because a reclaimed instance would make the pool a guess rather than a guarantee. ### [The workflow change](#the-workflow-change)Copy Anchor Link A BYOC label and a WarpBuild-hosted label can sit in the same workflow file. That is how most teams run Linux in their own account while keeping macOS on hosted capacity, since. And the macOS ones are hosted only. .github/workflows/ci.yml Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-custom-checkout-use1-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test integration-tests: needs: unit-tests runs-on: warp-custom-checkout-use1-16x steps: - uses: actions/checkout@v4 - run: ./scripts/integration.sh ios-build: runs-on: warp-macos-latest-arm64-6x steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build ``` Nothing else in the workflow moves. Actions, secrets, matrix strategies, and required status checks behave the same way, because the runner still registers against your repository through the standard GitHub Actions runner agent. ### [What you pay, and to whom](#what-you-pay-and-to-whom)Copy Anchor Link Three routes for the same 4 vCPU, 16 GB Linux job at 150,000 runner minutes per month. GitHub rates come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. WarpBuild rates come from the [pricing page](/pricing). | Line | GitHub-hosted 4-core Linux larger runner | WarpBuild hosted warp-ubuntu-latest-x64-4x | WarpBuild BYOC in your account | | ------------------------------ | ---------------------------------------- | ------------------------------------------ | ------------------------------ | | Rate per minute | $0.012 | $0.008 | $0.002 WarpBuild fee | | Minutes per month | 150,000 | 150,000 | 150,000 | | Monthly charge from the vendor | $1,800 | $1,200 | $300 | | Who bills the compute | GitHub | WarpBuild | Your cloud account | | Where job data sits | GitHub-hosted capacity | WarpBuild capacity | Your VPC and your bucket | The arithmetic on the hosted route is (0.012 - 0.008) / 0.012 = 33 percent lower list price, GitHub list price checked on 2026-08-13. The BYOC column is a partial bill by design. Add what your cloud charges for the instances, disks, IP addresses, and data transfer those jobs consume. 150,000 runner minutes is 2,500 instance hours per month, so multiply 2,500 by your effective hourly rate for the instance type you select, then add disk and storage. That rate is the lever BYOC gives you: BYOC runners cost $0.002 per minute in WarpBuild fees and the compute is billed by your own cloud account, where AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices ([AWS EC2 Spot Instances](https://aws.amazon.com/ec2/spot/), AWS discount checked on 2026-08-13). Committed-use discounts and reserved capacity you already hold apply the same way. Estimate a BYOC fleet from your cloud's instance rates and measured job duration, then add the WarpBuild fees on the [pricing page](/pricing). Model it against one real month of workflow minutes before migrating. Instance mix, Spot ratio, and how much cache and telemetry your bucket accumulates all move the answer, and only the $0.002 line is fixed. ### [The per-cloud reality check](#the-per-cloud-reality-check)Copy Anchor Link The three clouds are not at parity. This matrix is drawn from the [feature matrix](/docs/ci/feature-matrix) and the per-cloud configuration docs, checked on 2026-08-13. | Capability | AWS | GCP | Azure | | ------------------------------------ | ------------------------ | ------------------------ | ----------------- | | Create stack resources in a new VPC | Supported | Supported | Supported | | Import an existing VPC (import mode) | Supported | Not supported | Not supported | | Linux runners, x86-64 and arm64 | Supported | Supported | Supported | | Windows Server 2022 x86-64 runners | Supported | Not yet available | Supported | | macOS runners | Hosted only | Hosted only | Hosted only | | Snapshot runners | Not available | Not available | Not available | | Spot instances | Supported | Supported | Supported | | Static IPs | Supported | Supported | Supported | | Standby disks | Supported | Supported | Supported | | Custom VM images | Supported | Supported | Supported | | Custom service account or IAM roles | Supported | Supported | Not yet available | | Custom resource tags | Supported | Not yet available | Not yet available | | Terraform provider coverage | Supported | Not covered | Not covered | | Region availability | Any region you configure | Any region you configure | East US only | Four of those rows decide most migrations. Import mode matters when your network team will not accept a new VPC. On AWS you can hand WarpBuild an existing VPC, subnets, and security group as inputs. On GCP and Azure the stack creates its own resources. Azure BYOC is currently enabled for East US only, and the region is fixed at stack creation, so a second region means a second stack, a second storage container, and a second set of runner definitions. Additional regions go through [support@warpbuild.com](mailto:support@warpbuild.com). Windows on GCP BYOC is not yet available. Windows BYOC runners run on AWS and Azure, and on GCP a Windows job keeps a hosted `warp-windows-` label in the same workflow. Terraform support exists for BYOC on AWS. GCP and Azure stacks are created from the dashboard. ### [What stays on hosted runners](#what-stays-on-hosted-runners)Copy Anchor Link Snapshot runners are the one product surface BYOC does not carry on any cloud. Standby disks fill the cold-start role instead. The rest travels: remote Docker builders, CI observability, an MCP server, and the Action Debugger all work against BYOC runners. If your requirement is regional placement rather than account placement, hosted runners cover it without any cloud setup on your side. US and EU are the regions WarpBuild names publicly. Egress charges during deploy-heavy jobs are handled on the enterprise tier, on BYOC and on WarpBuild-hosted runners; see [zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress). The per-cloud walkthroughs are on the [AWS BYOC page](/byoc/aws), the [GCP BYOC page](/byoc/gcp), and the [Azure BYOC page](/byoc/azure). ## [Related Questions](#related-questions)Copy Anchor Link ### [Which clouds can WarpBuild runners run in?](#which-clouds-can-warpbuild-runners-run-in)Copy Anchor Link AWS, GCP, and Azure. Each cloud gets a connection, a stack in one region of your account, and one or more custom runner configurations. The setup steps differ per cloud, and so do a few capabilities. Import mode is AWS only, Azure BYOC is enabled for East US only, and Windows runners on GCP BYOC are not yet available. The details are on the [AWS BYOC page](/byoc/aws), the [GCP BYOC page](/byoc/gcp), and the [Azure BYOC page](/byoc/azure). ### [Who pays for the compute when runners live in my cloud account?](#who-pays-for-the-compute-when-runners-live-in-my-cloud-account)Copy Anchor Link Your cloud account does. AWS, Google, or Microsoft bills the instances, disks, IP addresses, and object storage at your own rates, including any Spot or committed-use discounts you already hold. WarpBuild charges $0.002 per runner minute on top. ### [What do I give up by running runners in my own account?](#what-do-i-give-up-by-running-runners-in-my-own-account)Copy Anchor Link Snapshot runners are not available on BYOC on any cloud, and macOS runners stay on WarpBuild-hosted capacity. Import mode for an existing VPC is AWS only, Terraform coverage is AWS BYOC only, Windows on GCP BYOC is not yet available, and Azure BYOC is currently enabled for East US only. The [feature matrix](/docs/ci/feature-matrix) is the row-by-row reference. ### [Should I pick hosted runners or BYOC?](#should-i-pick-hosted-runners-or-byoc)Copy Anchor Link Pick BYOC when job data, caches, and artifacts have to stay inside infrastructure your organization owns, or when you already hold cloud discounts worth spending runner minutes against. Pick hosted runners when you want no cloud quota work, macOS coverage, or snapshot runners. A single workflow can mix both, so the choice is per job rather than per organization. The [hosted versus BYOC comparison](/guides/hosted-vs-byoc-runners) walks through the decision with the numbers attached. --- # Can I Alert When a Runner Runs Out of Memory? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-alert-when-a-runner-runs-out-of-memory Description: Yes. Resource threshold alerts flag every runner whose max memory utilization reaches 80 percent, and the signal worth acting on is sustained pressure. # Can I Alert When a Runner Runs Out of Memory? Yes. Resource threshold alerts flag every runner whose max memory utilization reaches 80 percent, and the signal worth acting on is sustained pressure. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Yes. Resource threshold alerts are part of the observability feature: the Recommendations view filters and highlights every runner instance whose max memory utilization reaches 80 percent and labels it High Memory Usage, per the [observability documentation](/docs/ci/features/observability). The signal worth alerting on is sustained pressure across a week of runs rather than one spike, because a single job that touched 81 percent once proves nothing and a job whose p75 sits at 80 percent is running out of room on every pull request. Four thresholds ship with the Recommendations view, and memory is one of them. | Metric | Threshold | Alert label | | -------------------------- | ------------------------------- | --------------------- | | Max sustained CPU | \>= 80% | High CPU Usage | | Max memory utilization | \>= 80% | High Memory Usage | | Max filesystem utilization | \>= 80% | High Filesystem Usage | | Max disk I/O | \>= 80% of supported throughput | High Disk IO | Memory utilization here is the maximum memory usage percentage recorded for the instance while the job ran, collected by an agent on the runner over OpenTelemetry on port `33931`. Two collection details change what you will see. Observability records metrics and logs only for jobs longer than about one minute, so a fleet of 30 second jobs shows gaps, and the numbers cover the whole machine rather than one process, so a test runner and a database container in the same job land in the same reading. GitHub Actions itself reports none of this. The job object in the [workflow jobs REST API](https://docs.github.com/en/rest/actions/workflow-jobs) carries timestamps, steps, and a conclusion, with no utilization field, which is why an exhausted runner usually surfaces as a step that died with no stack trace. A process killed by the Linux out-of-memory killer dies on SIGKILL, and the shell reports that as exit code 137 ([signal(7)](https://man7.org/linux/man-pages/man7/signal.7.html)). ## [Detail](#detail)Copy Anchor Link ### [What the built-in alert covers](#what-the-built-in-alert-covers)Copy Anchor Link The Recommendations view is a filter over the fleet rather than a pager. It applies the fixed thresholds above, flags the jobs that cross them, and names the current label next to a recommended label, leaving the change to you. To turn that into a notification, poll the same analysis from the API. It is served at `https://api.warpbuild.com/api/v1` and authenticated with an API key carrying the `ci` scope as a bearer token. Copy Text ``` curl -sS -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/org_metrics/job_runner_recommendations?per_page=100" ``` Each recommendation carries `type`, `current_label`, `recommended_label`, `already_at_max_size`, and a `resources` array naming the metric that triggered it, so filtering on `"memory"` gives you exactly the jobs this question is about. A weekly workflow that fails when that list is non-empty is enough of an alert for most teams. Copy Text ``` name: runner-memory-watch on: schedule: - cron: "0 13 * * 1" workflow_dispatch: jobs: check-memory-recommendations: runs-on: warp-ubuntu-latest-x64-2x steps: - name: Pull runner recommendations env: WARPBUILD_API_KEY: ${{ secrets.WARPBUILD_API_KEY }} run: | curl -sS -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/org_metrics/job_runner_recommendations?per_page=100" \ > recommendations.json - name: Report memory bound jobs run: | jq -r '.recommendations[] | select(.recommendation.type == "upgrade") | select(.recommendation.resources | index("memory")) | "\(.repository) \(.workflow_name) \(.job_name): \(.recommendation.current_label) -> \(.recommendation.recommended_label)"' \ recommendations.json | tee memory-bound.txt test ! -s memory-bound.txt ``` That watch job runs on `warp-ubuntu-latest-x64-2x` at $0.004 per minute from the [cloud runners catalog](/docs/ci/cloud-runners), so a weekly run that finishes inside a billed minute costs under $0.02 a month. Swap the final `test` for a Slack step if you would rather be told than blocked. ### [Read the percentile before you pick a number](#read-the-percentile-before-you-pick-a-number)Copy Anchor Link The threshold that matters for your own alert comes out of history, not out of a guess. Pull the Jobs report for the last seven days and read `memory_p75` and `memory_p90` per repository, workflow, and job name, then compare the pair. The shapes below are illustrative rather than measurements, and they cover the four readings you will actually meet. | Job | Current label | memory\_p75 | memory\_p90 | Reading | | ----------------- | ------------------------- | ----------- | ----------- | ---------------------------------------------- | | integration-tests | warp-ubuntu-latest-x64-8x | 88.1 | 94.6 | Sustained pressure. Move up one size. | | bundle | warp-ubuntu-latest-x64-4x | 31.4 | 92.8 | One heavy step. Fix the step or split the job. | | unit-tests | warp-ubuntu-latest-x64-4x | 44.2 | 51.7 | Healthy. No action. | | lint | warp-ubuntu-latest-x64-8x | 12.6 | 15.1 | Over-provisioned. Move down one size. | Row one is the alert case: the machine is short of memory on most runs, and the next failure is a matter of when the dependency graph grows. Row two is the false positive that wastes an afternoon if you alert on p90 alone, because one linker or one bundler pass touching 92 percent while the rest of the job sits near 31 percent is a step problem. Set the alert on p75 at 80 percent, keep p90 as a warning, and re-read both after any change. ### [What moving up a size costs](#what-moving-up-a-size-costs)Copy Anchor Link Memory and vCPU scale together in this catalog, so the fix for row one is the next label up and the price of the fix is exact. Take that job at 600 runs a month with a 12.0 minute duration on `warp-ubuntu-latest-x64-8x`, which is 8 vCPU and 32 GB at $0.016 per minute: 7,200 minutes, or $115.20 a month. `warp-ubuntu-latest-x64-16x` carries 16 vCPU and 64 GB at $0.032 per minute. If the extra memory removes the swapping and the duration falls to 9.0 minutes, that is 5,400 minutes at $0.032, or $172.80, an increase of $57.60 a month for a job that stops failing. If the duration does not move at all, the same 7,200 minutes cost $230.40, and the upgrade is buying reliability rather than time. Compare that rate against the nearest GitHub-hosted shape before deciding it is expensive. `warp-ubuntu-latest-x64-16x` at $0.032 per minute sits against the 16-core Linux larger runner of the same 16 vCPU, 64 GB shape at $0.042 per minute: 24 percent lower list price ([GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). Every rate on this page comes from the [pricing page](/pricing) and the cloud runners catalog, checked on the same date. One check before you spend anything. A runtime that caps its own heap will die at the same point on a 64 GB machine, so confirm the ceiling is the machine rather than a flag. Node processes stop at `--max-old-space-size`, and JVM builds stop at the `-Xmx` value in `org.gradle.jvmargs` or `MAVEN_OPTS`. The [out of memory guide](/guides/github-actions-out-of-memory) separates those two cases, and the [right-sizing guide](/guides/runner-right-sizing-github-actions) covers the full ladder in both directions. ### [Coverage and what the alerting costs](#coverage-and-what-the-alerting-costs)Copy Anchor Link The same agent collects utilization on every runner WarpBuild operates, including BYOC instances in your own cloud account, as long as port `33931` is open on egress. The memory alert uses that observability data. The [observability solution page](/solutions/github-actions-observability) covers the reports and the API in full, and [alerting on GitHub Actions regressions](/guides/alerting-on-ci-regressions) covers the duration and failure-rate signals that sit next to this one. Observability, the Recommendations view, and the reports API carry no separate charge. ## [Related Questions](#related-questions)Copy Anchor Link ### [What memory threshold fires the alert on a WarpBuild runner?](#what-memory-threshold-fires-the-alert-on-a-warpbuild-runner)Copy Anchor Link Max memory utilization at or above 80 percent, flagged as High Memory Usage in the Recommendations view. The same 80 percent rule covers max sustained CPU, max filesystem utilization, and max disk I/O against supported throughput, and all four are listed in the [observability documentation](/docs/ci/features/observability). Jobs shorter than about one minute report no metrics at all, so they never trigger any of the four. ### [How do I set my own memory alert threshold for a GitHub Actions job?](#how-do-i-set-my-own-memory-alert-threshold-for-a-github-actions-job)Copy Anchor Link Read a week of runs first. Pull `memory_p75` and `memory_p90` per job from the Jobs report, alert when p75 crosses 80 percent, and treat a high p90 next to a low p75 as one heavy step rather than an undersized machine. The [observability solution page](/solutions/github-actions-observability) shows the report fields and the API call that returns them, and [alerting on GitHub Actions regressions](/guides/alerting-on-ci-regressions) covers wiring the result into a schedule. ### [What does moving the job up one runner size cost?](#what-does-moving-the-job-up-one-runner-size-cost)Copy Anchor Link Memory and vCPU scale together, so the next label doubles both the RAM and the rate. `warp-ubuntu-latest-x64-8x` is 8 vCPU and 32 GB at $0.016 per minute and `warp-ubuntu-latest-x64-16x` is 16 vCPU and 64 GB at $0.032 per minute, both from the [cloud runners catalog](/docs/ci/cloud-runners) and the [pricing page](/pricing). At 600 runs a month of 12.0 minutes, that step adds $115.20 if the duration holds flat and $57.60 if the duration falls to 9.0 minutes. The [right-sizing guide](/guides/runner-right-sizing-github-actions) works through the break-even in both directions. --- # Can I Attach an IAM Role to a BYOC Runner? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-attach-an-iam-role-to-a-byoc-runner Description: Yes. AWS BYOC runners take an IAM instance profile per runner class, so GitHub Actions jobs receive scoped AWS credentials without an access key in a secret. # Can I Attach an IAM Role to a BYOC Runner? Yes. AWS BYOC runners take an IAM instance profile per runner class, so GitHub Actions jobs receive scoped AWS credentials without an access key in a secret. ## [Answer](#answer)Copy Anchor Link Yes. Every custom runner in an AWS BYOC stack takes an Instance Profile ARN, so the EC2 instance WarpBuild launches for a job starts with an IAM role already attached and the AWS SDK inside your build steps reads credentials from instance metadata ([instance profile setup](/docs/ci/byoc/aws/instance-profile)). This is the documented way to give GitHub Actions jobs cloud access without an access key sitting in a repository secret, and the field lives on the runner configuration rather than on the stack, so permissions are scoped per workload type ([AWS BYOC configuration](/docs/ci/byoc/aws/config)). Two AWS objects have to exist before the field accepts a value, and both are described in the AWS documentation the WarpBuild guide points at: an IAM role that trusts `ec2.amazonaws.com`, and an instance profile holding that role ([AWS IAM roles for EC2](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html), [AWS IAM instance profiles](https://docs.aws.amazon.com/IAM/latest/UserGuide/id%5Froles%5Fuse%5Fswitch-role-ec2%5Finstance-profiles.html)). ## [Detail](#detail)Copy Anchor Link ### [The one grant people miss](#the-one-grant-people-miss)Copy Anchor Link WarpBuild launches instances in your account through the IAM role created when you connected the cloud, named `warpbuild-` and visible on the [BYOC page](https://app.warpbuild.com/ci/byoc). AWS refuses to let any principal attach a role it has no permission to pass, so that connection role needs `iam:PassRole` on your runner role, scoped by a condition to EC2: Copy Text ``` aws iam put-role-policy \ --role-name \ --policy-name PassRolePolicy \ --policy-document '{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "iam:PassRole", "Resource": "arn:aws:iam:::role/ci-runner-role", "Condition": { "StringEquals": { "iam:PassedToService": "ec2.amazonaws.com" } } } ] }' ``` Confirm the grant landed before you queue a job, using the simulator rather than a failed workflow run: Copy Text ``` aws iam simulate-principal-policy \ --policy-source-arn \ --action-names iam:PassRole \ --resource-arns arn:aws:iam:::role/ci-runner-role \ --context-entries ContextKeyName=iam:PassedToService,ContextKeyType=string,ContextKeyValues=ec2.amazonaws.com ``` The full sequence, including the role creation and the policy attachment, is in the [security hardening guide](/docs/ci/byoc/aws/security). ### [One profile per runner class](#one-profile-per-runner-class)Copy Anchor Link The Instance Profile ARN is per custom runner, which makes one profile per runner class the pattern to reach for. A shared role that carries every permission any job might need turns the weakest workflow in the repository into the blast radius for the strongest. Splitting by class keeps each policy small enough to read. A worked split for one stack, with the policy shapes drawn from the [security hardening guide](/docs/ci/byoc/aws/security): | Runner ID in runs-on | What jobs on it do | Instance profile | Policy scope | | -------------------------- | ------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- | | warp-custom-test-use1-8x | Unit tests, lint, type checks | None attached | No AWS authority at all | | warp-custom-build-use1-16x | Build and push container images | ci-ecr-push-profile | ecr:GetAuthorizationToken on \*, plus layer and image actions on arn:aws:ecr:::repository/ | | warp-custom-deploy-use1-4x | Upload release artifacts | ci-artifacts-profile | s3:PutObject, s3:GetObject, s3:ListBucket on one bucket and its objects | The workflow side carries no credential wiring at all. The label selects the machine, and the machine arrives with its authority: .github/workflows/release.yml Copy Text ``` name: release on: push: branches: [main] jobs: unit-tests: runs-on: warp-custom-test-use1-8x steps: - uses: actions/checkout@v4 - run: make test image: needs: unit-tests runs-on: warp-custom-build-use1-16x steps: - uses: actions/checkout@v4 - run: aws ecr get-login-password | docker login --username AWS --password-stdin "$ECR_REGISTRY" - run: docker build -t "$ECR_REGISTRY/app:$GITHUB_SHA" . - run: docker push "$ECR_REGISTRY/app:$GITHUB_SHA" ``` Each job runs on a freshly provisioned instance that is terminated when the job finishes, so the credentials the profile supplies never outlive the job that used them ([security hardening guide](/docs/ci/byoc/aws/security)). ### [Instance profile or an OIDC exchange](#instance-profile-or-an-oidc-exchange)Copy Anchor Link The alternative is an OpenID Connect exchange from inside the workflow: GitHub issues a short-lived token for the job, and your role trust policy decides which repository, branch, or environment may assume the role ([GitHub OIDC in AWS](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services)). The exchange itself is normally handled by the public [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) action. | Dimension | Instance profile | OIDC exchange in the workflow | | ----------------------------- | ---------------------------------------- | ---------------------------------------------------- | | Where the identity is decided | On the runner class, in WarpBuild | Per job, in the role trust policy | | Workflow changes | None | permissions: id-token: write plus a credentials step | | Granularity | Every job using that runs-on label | Repository, branch, environment, or job claims | | Works on hosted runners | No, EC2 BYOC runners only | Yes | | Failure mode | Every job on the class inherits the role | A trust policy condition typo blocks the job | Take the instance profile for baseline access that every job on a class needs, such as pulling base images or reading a shared bucket. Take the exchange when authority has to differ by branch or environment, or when the same job sometimes lands on hosted runners. They stack cleanly, because the credentials the action writes into the job environment take precedence over the instance metadata credentials for the remaining steps under the AWS SDK provider chain ([AWS SDK credential resolution](https://docs.aws.amazon.com/sdkref/latest/guide/standardized-credentials.html)). A common shape is a narrow profile on the class and an exchange for the deploy step alone. Two settings finish the hardening. Require IMDSv2 per runner, under Runner Specs in the dashboard, which closes the request-forgery path to instance credentials ([security hardening guide](/docs/ci/byoc/aws/security)). Keep the runner stack in a dedicated VPC or a dedicated AWS account so the profile's reach ends at the workloads it was written for. ### [What the profile costs](#what-the-profile-costs)Copy Anchor Link Nothing on the WarpBuild side. BYOC runners bill at $0.002 per runner minute for Linux runners and $0.002 per runner minute for Windows runners, rates checked on 2026-08-13, and AWS bills your own account for the EC2 instances, disks, and transfer those jobs consume. The instance profile applies to the EC2-backed BYOC ones, since macOS runners stay on WarpBuild-hosted capacity where the OIDC exchange is the route to AWS. The architecture around all of this is on the [AWS BYOC page](/byoc/aws). ## [Related Questions](#related-questions)Copy Anchor Link ### [What does WarpBuild need before it can attach my IAM role?](#what-does-warpbuild-need-before-it-can-attach-my-iam-role)Copy Anchor Link One grant. The WarpBuild connection role in your account, named `warpbuild-`, needs `iam:PassRole` on the role behind your instance profile, with a condition restricting `iam:PassedToService` to `ec2.amazonaws.com`. Without that grant the launch fails, because AWS blocks any principal from passing a role it has no permission to pass. The commands are in the [instance profile setup guide](/docs/ci/byoc/aws/instance-profile), and the walkthrough is on [instance profiles for AWS BYOC runners](/byoc/aws/instance-profiles). ### [Can two runners in the same stack carry different AWS permissions?](#can-two-runners-in-the-same-stack-carry-different-aws-permissions)Copy Anchor Link Yes. The Instance Profile ARN is a field on the custom runner configuration rather than on the stack, so a deploy runner and a unit-test runner sharing one VPC and one bucket can hold different AWS authority. That is the least-privilege pattern to use: one profile per runner class, sized to what jobs on that class actually touch. The term itself is defined on [IAM instance profile](/glossary/iam-instance-profile). ### [Should I use an instance profile or an OIDC exchange from the workflow?](#should-i-use-an-instance-profile-or-an-oidc-exchange-from-the-workflow)Copy Anchor Link Use an instance profile for the baseline access every job on a runner class needs, such as pulling base images from ECR. Use an OIDC exchange when the authority has to vary per repository, branch, or environment, or when the same job also runs on hosted runners. They compose, and the exchanged credentials win for the rest of the job once the workflow sets them in the environment. The exchange is walked through on [authenticating to AWS from GitHub Actions with OIDC](/answers/how-do-i-authenticate-to-aws-from-github-actions-with-oidc). ### [Do GCP and Azure BYOC runners support the same thing?](#do-gcp-and-azure-byoc-runners-support-the-same-thing)Copy Anchor Link GCP does, through a custom service account attached to the GCE instance ([attach a service account](/docs/ci/byoc/gcp/service-account)). Azure custom IAM roles are marked as not yet available in the [feature matrix](/docs/ci/feature-matrix), checked on 2026-08-13\. BYOC runs on AWS, GCP, and Azure, so check that matrix row before assuming a per-runner identity exists on your cloud. --- # Can I Build ARM64 Images on an x64 Runner? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-build-arm64-docker-images-on-an-x64-runner Description: Yes, through QEMU emulation of aarch64 instructions. A native ARM64 label such as warp-ubuntu-latest-arm64-8x at $0.012 per minute skips the translation. # Can I Build ARM64 Images on an x64 Runner? Yes, through QEMU emulation of aarch64 instructions. A native ARM64 label such as warp-ubuntu-latest-arm64-8x at $0.012 per minute skips the translation. Last verified: 2026-08-13 Yes, an x64 runner builds `linux/arm64` Docker images through QEMU emulation: `docker/setup-qemu-action` registers `binfmt_misc` handlers on the host, and BuildKit then executes every `RUN` layer as translated aarch64 instructions on x86-64 hardware ([Docker multi-platform documentation](https://docs.docker.com/build/building/multi-platform/)). The same Dockerfile built on a native ARM64 runner such as `warp-ubuntu-latest-arm64-8x` at $0.012 per minute runs those steps directly on aarch64 hardware instead, so no instruction translation happens at all ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link Three routes produce an arm64 image from a GitHub Actions workflow, and only the first one involves translation. **Emulation on an x64 runner.** [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) installs QEMU static binaries and registers the `binfmt_misc` handlers, so the kernel routes aarch64 executables through user-mode QEMU. BuildKit runs each `RUN` layer inside an arm64 container on the x86-64 machine. **A native ARM64 runner.** The job lands on aarch64 hardware and the build host architecture matches the target. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the [Linux ARM64 runner catalog](/runners/linux-arm64) lists ten labels from 2 vCPU to 32 vCPU. **A remote Docker builder with arm64 enabled.** The workflow job stays where it is and the build executes on a builder virtual machine of the target architecture with a persistent layer cache ([Docker builders documentation](/docs/ci/docker-builders)). Here is the emulated workflow in full: Copy Text ``` name: image-arm64-emulated on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: docker/setup-qemu-action@v3 with: platforms: arm64 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . platforms: linux/arm64 push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} ``` The native version of the same file changes one line and deletes one step: Copy Text ``` jobs: build: - runs-on: warp-ubuntu-latest-x64-8x + runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 - - uses: docker/setup-buildx-action@v3 ``` Everything below that point stays identical, including `platforms: linux/arm64`, the login step, and the tags. Per-minute rates for every label are on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [What emulation translates and what it leaves alone](#what-emulation-translates-and-what-it-leaves-alone)Copy Anchor Link The overhead lands on the stages where guest code executes. File movement is host work and barely notices the architecture difference. | Build stage | Runs as aarch64 code under emulation | Why | | ----------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------ | | COPY and ADD | No | BuildKit moves files on the host and executes no guest instructions | | Base image pull and layer unpack | No | Registry transfer and extraction are host work | | RUN apt-get install | Yes | Package post-install scripts and dpkg execute as aarch64 binaries | | Compilation (gcc, cargo build, go build with cgo, node-gyp) | Yes | The compiler itself is an aarch64 binary, so every instruction it runs is translated | | A test suite run inside the build | Yes | The language runtime and the test binary both execute under translation | | Layer packing and registry push | No | Compression and upload run on the host | Docker's multi-platform documentation makes the same split: emulation is slower than a native build, and compute-heavy work such as compilation carries most of that difference ([Docker multi-platform documentation](https://docs.docker.com/build/building/multi-platform/)). A Dockerfile that copies a prebuilt binary into a slim base image sees little of it. A Dockerfile that compiles from source and then runs a test suite sees all of it. Two secondary effects show up in emulated builds. Host-level probes report the x86-64 host, so any autodetection step that branches on CPU flags or core count takes a path the target hardware never runs. Runtimes that generate machine code at run time behave differently under translation, which is where JIT-heavy language runtimes and native extension builds tend to surprise people. The [QEMU emulation glossary entry](/glossary/qemu-emulation) covers the mechanism in more detail. Cross-compilation is the way to keep the build on the x64 runner without paying translation at all. The Dockerfile uses `--platform=$BUILDPLATFORM` on the build stage, reads `TARGETARCH`, and invokes a cross toolchain, so the compiler runs as native x86-64 code and emits aarch64 objects ([Docker multi-platform documentation](https://docs.docker.com/build/building/multi-platform/)). It needs a Dockerfile written for it, and it produces no aarch64 test signal. ### [Pricing a native job against a longer emulated one](#pricing-a-native-job-against-a-longer-emulated-one)Copy Anchor Link Rates come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. An emulated build bills on the x64 label it runs on, and a native build bills on the ARM64 label. | Wall clock of the build job | On warp-ubuntu-latest-x64-8x at $0.016 per minute | On warp-ubuntu-latest-arm64-8x at $0.012 per minute | | --------------------------- | ------------------------------------------------- | --------------------------------------------------- | | 5 minutes | $0.080 | $0.060 | | 10 minutes | $0.160 | $0.120 | | 20 minutes | $0.320 | $0.240 | | 40 minutes | $0.640 | $0.480 | Compare across rows: the emulated job and the native job sit on different rows, because the emulated one runs a longer clock through the compute-heavy stages. Time your own Dockerfile once on each route and the comparison is minutes times rate. The full ARM64 line runs from `warp-ubuntu-latest-arm64-2x` at $0.003 per minute to `warp-ubuntu-latest-arm64-32x` at $0.048 per minute, so a native build can also buy back time by taking a larger size. Against GitHub-hosted hardware at the same shape: `warp-ubuntu-latest-arm64-8x` (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB), which is 14 percent lower list price. GitHub list price checked on 2026-08-13 in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), with shapes from the [GitHub-hosted runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). ### [The remote Docker builder route](#the-remote-docker-builder-route)Copy Anchor Link The remote builder removes the architecture question from the runner entirely: the job keeps its x64 label, the build is sent to a builder virtual machine that carries a persistent layer cache, and the builder profile decides which architectures are available ([Docker builders documentation](/docs/ci/docker-builders)). Four operational facts govern that route, from the [Docker builders documentation](/docs/ci/docker-builders). Builder profiles carry amd64, arm64, and multi-architecture options, and an arm64 build against a profile without arm64 enabled fails with `exec format error`. A multi-architecture build runs each architecture on a separate builder instance, producing one session per architecture, and each session bills independently. The 16 vCPU, 32 GB, 100GB disk profile is $0.06 per minute per session, and arm64 and multi-architecture profiles cap at 64 vCPU. The runner and the builder are separate resources and both are billed, so the total for a job is runner minutes plus builder session minutes. The [QEMU versus native ARM64 guide](/guides/qemu-vs-native-arm64-builds) works through which of the three routes fits which Dockerfile shape. ### [When emulation is still the right call](#when-emulation-is-still-the-right-call)Copy Anchor Link Emulation earns its place when the arm64 image is a release artifact rather than a test target, when the build is a handful of `COPY` steps over a prebuilt binary, or when you need one arm64 image today and rewriting the pipeline can wait. It is a poor place to run a test suite, because assertions execute under translation on a host whose CPU reports the wrong architecture, and the binary you assert against differs from the one running in production on aarch64 hosts. ## [Related Questions](#related-questions)Copy Anchor Link **Which build stages get slower under QEMU emulation?** The stages where guest code executes: package installs, compilation, and any test suite run inside the build. `COPY` and `ADD` steps, base image pulls, layer packing, and the registry push run on the host and execute no aarch64 instructions ([Docker multi-platform documentation](https://docs.docker.com/build/building/multi-platform/)). **Do I still need `docker/setup-qemu-action` on a native ARM64 runner?** No. The runner is already aarch64, so the binfmt handlers have nothing to translate for a `linux/arm64` target. Delete the step and set `runs-on` to an ARM64 label from the [Linux ARM64 runner catalog](/runners/linux-arm64), such as `warp-ubuntu-latest-arm64-8x`. **Why does my arm64 build fail with `exec format error` on a remote Docker builder?** The builder profile does not have arm64 enabled. Enable both architectures on the profile before setting `platforms`, and each architecture then runs on its own builder instance, producing one session per architecture ([Docker builders documentation](/docs/ci/docker-builders)). **Can I emulate arm64 to build the image and still test on real ARM64 hardware?** Yes, and that split is common. Build and push the image from the x64 job, then add a second job on an ARM64 label that pulls the pushed digest and runs the suite. The [QEMU versus native ARM64 guide](/guides/qemu-vs-native-arm64-builds) covers the handoff. Pick an ARM64 label from the [Linux ARM64 runner catalog](/runners/linux-arm64), price the change against your own build minutes on the [pricing page](/pricing), and read the [QEMU emulation glossary entry](/glossary/qemu-emulation) before you decide which stages to emulate. --- # Can I Cache a Built Image Instead of Rebuilding It? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-cache-a-docker-image-instead-of-rebuilding-it Description: Yes. Push the image once and pull it by digest in later jobs, which usually beats saving a tar through the cache. The byte math, the YAML, the exceptions. # Can I Cache a Built Image Instead of Rebuilding It? Yes. Push the image once and pull it by digest in later jobs, which usually beats saving a tar through the cache. The byte math, the YAML, the exceptions. Yes, and the mechanism that works best is a registry rather than the cache: push the image once in the build job and pull it by digest in every later job, which is usually cheaper than saving and loading a tar through the cache. The push is work the pipeline already does when it deploys the image, the pull moves compressed blobs, and the digest guarantees that the artifact the test job runs is byte for byte the artifact the build job produced. ## [Answer](#answer)Copy Anchor Link Three mechanisms can get a built image into a downstream job, and they move very different amounts of data. | Approach | Bytes into each downstream job | Extra work in the build job | Artifact identity | | ---------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------- | | Registry push, then pull by digest | Compressed layer blobs the host does not already hold | One push, which a deploying pipeline already runs | Exact, pinned by digest | | docker save tar through the cache or artifacts | Compressed archive, then docker load unpacks the uncompressed image to disk | docker save, compress, upload | Exact, but the archive duplicates bytes the registry can already serve | | Rebuild from a layer cache | Layer cache traffic plus the steps that miss | None | Not guaranteed identical to the build job's image | Take a concrete image: 1.2 GB of uncompressed layers on disk and 420 MB of compressed blobs in the registry. Run `docker manifest inspect` against your own tag and sum the layer sizes to replace those two numbers with real ones. A downstream job on the registry path downloads 420 MB and uploads nothing. The same job on the tar path downloads a comparable archive and then pays `docker load` to expand 1.2 GB back onto the disk, and the build job paid `docker save` plus a compress plus an upload of that archive before any downstream job started. Fan the pipeline out to a three-way test matrix and the registry path still uploads once, while the tar path has moved its archive four times. The third row is the one that quietly breaks release pipelines. Rebuilding from a layer cache in the second job produces a new image, and two builds of the same Dockerfile are only bit identical when every step is reproducible. Timestamps, package index resolution, and generated files all move the digest. What you tested is then not provably what you shipped, which is the whole reason an [image digest](/glossary/image-digest) exists as a pinning tool and why [build once, deploy many](/guides/build-once-deploy-many-github-actions) treats a single build as the release artifact. ## [Detail](#detail)Copy Anchor Link ### [The workflow](#the-workflow)Copy Anchor Link The build job publishes the image and exports its digest as a job output. Downstream jobs read that output through the `needs` context, documented in [GitHub's job output reference](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/passing-information-between-jobs), and pull the digest rather than the tag. Copy Text ``` name: build-and-test on: [push] permissions: contents: read packages: write jobs: build: runs-on: warp-ubuntu-latest-x64-4x outputs: digest: ${{ steps.build.outputs.digest }} steps: - uses: actions/checkout@v5 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push id: build uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} profile-name: "super-fast-builder" test: needs: build runs-on: warp-ubuntu-latest-x64-4x strategy: matrix: suite: [unit, integration, e2e] steps: - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Run the suite against the exact built image run: | docker run --rm \ ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }} \ ./run-tests.sh ${{ matrix.suite }} ``` `Warpbuilds/build-push-action@v6` is a drop-in replacement for `docker/build-push-action`, which publishes the `digest` output this workflow reads, per the [action's repository](https://github.com/docker/build-push-action). The `profile-name` input routes the build to a WarpBuild remote Docker builder, described in the [Docker builders documentation](/docs/ci/docker-builders); those builders hold a persistent layer cache on a dedicated VM, so the build job is not starting cold either. On WarpBuild runners the action needs no API key, and on other runners it takes one through the `api-key` input. Three details make this workflow behave: * `permissions: packages: write` on the build job, because the default token cannot publish to the registry without it. * The digest, not the tag, in `docker run`. A tag can be moved by a later push; a digest cannot. * Login in every job that pulls, since each job is a fresh machine. ### [When the saved tar still wins](#when-the-saved-tar-still-wins)Copy Anchor Link Three situations keep the tar path in play. The image must not be published. A scan-only or lint-only job on a build that should never reach a registry has nowhere to push, and a tar through the cache is the remaining transport. The workflow runs on a pull request from a fork. [GitHub gives fork pull request workflows a read-only token and withholds secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#permissions-for-the-github%5Ftoken), so registry write is unavailable and a saved archive is the only way to hand the image to a second job. The image is small. Below a few hundred megabytes the save, compress, upload, download, and load sequence is short enough that the operational simplicity of not managing registry credentials is worth more than the bytes. ### [The size at which it stops winning](#the-size-at-which-it-stops-winning)Copy Anchor Link The ceiling decides it. GitHub caps the combined size of all caches in a repository at 10 GB by default and evicts in least recently used order once the total passes the cap, with any entry unused for 7 days deleted regardless, per [GitHub's caching documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows), checked on 2026-08-13\. An image archive is large enough to collide with that ceiling on its own. WarpBuild cache storage bills at $0.20 per GB-month and cache write or restore operations at $0.0001 each, from the [pricing page](/pricing), checked on 2026-08-13. | Archive size | Entries kept, one per active branch | WarpBuild cache storage per month | Total against GitHub's 10 GB ceiling | | ------------ | ----------------------------------- | --------------------------------- | ------------------------------------ | | 0.5 GB | 10 | $1.00 | 5 GB, half the ceiling | | 1 GB | 10 | $2.00 | 10 GB, at the ceiling | | 2 GB | 5 | $2.00 | 10 GB, at the ceiling | | 2 GB | 10 | $4.00 | 20 GB, evicting continuously | A 2 GB archive stops fitting past five active branches, and the failure is not an error. The restore step logs a miss, the downstream job rebuilds or fails on a missing image, and nothing in the run points at eviction. Storing image archives as artifacts instead moves the problem rather than solving it: artifacts carry a default 90-day retention and bill storage, per [GitHub's artifact documentation](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/remove-workflow-artifacts), and they cannot be restored by key from a later workflow the way a cache entry can. If you keep the tar path deliberately, the [WarpBuild cache](/docs/ci/features/caching) is a drop-in replacement for `actions/cache@v4` with no repository size ceiling, which removes the eviction column from the table above and leaves only the storage cost. The [persistent caches guide](/guides/persistent-caches-github-actions) covers the wider question of which state belongs in a cache, in a snapshot, or in a registry. ### [Where this runs](#where-this-runs)Copy Anchor Link Remote Docker builder sessions start at $0.06 per minute for a 16 vCPU builder with 32 GB of RAM, and `warp-ubuntu-latest-x64-4x` is $0.008 per minute, both from the [pricing page](/pricing), checked on 2026-08-13. ## [Related Questions](#related-questions)Copy Anchor Link ### [Should I push the image to a registry or save it as a tar through the cache?](#should-i-push-the-image-to-a-registry-or-save-it-as-a-tar-through-the-cache)Copy Anchor Link Push it. A registry push happens once in the build job, and every downstream job pulls the compressed blobs by digest without a second upload. The tar path moves the same bytes up once and down again for every job that needs them, then unpacks the uncompressed image a second time with `docker load`. The [Docker builders documentation](/docs/ci/docker-builders) covers the build side, and [reducing Docker image pull time](/answers/how-do-i-reduce-docker-image-pull-time-in-github-actions) covers the pull side. ### [How do I pass an image digest from one job to another?](#how-do-i-pass-an-image-digest-from-one-job-to-another)Copy Anchor Link Declare a job output on the build job and read it through the `needs` context in the downstream job. The build-push-action sets a `digest` output, so `outputs.digest` maps to `steps.build.outputs.digest` and the test job pulls `image@${{ needs.build.outputs.digest }}`. [Image digest](/glossary/image-digest) defines what that value is and why a tag is not a substitute. ### [Does the GitHub Actions cache size limit apply to a saved image tar?](#does-the-github-actions-cache-size-limit-apply-to-a-saved-image-tar)Copy Anchor Link Yes. GitHub caps the combined size of all caches in a repository at 10 GB by default and evicts entries in least recently used order once the total passes the cap, so a 2 GB image archive written per branch stops fitting past five active branches. Checked on 2026-08-13 against GitHub's caching documentation. The [persistent caches guide](/guides/persistent-caches-github-actions) and [build once, deploy many](/guides/build-once-deploy-many-github-actions) cover the two ways out. --- # Can I Keep a Database Warm Between Jobs? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-keep-a-database-warm-between-github-actions-jobs Description: Yes. Snapshot a WarpBuild Ubuntu runner once the database is seeded, and later jobs boot with the data directory already on disk instead of importing it. # Can I Keep a Database Warm Between Jobs? Yes. Snapshot a WarpBuild Ubuntu runner once the database is seeded, and later jobs boot with the data directory already on disk instead of importing it. Yes, on WarpBuild Cloud Ubuntu runners. Seed the database once, capture the machine with the `WarpBuilds/snapshot-save` action after the data is loaded, and later jobs that request the same snapshot alias boot with the data directory already on disk, so they start the database process and run tests instead of importing the dump again ([snapshot runners documentation](/docs/ci/features/snapshot-runners)). ## [Answer](#answer)Copy Anchor Link The reason a database goes cold is the job boundary. Every GitHub Actions job runs on a freshly allocated ephemeral virtual machine whose storage is deleted when the runner terminates ([cloud runners documentation](/docs/ci/cloud-runners)), and a [service container](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidservices) declared under `services` is created from its image at job start and removed at job end. Both facts point the same way: the database process is new, its data directory is empty, and the schema plus the seed data have to be loaded again. A snapshot runner changes what is on the disk, not what is running. `WarpBuilds/snapshot-save` captures the runner VM disk at the point it runs, and a later job whose label carries `snapshot.key=` boots from that captured disk ([snapshot runners documentation](/docs/ci/features/snapshot-runners)). A database data directory written under `$HOME` or into a Docker named volume is part of that disk and comes back with the rows in it. Three properties decide how you write the workflow: * Nothing is running after a boot from a snapshot. The machine reboots, so no process is up and no port is listening. The database has to be started again, pointed at the data directory that was restored with the disk. * `/tmp` does not persist, because the directory is cleaned on reboot. A data directory staged in `/tmp` is gone; keep it under `$HOME`, in the workspace, or in `/var/lib/docker`. * The container record from the earlier job is still on the disk while no process is running, so a `docker run --name pg` in the warm job collides with the stale name unless the step removes it first. Platform scope is narrow. Snapshots apply to the Cloud Ubuntu part of that catalog. On macOS, Windows, and BYOC runners the snapshot labels are silently ignored, which means a mislabeled job produces no warning and simply runs cold. The [snapshot runners hub](/runners/snapshot) lists the Ubuntu labels that accept the feature. ### [What the two paths cost in job time](#what-the-two-paths-cost-in-job-time)Copy Anchor Link The numbers below are stated assumptions for a 4 GB compressed dump, not measurements. Substitute the durations from your own job log; the shape of the comparison is what carries over. The snapshot boot figure is from the [snapshot runners documentation](/docs/ci/features/snapshot-runners), which states that snapshot boots take 45 to 60 seconds. | Phase | Cold job that seeds every run | Job that boots from the snapshot | | ----------------------- | ----------------------------- | ---------------------------------------------- | | Runner boot penalty | none | 45 to 60 seconds | | Pull the database image | 40 seconds | already in the local image store | | initdb and first start | 20 seconds | 15 seconds against the restored data directory | | Restore the dump | 6 minutes 20 seconds | not run | | VACUUM ANALYZE | 50 seconds | not run | | Test suite | 4 minutes | 4 minutes | | Total | about 12 minutes | about 5 minutes 15 seconds | Two line items sit on top of runner minutes, both from the [pricing page](/pricing), checked on 2026-08-13: snapshot restore at $0.04 per job and snapshot storage at $0.025 per hour per snapshot. On `warp-ubuntu-latest-x64-8x` at $0.016 per minute, the restore fee is worth 2.5 minutes of runner time, and the snapshot boot costs about another minute, so an alias pays for itself only when it removes more than about 3.5 minutes of seeding work. At 600 pull request runs per month with one alias held live for 720 hours, the assumptions above price out as follows. | Line | Cold job that seeds every run | Job that boots from the snapshot | | -------------------------------- | ----------------------------- | -------------------------------- | | Minutes per run | 12.2 | 5.25 | | Runner minutes per month | 7,320 | 3,150 | | Runner cost at $0.016 per minute | $117.12 | $50.40 | | Snapshot restore fees | $0 | $24.00 | | Snapshot storage | $0 | $18.00 | | Total per month | $117.12 | $92.40 | Storage scales with live aliases rather than with runs, so two aliases held the same way is $36.00 per month instead of $18.00. ## [Detail](#detail)Copy Anchor Link ### [The workflow](#the-workflow)Copy Anchor Link The alias has to be decided before the runner is chosen, and it has to depend on the schema and the seed files. `hashFiles` reads the runner workspace, which does not exist yet when `runs-on` is evaluated, so a small first job checks out the repository, computes the hash, and publishes the label as a job output that the test job consumes. Copy Text ``` name: tests on: [pull_request, push] jobs: plan: runs-on: warp-ubuntu-latest-x64-2x outputs: label: ${{ steps.key.outputs.label }} alias: ${{ steps.key.outputs.alias }} steps: - uses: actions/checkout@v5 - id: key run: | hash=$(cat db/schema.sql db/seed/*.sql | sha256sum | cut -c1-12) echo "alias=pgdata-$hash" >> "$GITHUB_OUTPUT" echo "label=warp-ubuntu-latest-x64-8x;snapshot.key=pgdata-$hash" >> "$GITHUB_OUTPUT" test: needs: plan runs-on: ${{ needs.plan.outputs.label }} steps: - uses: actions/checkout@v5 - name: Check for a warm boot id: boot run: echo "warm=${WARPBUILD_SNAPSHOT_KEY:+true}" >> "$GITHUB_OUTPUT" - name: Start Postgres on the persisted data directory run: | docker rm -f pg >/dev/null 2>&1 || true mkdir -p "$HOME/pgdata" docker run -d --name pg -p 5432:5432 \ -e POSTGRES_PASSWORD=postgres \ -v "$HOME/pgdata:/var/lib/postgresql/data" \ postgres:16 until docker exec pg pg_isready -U postgres; do sleep 1; done - name: Seed the database if: steps.boot.outputs.warm != 'true' run: | psql "$DATABASE_URL" -f db/schema.sql for f in db/seed/*.sql; do psql "$DATABASE_URL" -f "$f"; done psql "$DATABASE_URL" -c 'VACUUM ANALYZE' env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres - name: Run the suite run: npm run test:integration env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres - name: Stop the database and remove credentials if: steps.boot.outputs.warm != 'true' run: | docker stop pg rm -rf $HOME/.ssh $HOME/.aws $HOME/.npmrc git clean -ffdx - name: Save the seeded machine if: steps.boot.outputs.warm != 'true' uses: WarpBuilds/snapshot-save@v1 with: alias: ${{ needs.plan.outputs.alias }} fail-on-error: false wait-timeout-minutes: 45 ``` Four details in that file are doing the work. `WARPBUILD_SNAPSHOT_KEY` lives on the runner process, and GitHub Actions does not read process-level variables into the `env` context on its own, so the `Check for a warm boot` step reads it from the shell and republishes it as the step output that the later `if:` conditions test. The `docker stop` before the save flushes the database to the data directory, so the disk that gets captured is consistent rather than mid-write. `fail-on-error: false` keeps a failed capture from turning a green pipeline red. And the bind mount under `$HOME` keeps the data out of `/tmp`, which would not survive. The second start needs no `initdb`: Postgres finds a populated `PGDATA` and comes up against it, which is why the warm column above pays 15 seconds instead of the import. ### [The invalidation rule](#the-invalidation-rule)Copy Anchor Link The alias is the cache key, and it has to change whenever the data inside it would be wrong. `pgdata-$(cat db/schema.sql db/seed/*.sql | sha256sum)` covers both halves: a migration that alters a table and a fixture edit that adds rows both produce a new alias. Requesting an alias that has no snapshot is a documented fallback rather than an error, so the first job on the new schema boots from the base image, seeds, and publishes the new alias for everyone behind it. Two lifetime rules belong in the same decision. Snapshots are deleted after 15 days ([snapshot runners documentation](/docs/ci/features/snapshot-runners)), so an alias that no run refreshes stops resolving and the next job runs cold. Storage bills per snapshot-hour, so a scheme that produces an alias per branch holds many live snapshots at once. Aliasing on the schema hash rather than on the branch keeps the count near the number of open migrations. [Database migrations on GitHub Actions](/solutions/database-migrations-github-actions) covers running the migration itself, which is the step that changes the hash. ### [What belongs in a snapshot](#what-belongs-in-a-snapshot)Copy Anchor Link A snapshot is readable by any job that names its alias. On a public repository, a contributor can name the alias in a pull request; on a private repository, WarpBuild provisions runners at the organization level, so a snapshot can reach other jobs in the organization. Seed data belongs in a snapshot; a production dump with customer records does not. Delete credentials in the step immediately before the save, as the workflow above does. Snapshot runners are one part of the WarpBuild product surface, alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger. When the seeding cost is small enough that the 3.5-minute threshold is not met, the plain `services` block is the simpler answer, and the [service containers guide](/guides/service-containers-github-actions) covers health checks, port mapping, and readiness gating for that route. ## [Related Questions](#related-questions)Copy Anchor Link **Does the GitHub Actions `services` key keep data between jobs?** No. A [service container](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidservices) is created from its image when the job starts and removed when the job ends, so every job gets an empty database and pays the seeding cost again. The data survives only if the data directory sits on a disk that outlives the job, which is what a snapshot runner provides. The [service containers guide](/guides/service-containers-github-actions) compares the two routes. **Can I keep a database warm on macOS, Windows, or BYOC runners?** No. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only, and a snapshot label on a macOS, Windows, or BYOC runner is silently ignored, so the job runs normally with no snapshot behavior and no error in the log ([snapshot runners documentation](/docs/ci/features/snapshot-runners)). The [snapshot runners hub](/runners/snapshot) lists the Ubuntu labels and their per-minute rates. **What happens to the warm database when the schema changes?** Put the hash of the schema and seed files in the snapshot alias. A schema change produces an alias that has no snapshot yet, the runner boots from the base image, the job seeds from scratch and saves under the new alias, and later jobs on that schema boot warm again. [Database migrations on GitHub Actions](/solutions/database-migrations-github-actions) covers ordering the migration against the test job. --- # Can I Keep Builds in the EU Only? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-keep-builds-in-the-eu-only Description: Yes for build data. US and EU are the regions WarpBuild names publicly, and BYOC puts the runner, its disk, and the cache bucket in an EU region you own. # Can I Keep Builds in the EU Only? Yes for build data. US and EU are the regions WarpBuild names publicly, and BYOC puts the runner, its disk, and the cache bucket in an EU region you own. ## [Answer](#answer)Copy Anchor Link Yes for the data a build touches, and the two paths that get you there are a hosted EU region on the enterprise tier or BYOC runners in an EU region of your own cloud account. US and EU are the regions WarpBuild names publicly, and BYOC gives the strongest version of the answer, because the runner instance, the disk under it, and the object storage bucket holding caches, logs, and artifacts are all resources in your own account, in a region fixed before any runner existed. An EU-only requirement decomposes into three separate questions once a reviewer writes it down: where the runner virtual machine executes, where build data comes to rest, and what keeps running outside that region. The table below answers the first two for each path. Every row comes from the [BYOC setup documentation](/docs/ci/byoc) or the WarpBuild runner catalog, checked on 2026-08-13. | Path | Where the runner VM executes | Where caches, artifacts, and logs land | How you turn it on | | ---------------- | ---------------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------- | | Hosted EU region | WarpBuild infrastructure in the EU, on the enterprise tier | The same EU region as the runner | Ask support to deploy your org on region-specific infrastructure | | BYOC on AWS | An EC2 instance in an EU region of your AWS account | An S3 bucket in the stack region | Connect the account, create a stack in an EU region, create custom runners | | BYOC on GCP | A Compute Engine instance in an EU region of your project | A Cloud Storage bucket in the stack region | The same three steps against a GCP project | | BYOC on Azure | East US only at present | Storage in the stack region | Email [support@warpbuild.com](mailto:support@warpbuild.com) for regions beyond East US | BYOC runs on AWS, GCP, and Azure, so two of the three clouds carry an EU placement today. One documented exception belongs in the scope statement: region-specific infrastructure does not apply to macOS instances, so Xcode jobs keep running on the shared macOS fleet. Teams usually resolve that by scoping the EU commitment to the repositories that carry regulated data, and by keeping macOS work in repositories outside that scope. The [security review checklist for GitHub Actions runners](/enterprise/ci-security-review) is where the rest of the questionnaire gets answered. ## [Detail](#detail)Copy Anchor Link ### [The hosted EU region](#the-hosted-eu-region)Copy Anchor Link On enterprise plans, cloud runners can be deployed on region-specific infrastructure, and the data you choose remains in that specific region. Nothing changes in the workflow file, because placement is configured at the account level rather than per job. The same `warp-` labels keep resolving, and the fleet behind them sits in the EU region your org was deployed on. The page on [EU data residency for GitHub Actions runners](/enterprise/data-residency-eu) covers the path-by-path detail for that setup. ### [BYOC pins the region at stack creation](#byoc-pins-the-region-at-stack-creation)Copy Anchor Link BYOC is three steps in the [BYOC setup documentation](/docs/ci/byoc). Connect the cloud account, which creates the IAM role or service account WarpBuild uses. Create a stack, which is a group of infrastructure components in one region of your account: VPC, subnets, and the object storage bucket used for cache storage, container image layer cache, logs, and other workflow artifacts. Then create custom runners with the instance types, disks, and IP configuration you want. The documentation states that the stack name, object storage location, and region cannot be changed after creation. For a reviewer, that sentence carries more weight than a policy statement, because the placement becomes a property of created infrastructure rather than a toggle somebody can flip later. Two artifacts you generate yourself close the question: the stack region and bucket location read out of your own cloud console, and the workflow files showing which labels each repository uses. If you need EU and US footprints side by side, each one is a separate stack, and [running BYOC runners across multiple regions](/byoc/multi-region) covers how those stacks and labels are organized. One caveat stays plain. BYOC on Azure is currently enabled for East US only, and additional regions go through [support@warpbuild.com](mailto:support@warpbuild.com), so an Azure-only estate has no EU BYOC placement today without that conversation. ### [What still runs outside the region you pick](#what-still-runs-outside-the-region-you-pick)Copy Anchor Link Two things sit outside the EU boundary in every path, and naming them up front is what keeps a review short. * **The WarpBuild control plane.** It queues jobs, starts and destroys runner VMs, and meters usage. The data it holds is orchestration metadata: which job is queued, which label it asked for, when a VM started, when it terminated, and the counters behind your invoice. * **GitHub.** The repository, the workflow definitions, and the secrets stay with GitHub, because a runner is a machine that polls GitHub and streams results back to it. The term itself is worth agreeing on before the meeting, since residency and processing get used interchangeably in questionnaires. See [data residency, defined for GitHub Actions](/glossary/data-residency) for the vendor-neutral version. ### [What the workflow file looks like](#what-the-workflow-file-looks-like)Copy Anchor Link Hosted jobs keep the labels they already have. A BYOC job uses the runner ID from the custom runners page, always prefixed with `warp-custom-`, which makes the placement readable straight out of the workflow file. .github/workflows/ci.yml Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - run: make test regulated-build: runs-on: warp-custom-eu-release-16x steps: - uses: actions/checkout@v4 - name: Build release artifacts run: make release ``` Naming stacks and runners after their region and purpose, as `eu-release` does above, turns the workflow file into the record an auditor asks for. ### [What the EU path costs](#what-the-eu-path-costs)Copy Anchor Link Rates come from the WarpBuild [pricing page](/pricing) and runner catalog, checked on 2026-08-13. | Label | Shape | Hosted rate per minute | BYOC WarpBuild fee per minute | | --------------------------- | ------------- | ---------------------- | ----------------------------- | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | $0.002 | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | $0.002 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $0.002 | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016 | $0.002 | On BYOC, instance time, disks, and object storage land on your own cloud bill at your account rates, and the WarpBuild fee is the $0.002 per minute above. A 20,000 minute month on `warp-ubuntu-latest-x64-4x` therefore costs $160 hosted, or $40 in WarpBuild fees on BYOC plus whatever your EU region charges for the equivalent instance hours and storage. ### [Evidence a reviewer can collect](#evidence-a-reviewer-can-collect)Copy Anchor Link Request the report and the supporting security documentation there. Three controls from the [WarpBuild security documentation](/docs/ci/security) usually get quoted alongside the region answer, because a reviewer asking about the EU is also asking how long build data lives anywhere. Each runner runs in its own virtual machine, created on demand and destroyed after each build. Each runner has its own encrypted storage volume created and destroyed the same way, and cache storage is encrypted and reachable only by your runner. WarpBuild does not access or store any build secrets, which stay in your source code repository and reach only the runner environment. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can I keep GitHub Actions builds in the EU only?](#can-i-keep-github-actions-builds-in-the-eu-only)Copy Anchor Link Yes for the data a build touches. US and EU are the regions WarpBuild names publicly. On the enterprise tier, hosted cloud runners can be deployed on region-specific infrastructure so the data you choose stays in the EU, and on BYOC the runner instance and the object storage bucket sit in an EU region of your own AWS or GCP account. The [EU data residency page](/enterprise/data-residency-eu) covers both paths in detail. ### [Which EU path holds up best in a security review?](#which-eu-path-holds-up-best-in-a-security-review)Copy Anchor Link BYOC. The instance, the disk under it, and the bucket holding caches, container image layers, logs, and workflow artifacts are resources in your own cloud account, in a region fixed when the stack was created, visible in your own console and on your own bill. The [BYOC setup documentation](/docs/ci/byoc) records the region constraint, and [running BYOC runners across multiple regions](/byoc/multi-region) covers estates that need more than one. ### [What still runs outside the EU region I pick?](#what-still-runs-outside-the-eu-region-i-pick)Copy Anchor Link The WarpBuild control plane, which queues jobs, starts and destroys runner VMs, and meters usage, plus GitHub, which holds the repository, the workflow files, and the secrets. Region-specific infrastructure also does not apply to macOS instances. Walk both boundaries with the [security review checklist for GitHub Actions runners](/enterprise/ci-security-review). --- # Can I Keep DerivedData Between Runs? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-keep-derived-data-between-github-actions-runs Description: Yes. Cache the DerivedData directory keyed on your lockfile and Xcode version, or keep prepared machine state. What restores safely and what breaks. # Can I Keep DerivedData Between Runs? Yes. Cache the DerivedData directory keyed on your lockfile and Xcode version, or keep prepared machine state. What restores safely and what breaks. Yes, DerivedData can survive from one GitHub Actions run to the next, by one of two mechanisms: storing the directory as a cache entry keyed on your lockfile and toolchain, or keeping the whole prepared machine state so the disk itself comes back. The two behave differently on invalidation, and only part of a DerivedData tree is safe to restore at all. ## [Answer](#answer)Copy Anchor Link A GitHub Actions runner is ephemeral, so `xcodebuild` starts against an empty DerivedData tree on every run and recompiles everything. Two mechanisms put state back. **Cache the directory.** Point `xcodebuild` at an explicit `-derivedDataPath` inside the workspace, then restore and save the useful subdirectories with a cache action. On WarpBuild runners this is `WarpBuilds/cache@v1`, a drop-in replacement for `actions/cache@v4` that takes the same `path`, `key`, and `restore-keys` inputs and produces the same `cache-hit` output. The [WarpBuild caching documentation](/docs/ci/features/caching) covers the action and its subactions, and the cache is available on all WarpBuild runners. **Keep the prepared machine.** A [snapshot runner](/runners/snapshot) boots a job from a saved image of an earlier run's virtual machine, so everything the earlier run left on disk is present at step one, including files no cache step was ever configured to track. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners; on macOS runners the snapshot labels are silently ignored and the job runs normally, per the [snapshot runners documentation](/docs/ci/features/snapshot-runners). For an Xcode job on macOS, the cache path is the mechanism that applies today, and the machine-state path applies to Linux jobs in the same pipeline. Labels, sizes, and rates for the macOS fleet are on the [macOS runners page](/runners/macos). ## [Detail](#detail)Copy Anchor Link ### [The two mechanisms side by side](#the-two-mechanisms-side-by-side)Copy Anchor Link | Dimension | Cached DerivedData directory | Prepared machine state | | --------------- | ------------------------------------------------ | ------------------------------------------- | | What comes back | Only the paths listed in path | The whole runner disk as saved | | Addressed by | An immutable key string, usually a lockfile hash | A mutable alias in the runs-on label | | Miss behavior | restore-keys prefix match, then empty | Boots from the base image, job proceeds | | Invalidation | New key writes a new entry; old entries stay | Next save overwrites the alias | | Expiry | 7 days after last use | 15 days | | Platform | All WarpBuild runners, including macOS | Cloud Ubuntu runners only | | Cost lines | $0.20 per GB-month, $0.0001 per operation | $0.04 per restore, $0.025 per snapshot-hour | Rates come from the [pricing page](/pricing), checked on 2026-08-13, and both cache lines are free on BYOC. The invalidation row is the one that decides most designs. A cache key is immutable once written, so a bad restore is repaired by changing the key and the next run starts clean. A snapshot alias compounds, so a bad run poisons the alias until you rename it. One more constraint applies to the cache path. A cache entry carries a version derived from the runner operating system and the cached paths, so an entry written on `warp-macos-14-arm64-6x` does not restore on `warp-ubuntu-latest-x64-4x`, as the [caching documentation](/docs/ci/features/caching) records. Keep the runner label stable across the workflows that write and read a DerivedData entry. ### [What is safe to restore](#what-is-safe-to-restore)Copy Anchor Link DerivedData is several trees with different lifetimes under one directory, and restoring all of them is what produces stale-build failures. * `Build/Products` holds the built `.app`, `.xctest`, and framework bundles. Safe, and the piece that makes a `build-for-testing` then `test-without-building` split worth doing. * `Build/Intermediates.noindex` holds object files, generated headers, and `.swiftmodule` outputs. Safe when the checkout path is identical between save and restore, because these files record absolute paths. * `ModuleCache.noindex` holds precompiled clang modules that are validated against the compiler and the headers they were built from. Restore it after an Xcode change and the build fails on module validation rather than falling back to a rebuild. Leave it out. * `Index.noindex` is the editor index store. Nothing in a command line build reads it, and it is often the largest tree in DerivedData. * `Logs` is build and test logs. No value to a later run. The absolute path rule is the one that catches teams first. DerivedData records the path it was built at, so `-derivedDataPath` has to resolve to the same location on the run that saves and the run that restores. Put it under `${{ github.workspace }}` and the runner keeps it stable. Put the Xcode version in the key as well. The macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes on top of the upstream GitHub macOS 26 image, while GitHub's upstream macOS 27 runner image is in beta; a dedicated macOS 27 image follows once that image is released, per the [cloud runners documentation](/docs/ci/cloud-runners). A pipeline that moves between macOS images changes compilers, so an entry from the old compiler should miss rather than restore. ### [The workflow shape](#the-workflow-shape)Copy Anchor Link Copy Text ``` name: ios-build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-macos-latest-arm64-6x env: DERIVED_DATA: ${{ github.workspace }}/DerivedData steps: - uses: actions/checkout@v5 - name: Record toolchain id: toolchain run: | echo "xcode=$(xcodebuild -version | head -1 | tr ' ' '-')" >> "$GITHUB_OUTPUT" - name: Restore DerivedData uses: WarpBuilds/cache@v1 with: path: | DerivedData/Build/Products DerivedData/Build/Intermediates.noindex key: dd-${{ steps.toolchain.outputs.xcode }}-${{ hashFiles('**/Package.resolved') }}-${{ github.sha }} restore-keys: | dd-${{ steps.toolchain.outputs.xcode }}-${{ hashFiles('**/Package.resolved') }}- dd-${{ steps.toolchain.outputs.xcode }}- - name: Build for testing run: | xcodebuild build-for-testing \ -scheme App \ -destination 'platform=iOS Simulator,name=iPhone 17' \ -derivedDataPath "$DERIVED_DATA" ``` The key has three segments and the `restore-keys` ladder drops one segment at a time. The primary key ends in the commit SHA, so every run writes a fresh entry and the entry a pull request restores is the closest previous build with the same toolchain and the same resolved packages. The second rung keeps a build warm across commits. The third keeps it warm across a dependency bump, which restores intermediates the new package graph will partly discard, still cheaper than a full recompile. Guard nothing on `cache-hit` here. That output is `true` only on an exact primary key match, and with a SHA in the key it is almost always `false` even when the second rung restored several gigabytes. The build step reads the restored tree either way. ### [What it costs to keep](#what-it-costs-to-keep)Copy Anchor Link A worked month, with the assumptions stated so you can substitute your own: 880 workflow runs, a DerivedData entry of 4 GB kept warm for the whole month, one restore and one save per run, and `warp-macos-latest-arm64-6x` at $0.08 per minute from the [pricing page](/pricing), checked on 2026-08-13. | Line | Amount | | -------------------------------------------------- | ------- | | Cache storage, 4 GB for one month | $0.80 | | Cache operations, 1,760 at $0.0001 | $0.18 | | Total cache charge | $0.98 | | Runner minutes returned by 4 minutes saved per run | 3,520 | | Runner cost of those minutes at $0.08 | $281.60 | Treat the four minutes as an input to substitute: run the build once cold and once warm on your own repository, then put your own number in the table. The shape of the trade holds at almost any value, because a cache entry that size costs under a dollar a month while macOS minutes are the expensive line. The [Xcode build times guide](/guides/xcode-build-times-github-actions) covers the other levers on the same job. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does caching DerivedData work on WarpBuild macOS runners?](#does-caching-deriveddata-work-on-warpbuild-macos-runners)Copy Anchor Link Yes. The WarpBuild cache is available on all WarpBuild runners, and `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` with the same `path`, `key`, and `restore-keys` inputs. Cache entries carry a version derived from the runner OS and the cached paths, so an entry written on a macOS runner never restores on a Linux runner. The [caching documentation](/docs/ci/features/caching) has the full action reference, and the [DerivedData cache guide](/guides/xcode-derived-data-cache-github-actions) walks the setup end to end. ### [Can snapshot runners keep DerivedData for an Xcode job?](#can-snapshot-runners-keep-deriveddata-for-an-xcode-job)Copy Anchor Link No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. On macOS runners the snapshot labels are silently ignored and the job runs normally with no snapshot behavior and no error, per the [snapshot runners documentation](/docs/ci/features/snapshot-runners). Use the cache path for Xcode jobs and read [snapshot runners](/runners/snapshot) for the Linux jobs in the same pipeline. ### [Which DerivedData subdirectories should stay out of the cache?](#which-deriveddata-subdirectories-should-stay-out-of-the-cache)Copy Anchor Link `ModuleCache.noindex`, `Index.noindex`, and `Logs`. The module cache is validated against the compiler and the headers it was built from, which turns a toolchain change into a build failure instead of a rebuild. The index store and the logs add gigabytes that no command line build reads. Keep `Build/Products` and `Build/Intermediates.noindex`, and see the [macOS runners page](/runners/macos) for the image each label carries. ### [What does keeping DerivedData between runs cost?](#what-does-keeping-deriveddata-between-runs-cost)Copy Anchor Link Cache storage is $0.20 per GB-month and each cache write or restore is $0.0001 on hosted runners, both free on BYOC, from the [pricing page](/pricing), checked on 2026-08-13. --- # Can I Mix GitHub-Hosted and Managed Runners? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-mix-github-hosted-and-self-hosted-runners Description: 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. # 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: 2026-08-13 ## [Answer](#answer)Copy Anchor Link 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: Copy Text ``` 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](/docs/ci/cloud-runners). ## [Detail](#detail)Copy Anchor Link ### [How runs-on resolves, one job at a time](#how-runs-on-resolves-one-job-at-a-time)Copy Anchor Link `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: Copy Text ``` 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](#a-matrix-that-moves-the-heavy-jobs-and-leaves-the-short-ones-alone)Copy Anchor Link 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. Copy Text ``` 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](/guides/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](#the-migration-pattern-move-one-job-measure-then-move-the-rest)Copy Anchor Link 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](/docs/ci/features/reports) 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](#what-the-split-costs)Copy Anchor Link Every WarpBuild rate below comes from the [cloud runners documentation](/docs/ci/cloud-runners). Every GitHub rate comes from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub-hosted runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13. | WarpBuild label | Shape | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | List price difference | | -------------------------- | --------------- | -------------------- | --------------------------------------------------- | ----------------- | --------------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | ubuntu-latest, 2 vCPU, 8 GB on private repositories | $0.006 | 33 percent lower list price | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent lower list price | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent lower list price | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent lower list price | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064 | 32-core Linux larger runner | $0.082 | 22 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. | Job | Minutes per run | Monthly minutes | Runner in the mixed fleet | Mixed fleet cost | Same job on the GitHub-hosted equivalent | | ----------- | --------------- | --------------- | -------------------------- | ---------------- | ---------------------------------------- | | lint | 2 | 1,600 | ubuntu-latest | $9.60 | $9.60 | | unit tests | 9 | 7,200 | warp-ubuntu-latest-x64-8x | $115.20 | $158.40 | | integration | 6 | 4,800 | warp-ubuntu-latest-x64-16x | $153.60 | $201.60 | | **Total** | **13,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](/pricing). ### [Where a mixed fleet needs attention](#where-a-mixed-fleet-needs-attention)Copy Anchor Link 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](/docs/ci/cloud-runners) 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. ## [Related Questions](#related-questions)Copy Anchor Link ### [Do I need a separate runner group to mix runner pools?](#do-i-need-a-separate-runner-group-to-mix-runner-pools)Copy Anchor Link 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](/guides/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?](#how-does-github-match-a-job-to-the-right-runner)Copy Anchor Link 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](/answers/how-do-i-change-the-runner-in-a-github-actions-workflow). ### [Can I move one job at a time instead of the whole workflow?](#can-i-move-one-job-at-a-time-instead-of-the-whole-workflow)Copy Anchor Link 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](/docs/ci/features/reports) 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?](#do-caches-and-artifacts-carry-across-the-two-runner-pools)Copy Anchor Link 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](/answers/what-is-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](/docs/ci/cloud-runners), price the change against your own minute counts on the [pricing page](/pricing), and read the result out of the [Reports page](/docs/ci/features/reports) before moving the next job. --- # Can I Restrict Outbound Traffic From Runners? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-restrict-outbound-traffic-from-byoc-runners Description: Yes. Narrow the egress rules on your BYOC runner security group, as long as runners keep reaching the WarpBuild control plane, GitHub, and your registries. # Can I Restrict Outbound Traffic From Runners? Yes. Narrow the egress rules on your BYOC runner security group, as long as runners keep reaching the WarpBuild control plane, GitHub, and your registries. Yes. Narrowing outbound rules on BYOC runners is documented WarpBuild guidance, and the constraint is that the runner still has to reach the destinations the platform documents: `api.warpbuild.com` for registration and lifecycle, `github.com` and `api.github.com` for git and GitHub Actions API traffic, and the package registries your builds pull from ([BYOC AWS security hardening](/docs/ci/byoc/aws/security)); cover those in the allowlist and the runner behaves exactly as it does under the default allow-all egress rule. ## [Answer](#answer)Copy Anchor Link BYOC runners launch inside your own cloud account, so the firewall in front of them is yours to write. BYOC runs on AWS, GCP, and Azure ([BYOC on AWS](/byoc/aws)). On AWS the rules live on the security group the CloudFormation stack attaches to every runner instance, which the stack either provisions for you in create mode or imports from an existing security group you select ([BYOC AWS security hardening](/docs/ci/byoc/aws/security)). The default posture the stack ships with is deliberately permissive on the way out and already closed on the way in: | Stack resource | Default configuration | | -------------- | ------------------------------------------------------------------------------------- | | Security group | All egress allowed to 0.0.0.0/0; ingress allowed from VPC subnet CIDRs only | | Network ACLs | Default VPC ACLs, which allow all traffic | | Subnets | Public subnets, with optional private subnets when you need stable outbound addresses | Rows come from the [BYOC AWS security hardening guide](/docs/ci/byoc/aws/security), checked on 2026-08-13. Tightening the egress side means replacing the single allow-all rule with a list. This is the set that has to survive the edit: | Destination | What depends on it | | ------------------------------------------------------------- | --------------------------------------------------------------------- | | api.warpbuild.com | Runner registration and lifecycle against the WarpBuild control plane | | github.com | Git operations, including actions/checkout | | api.github.com | GitHub Actions API calls the runner makes during a job | | WarpBuild AWS S3 over HTTPS | Runner telemetry and system logs | | Your package registries and any external service a step calls | Dependency installs, image pulls from outside AWS, deploy targets | Rows come from the [BYOC AWS security hardening guide](/docs/ci/byoc/aws/security), checked on 2026-08-13\. Two categories do not need a public rule: cache traffic to the stack S3 bucket and ECR image pulls are routed by the stack inside the AWS network, which the [BYOC AWS config reference](/docs/ci/byoc/aws/config) covers along with the stack template version required for ECR logins from public subnets. ## [Detail](#detail)Copy Anchor Link ### [What a security group can actually match](#what-a-security-group-can-actually-match)Copy Anchor Link Security group rules match IP ranges and prefix lists, so the destination list above cannot be entered as written. Two paths get you from a hostname list to an enforceable rule set. The first is address ranges. GitHub publishes the ranges its services use through the REST meta endpoint ([GitHub REST API meta documentation](https://docs.github.com/en/rest/meta/meta)), which covers the `github.com` and `api.github.com` side. Those ranges change, so a list built from them needs a scheduled refresh and a review step; a stale allowlist fails as a build outage rather than as an alert. The second is a proxy or a name-aware firewall in front of the runner subnets. The security group then allows egress to the proxy only, and the hostname policy lives in one place you can edit without touching the stack. Teams that want to allow `registry.npmjs.org` but nothing else on the public internet usually end up here, because package registries publish no stable address ranges to allowlist against. ### [The failure signature when a destination is blocked](#the-failure-signature-when-a-destination-is-blocked)Copy Anchor Link Egress mistakes are diagnosable because each blocked destination fails at a different point in the job lifecycle. Use this to work out which rule you got wrong: | Blocked destination | What you observe | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | api.warpbuild.com | The EC2 instance launches and shows up in your account under the warp- name pattern, then the GitHub Actions job stays queued with an empty log until it times out, because no runner ever registered | | github.com | The runner picks up the job and the first steps run, then actions/checkout stalls and fails on a connection timeout during the git fetch | | api.github.com | Registration and job hand-off break the same way as a control plane block, and steps that call the GitHub API, such as release asset uploads, fail with connection timeouts | | WarpBuild AWS S3 over HTTPS | Jobs complete normally and runner logs and telemetry go missing, so the next real failure is harder to diagnose | | A package registry | A single step fails with the package manager's own connect or DNS timeout while the rest of the job stays healthy | The instance naming pattern in the first row comes from the [BYOC AWS config reference](/docs/ci/byoc/aws/config), which documents `warp-*` for EC2 instances and EBS volumes and `tmpl-warp-*` for launch templates. That naming is what lets you confirm an instance launched at all before you start reading rules. The dividing line is worth keeping in mind. If the job never gets a runner, the block sits on the control plane or GitHub path. If the job gets a runner and one step fails, the block sits on a registry or a service that step calls. ### [The two checklist items that pair with it](#the-two-checklist-items-that-pair-with-it)Copy Anchor Link Restricting egress is one line of the BYOC security checklist, and it works best next to two others. Blocking all inbound comes first. Runner instances have no reason to accept inbound connections, including from inside the VPC, so the hardened shape is a security group with an empty inbound rule set. In import mode you supply that security group when creating the stack: Copy Text ``` { "InboundRules": [], "OutboundRules": [ { "IpProtocol": "-1", "Destination": "0.0.0.0/0", "Description": "Allow all outbound" } ] } ``` That shape comes from the [BYOC AWS security hardening guide](/docs/ci/byoc/aws/security). Start here, confirm jobs pass, then replace the single outbound rule with the narrower destinations once you have the ranges or the proxy in place. Inbound stays empty either way. Instance isolation comes second. Runner instances have no reason to talk to each other, so network ACLs on the runner subnets can deny intra-subnet traffic and close off lateral movement between concurrent jobs ([BYOC AWS security hardening](/docs/ci/byoc/aws/security)). Each CI job already runs on a freshly provisioned instance that is terminated when the job completes, so there is no shared state between jobs to protect; the isolation rule protects the window while jobs run side by side. Pair both with IMDSv2 required on the runner, set per runner under Runner Specs in the dashboard ([IMDSv2 configuration](/docs/ci/byoc/aws/config#set-instance-metadata-service-version-2-imds-v2-to-required)), which closes the metadata credential path that SSRF bugs in a build step would otherwise reach. The full list, including S3 lifecycle policies and tagging for cost attribution, is in the [BYOC on AWS security checklist](/byoc/aws/security-checklist). The rule-writing mechanics themselves are covered in [outbound traffic controls for BYOC runners](/byoc/outbound-traffic-controls), and the AWS object the rules live on is defined in [security group](/glossary/security-group). ### [What tightening egress does not change](#what-tightening-egress-does-not-change)Copy Anchor Link Rule changes are free. BYOC Linux and Windows runners carry a WarpBuild fee of $0.002 per minute and the compute itself is billed by your own cloud account ([pricing page](/pricing), checked on 2026-08-13). Every cost number on this page carries a source link and a checked-on date, and the same numbers appear on the [pricing page](/pricing). Platform coverage does not change either. A hardened stack runs the same labels a permissive one does, so no workflow edit follows an egress change. ## [Related Questions](#related-questions)Copy Anchor Link **Can I allowlist outbound traffic by hostname on an AWS security group?** No. Security group rules match IP ranges, so a hostname allowlist belongs on an outbound proxy or a firewall placed in front of the runner subnets. GitHub publishes its address ranges through the [REST meta endpoint](https://docs.github.com/en/rest/meta/meta), and those ranges change, so an address-based list needs a refresh job. **What breaks first if I block the WarpBuild control plane?** The job never gets a runner. The instance launches in your account with the `warp-` name tag, registration fails, and the GitHub Actions job stays queued with an empty log until it times out. Compare that against a `github.com` block, where the runner does pick up the job and `actions/checkout` is the step that fails. **Do GCP and Azure BYOC stacks follow the same outbound rules?** BYOC runs on AWS, GCP, and Azure, and the runner needs the same destinations on all three. The rule-by-rule hardening guidance WarpBuild publishes is written against the AWS stack ([BYOC AWS security hardening](/docs/ci/byoc/aws/security)), so translate it into firewall rules on GCP or network security group rules on Azure. Start from the default stack, confirm jobs pass, then narrow the rules one destination at a time using the [BYOC on AWS overview](/byoc/aws) and the hardening docs. --- # Can I Restrict Which Repositories Use My Runners? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-restrict-which-repositories-use-my-runners Description: Yes. A GitHub runner group decides which repositories may claim a set of runners, and the WarpBuild connection list adds a second allowlist in front of it. # Can I Restrict Which Repositories Use My Runners? Yes. A GitHub runner group decides which repositories may claim a set of runners, and the WarpBuild connection list adds a second allowlist in front of it. Yes. Repository access to a set of runners is decided by the GitHub runner group those runners belong to, and WarpBuild registers its runners into the Default runner group (id 1) of your organization unless you select another group in the dashboard ([public repositories documentation](/docs/ci/public-repos)). A second allowlist sits in front of that one: the repository list on your WarpBuild GitHub connection, which decides which repositories the WarpBuild bot serves at all ([common issues](/docs/ci/common-issues)). ## [Answer](#answer)Copy Anchor Link A job has to pass both allowlists before any runner picks it up, and both are evaluated before label matching happens ([common issues](/docs/ci/common-issues)). That is what makes restriction reliable: a repository you leave off either list has no eligible runner, whatever `runs-on` label its workflow names. | Layer | Where it is configured | What it controls | What a block looks like | | -------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | WarpBuild GitHub connection | app.warpbuild.com/settings/account, Configure Runners on the GitHub connection | Which repositories the WarpBuild bot serves | Job queues with an empty log, and the repository is missing from the connection list | | Default runner group, id 1 | GitHub organization settings, Actions, Runner groups, group 1 | Whether public repositories may use the runners | Every job in every public repository queues while private repositories keep running | | Non-default runner group | The same settings page, on the group selected in WarpBuild | Which repositories may claim that group's runners | Jobs queue only in the repositories left off the group's access list | | Workflow restrictions on a group | The same group settings page | Which workflow paths, branches, tags, and SHAs may claim the runners | One workflow, branch, or tag queues while the rest of the repository is fine | Rows come from the [common issues guide](/docs/ci/common-issues) and the [public repositories guide](/docs/ci/public-repos), checked on 2026-08-13. Two properties of this design are worth stating before the setup path. First, a group covers every runner inside it. One repository access list governs all four platforms at once, so a matrix job that stalls on every leg at the same moment points at access rather than at any one image. Second, the access list decides eligibility rather than capacity, so removing repositories from a group changes who may run jobs rather than how many jobs run in parallel. The parallelism question is answered separately in [do runner groups limit concurrency](/answers/do-runner-groups-limit-concurrency). ## [Detail](#detail)Copy Anchor Link ### [The setup path for a non-default group](#the-setup-path-for-a-non-default-group)Copy Anchor Link GitHub Enterprise supports creating multiple runner groups, and WarpBuild runners land in the Default group (id 1) until you say otherwise ([public repositories documentation](/docs/ci/public-repos)). The sequence for a narrower list: 1. In GitHub organization settings, open Actions, then Runner groups, and create a group such as `warpbuild-runners`. 2. Set repository access on that group to selected repositories, and add exactly the repositories that should be allowed to run jobs on WarpBuild runners. 3. Open `https://app.warpbuild.com/ci`, go to Runners, and set the Default GitHub Runner Group to the group you created so runners register into it ([common issues](/docs/ci/common-issues)). 4. Push a change in an allowed repository and confirm the job starts. Push one in an excluded repository and confirm it stays queued with an empty log. 5. Keep the list under review through the [GitHub REST API for self-hosted runner groups](https://docs.github.com/en/rest/actions/self-hosted-runner-groups) if you would rather version the access list than edit it in a browser tab. Once the group exists, a workflow can name it directly. GitHub narrows the candidate runners to the group first, then applies the usual label match inside it ([common issues](/docs/ci/common-issues)): Copy Text ``` name: ci on: pull_request: push: branches: [main] jobs: test: runs-on: group: warpbuild-runners labels: [warp-ubuntu-latest-x64-4x] steps: - uses: actions/checkout@v4 - name: Print runner identity run: echo "name=$RUNNER_NAME os=$RUNNER_OS arch=$RUNNER_ARCH" - run: make test package: runs-on: group: warpbuild-runners labels: [warp-ubuntu-latest-arm64-4x] steps: - uses: actions/checkout@v4 - run: ./scripts/package.sh ``` A job that names no group stays eligible for any group the repository can reach, which is why the plain `runs-on: warp-ubuntu-latest-x64-4x` form keeps working after you create the group. The `Print runner identity` step is a cheap probe during rollout: if that line never appears in the log, no runner claimed the job and the cause is access. ### [Workflow restrictions inside a group](#workflow-restrictions-inside-a-group)Copy Anchor Link A group can be pinned further, to named workflow files at a specific branch, tag, or SHA. Restrictions are written as a full path with a ref: Copy Text ``` monalisa/octocat/.github/workflows/cd.yaml@refs/heads/main ``` That entry allows `cd.yaml` on `main` and nothing else. Each entry is an allow rule, so the first one you add excludes every workflow you did not list ([common issues](/docs/ci/common-issues)). This is the layer that produces the confusing case, where a deploy workflow runs and the pull request check in the same repository queues forever, so record the intent somewhere your reviewers can read it. ### [The public repository consideration](#the-public-repository-consideration)Copy Anchor Link GitHub disables self-hosted runners, including managed ones such as WarpBuild, in public repositories by default. The switch lives on the default group's settings page at `https://github.com/organizations/[YOUR_ORG]/settings/actions/runner-groups/1`, as the `Allow public repositories` checkbox ([public repositories documentation](/docs/ci/public-repos)). Leaving it unchecked is the coarsest restriction available, and it applies to every public repository in the organization at once. The reason GitHub ships that default is that a pull request from an outside contributor can run arbitrary code on shared infrastructure ([GitHub self-hosted runner security](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#self-hosted-runner-security)). WarpBuild runs each workflow inside an isolated virtual machine, which is what makes the checkbox safe to turn on here. One documented exception survives the change: for security reasons, Dependabot updates do not run on self-hosted runners in public repositories ([GitHub Dependabot on self-hosted runners](https://docs.github.com/en/enterprise-cloud@latest/code-security/dependabot/maintain-dependencies/managing-dependabot-on-self-hosted-runners)). ### [What the allowed repositories cost to run](#what-the-allowed-repositories-cost-to-run)Copy Anchor Link Access lists change who runs jobs, and the bill still follows minutes. Rates for the labels in the workflow above, from the [pricing page](/pricing), checked on 2026-08-13: | Runner label | vCPU | RAM | Rate per minute | | --------------------------- | ---- | ----- | --------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | At the 4 vCPU, 16 GB shape, `warp-ubuntu-latest-x64-4x` costs $0.008 per minute against $0.012 per minute for the GitHub-hosted 4-core Linux larger runner: (0.012 - 0.008) / 0.012, which is 33 percent lower list price ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). ## [Related Questions](#related-questions)Copy Anchor Link **Which runner group do WarpBuild runners join by default?** The Default runner group, id 1, of your GitHub organization. If you want a narrower repository list, create a non-default group, give it a selected-repositories access list, and point WarpBuild at that group under Runners in the dashboard. The full troubleshooting order is in [using runner groups with GitHub Actions](/guides/github-actions-runner-groups). **How do I restrict runners to a subset of repositories?** Use both documented layers. The repository list on the WarpBuild GitHub connection decides which repositories the bot serves, and the repository access list on the runner group decides which repositories may claim the runners on the GitHub side. A repository has to pass both, so the stricter of the two lists is the one that takes effect. [Runner group, defined](/glossary/runner-group) covers the GitHub-side object itself. **What does a blocked repository look like in the Actions tab?** The job stays queued with an empty log, because no runner ever claims it and nothing writes to the log. GitHub cancels a job that has been queued for 24 hours ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), so a policy mistake reports itself a day after the push. Confirm the repository against the [common issues checks](/docs/ci/common-issues) before adding runner capacity. Set the group up with the steps above, price the allowed repositories against your own minutes on the [pricing page](/pricing), and hand a reviewer the [security review material](/enterprise/ci-security-review) if the access model needs to be documented. --- # Can I Build 32-bit ARM Targets on ARM64 Runners? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-run-32-bit-arm-builds-on-arm64-runners Description: Yes. Cross compile with an arm-linux-gnueabihf toolchain on an ARM64 runner such as warp-ubuntu-latest-arm64-4x at $0.006 per minute, then test on hardware. # Can I Build 32-bit ARM Targets on ARM64 Runners? Yes. Cross compile with an arm-linux-gnueabihf toolchain on an ARM64 runner such as warp-ubuntu-latest-arm64-4x at $0.006 per minute, then test on hardware. Yes. A 32-bit ARM artifact is produced on an ARM64 runner by cross compiling with a toolchain that targets the 32-bit ABI, which keeps the compiler running as native aarch64 code and avoids standing up an emulated 32-bit environment for the build. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and these builds run on an ARM64 label such as `warp-ubuntu-latest-arm64-4x` at $0.006 per minute ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link The 32-bit ARM world splits into three targets, and each one has its own toolchain package and its own flag set. Pick by ABI, then install the matching compiler in the job. | Target | Triple or setting | Ubuntu package | Compiler entry point | Flags that select the target | | --------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------- | | ARMv7 Linux, hard float | arm-linux-gnueabihf | [gcc-arm-linux-gnueabihf](https://packages.ubuntu.com/noble/gcc-arm-linux-gnueabihf), libc6-dev-armhf-cross | arm-linux-gnueabihf-gcc | \-march=armv7-a -mfpu=neon-vfpv4 -mfloat-abi=hard -mthumb | | Older ARM Linux, soft float | arm-linux-gnueabi | [gcc-arm-linux-gnueabi](https://packages.ubuntu.com/noble/gcc-arm-linux-gnueabi), libc6-dev-armel-cross | arm-linux-gnueabi-gcc | \-march=armv5te -mfloat-abi=soft | | Bare metal and RTOS | arm-none-eabi | [gcc-arm-none-eabi](https://packages.ubuntu.com/noble/gcc-arm-none-eabi) | arm-none-eabi-gcc | \-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 | | Go | GOARCH=arm | none, the Go toolchain covers it | go build | GOOS=linux GOARCH=arm GOARM=7 CGO\_ENABLED=0 | | Rust | armv7-unknown-linux-gnueabihf | rustup target plus gcc-arm-linux-gnueabihf | cargo build --target | CARGO\_TARGET\_ARMV7\_UNKNOWN\_LINUX\_GNUEABIHF\_LINKER=arm-linux-gnueabihf-gcc | The `-march`, `-mfpu`, `-mfloat-abi` and `-mthumb` flags are documented in the [GCC ARM options reference](https://gcc.gnu.org/onlinedocs/gcc/ARM-Options.html). The Go setting comes from the [GoArm wiki page](https://go.dev/wiki/GoArm), where `GOARM=7` selects hardware floating point on ARMv7\. The Rust triple is listed under tier 2 in the [rustc platform support table](https://doc.rust-lang.org/rustc/platform-support.html). None of the cross packages are preinstalled. The Ubuntu 24.04 and Ubuntu 26.04 ARM64 images are compatible with GitHub's ARM64 images and carry the package set published at [actions/partner-runner-images](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md), so the toolchain install is an explicit `apt-get` step in the job ([preinstalled software documentation](/docs/ci/preinstalled-software)). ## [Detail](#detail)Copy Anchor Link ### [Why the 32-bit build lands at compile time](#why-the-32-bit-build-lands-at-compile-time)Copy Anchor Link AArch32 execution at the application level is optional in the 64-bit Arm architecture, and server-class cores generally leave it out. AWS's [Graviton getting-started repository](https://github.com/aws/aws-graviton-getting-started) states that its 64-bit cores do not run 32-bit Arm applications. On such a host, an armhf ELF binary fails at `exec` with a format error even though the CPU is an Arm part. Cross compiling sidesteps that entirely. The compiler, linker, and build system are aarch64 binaries running at full speed on the runner, and only the emitted object code is 32-bit. Every step of the build stays native, so a 4 vCPU ARM64 runner compiles armhf code at the same rate it compiles aarch64 code. The alternative shape is user-mode emulation. [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) registers binfmt handlers so `linux/arm/v7` containers and binaries execute on an aarch64 host through QEMU translation. That is worth doing to run something, and it is a poor way to build something, because every instruction of the compiler itself is translated. ### [Installing the toolchain and the armhf sysroot](#installing-the-toolchain-and-the-armhf-sysroot)Copy Anchor Link The cross packages supply a compiler and a glibc sysroot for the target. Third-party libraries come from multiarch instead, and this is the step where the host architecture matters: Copy Text ``` - name: Install the 32-bit ARM toolchain run: | sudo dpkg --add-architecture armhf sudo apt-get update sudo apt-get install -y --no-install-recommends \ gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf \ libc6-dev-armhf-cross libssl-dev:armhf arm-linux-gnueabihf-gcc -print-multiarch ``` On an arm64 Ubuntu image the apt sources already point at the Ubuntu ports archive, which carries armhf next to arm64, so `dpkg --add-architecture armhf` resolves armhf development packages with no edit to `sources.list`. On an x86-64 host the default archive does not carry armhf at all, so the same step needs a second source entry plus architecture pinning on the existing entries to stop apt from looking for armhf on the main archive ([Debian multiarch HOWTO](https://wiki.debian.org/Multiarch/HOWTO)). Doing 32-bit Arm work on an ARM64 runner removes that piece of setup. Two environment variables keep `pkg-config` honest once multiarch libraries are present: `PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig` and `PKG_CONFIG_SYSROOT_DIR=/`. Without them, a configure script reads the aarch64 `.pc` files and links the wrong ABI. ### [One ARM64 job, several 32-bit artifacts](#one-arm64-job-several-32-bit-artifacts)Copy Anchor Link A target matrix on a single ARM64 label produces one artifact per ABI: Copy Text ``` name: cross-build-32bit on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-arm64-4x strategy: fail-fast: false matrix: include: - target: armhf cc: arm-linux-gnueabihf-gcc cflags: "-march=armv7-a -mfpu=neon-vfpv4 -mfloat-abi=hard -mthumb -O2" packages: gcc-arm-linux-gnueabihf libc6-dev-armhf-cross - target: armel cc: arm-linux-gnueabi-gcc cflags: "-march=armv5te -mfloat-abi=soft -O2" packages: gcc-arm-linux-gnueabi libc6-dev-armel-cross - target: cortex-m4 cc: arm-none-eabi-gcc cflags: "-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -Os" packages: gcc-arm-none-eabi steps: - uses: actions/checkout@v4 - name: Install the cross toolchain run: | sudo apt-get update sudo apt-get install -y --no-install-recommends ${{ matrix.packages }} - name: Build env: CC: ${{ matrix.cc }} CFLAGS: ${{ matrix.cflags }} run: make clean all OUT=build/${{ matrix.target }} - name: Record the produced ABI run: file build/${{ matrix.target }}/* | tee build/${{ matrix.target }}/abi.txt - uses: actions/upload-artifact@v4 with: name: bin-${{ matrix.target }} path: build/${{ matrix.target }} ``` The `file` step is the cheap check that the flags did what the matrix says. For the armhf leg it prints `ELF 32-bit LSB`, `ARM, EABI5` and a hard-float note, and `abi.txt` travels with the artifact so a release job can assert on it later. When the apt install dominates the wall clock, collapse the three legs into one job and loop over the same table, installing all three compilers once and calling `actions/upload-artifact` per target directory. The tradeoff is straightforward: three jobs give parallel wall clock and pay three toolchain installs, one job pays a single install and runs the builds in sequence. ### [Where cross compilation stops](#where-cross-compilation-stops)Copy Anchor Link The build is the easy half. These failure classes only surface when the code executes: * **glibc symbol skew.** The cross sysroot is Ubuntu 24.04 vintage, so a binary linked against it can reference symbol versions the device's older rootfs lacks, and the failure reads as a missing `GLIBC_2.xx` at load time. * **Float ABI mismatch.** Linking hard-float objects against a soft-float vendor blob is accepted by some link steps and produces a binary that computes wrong values or faults on the first call. * **32-bit type width.** `time_t`, `off_t`, and pointer arithmetic all change size, so Y2038 handling, large file access, and any struct laid out by hand behave differently from the aarch64 build that passes in the same repository. * **Alignment and atomics.** ARMv5 and ARMv7 differ in unaligned access behavior and in the atomic instructions available, and the compiler will happily emit code the target core cannot execute if `-march` is set higher than the hardware. Unit tests that touch nothing beyond libc and the CPU can run on the runner under QEMU user-mode emulation, which is enough to catch the type-width and arithmetic classes above. Anything that touches device drivers, `/dev` nodes, interrupt latency, peripheral registers, a vendor BSP, or a kernel module needs real hardware. The GitHub Actions runner agent ships a `linux-arm` build for 32-bit hosts on [github.com/actions/runner](https://github.com/actions/runner), so a device on a bench can register as a self-hosted runner and take the test job through `runs-on: [self-hosted, linux, ARM]` while the cross build stays on the managed ARM64 label. The [cross compiling against native ARM64 builds guide](/guides/cross-compiling-vs-native-arm64-builds) covers how to split those two jobs, and the [embedded firmware solution page](/solutions/embedded-firmware-github-actions) covers the hardware-in-the-loop side. ### [What the ARM64 host costs](#what-the-arm64-host-costs)Copy Anchor Link Every WarpBuild rate comes from the [cloud runners documentation](/docs/ci/cloud-runners). GitHub rates come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing), checked on 2026-08-13. | Runner label | vCPU | RAM | Per minute | GitHub-hosted ARM64 equivalent | GitHub per minute | | --------------------------- | ---- | ----- | ---------- | -------------------------------- | ----------------- | | warp-ubuntu-latest-arm64-2x | 2 | 8 GB | $0.003 | ubuntu-24.04-arm | $0.005 | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | 4-core Linux ARM64 larger runner | $0.008 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | 8-core Linux ARM64 larger runner | $0.014 | **Worked model.** A firmware repository runs the three-leg matrix above on every pull request, 400 pull request events a month, each leg taking 5 minutes on the 4 vCPU label. That is 400 x 3 x 5 = 6,000 minutes. * `warp-ubuntu-latest-arm64-4x` at $0.006 per minute: 6,000 x $0.006 = **$36.00 per month**. * GitHub 4-core Linux ARM64 larger runner at $0.008 per minute: 6,000 x $0.008 = **$48.00 per month**. * Difference: $12.00 per month, $144.00 over twelve months, which is 25 percent lower list price (GitHub pricing, checked 2026-08-13). * The $10 in signup credits covers the first $10 of that bill, so month one lands at $26.00 at this volume. Caching the apt download or baking the toolchain into an image removes the install from every leg. A remote Docker builder is the route when the 32-bit output is a `linux/arm/v7` container image rather than a bare binary. Labels, sizes, and regions are in the [Linux ARM64 runner catalog](/runners/linux-arm64). ## [Related Questions](#related-questions)Copy Anchor Link **Can I run the 32-bit binary I just built on the same ARM64 runner?** Not directly. Server-class ARM64 cores commonly ship without AArch32 support, so an armhf binary fails to exec. Register the QEMU binfmt handlers with [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) to run it under user-mode emulation, or send the test job to a self-hosted 32-bit device while the build stays on a label from the [Linux ARM64 runner catalog](/runners/linux-arm64). **Which package do I need for armhf against bare-metal Cortex-M?** Linux userspace targets use `gcc-arm-linux-gnueabihf` with `libc6-dev-armhf-cross`. Bare-metal and RTOS targets use `gcc-arm-none-eabi`, which ships newlib instead of glibc, so there is no dynamic loader and no libc syscall layer to match against a rootfs. Both install on the ARM64 Ubuntu image with `apt-get` ([preinstalled software documentation](/docs/ci/preinstalled-software)), and the [embedded firmware solution page](/solutions/embedded-firmware-github-actions) walks the bare-metal case end to end. **Do Go and Rust need the apt cross toolchain?** Pure Go does not. Set `GOOS=linux`, `GOARCH=arm`, `GOARM=7` and `CGO_ENABLED=0` and the Go toolchain emits the 32-bit binary on its own ([GoArm wiki page](https://go.dev/wiki/GoArm)). Rust needs `rustup target add armv7-unknown-linux-gnueabihf` plus `arm-linux-gnueabihf-gcc` as the linker, because the target is tier 2 and cargo still shells out to a C linker ([rustc platform support](https://doc.rust-lang.org/rustc/platform-support.html)). **Is there a 32-bit ARM runner label instead?** No. The catalog runs 64-bit hosts, so Linux ARM64 is the closest host architecture and cross compiling from it is the supported path. The [cross compilation glossary entry](/glossary/cross-compilation) defines the terms, and the [Linux ARM64 runner catalog](/runners/linux-arm64) lists the labels and per minute rates. Start the 32-bit matrix on a label from the [Linux ARM64 runner catalog](/runners/linux-arm64), price it against your own pull request volume on the [pricing page](/pricing), and read the [cross compiling against native ARM64 builds guide](/guides/cross-compiling-vs-native-arm64-builds) before you decide which jobs still need real hardware. --- # Can Android and iOS Builds Share One Workflow? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-run-android-and-ios-builds-in-the-same-workflow Description: Yes. runs-on is a per-job setting, so an Android job on a Linux label and an iOS job on a macOS label live in the same GitHub Actions workflow file. # Can Android and iOS Builds Share One Workflow? Yes. runs-on is a per-job setting, so an Android job on a Linux label and an iOS job on a macOS label live in the same GitHub Actions workflow file. Last verified: 2026-08-13 Yes. `runs-on` is declared on each job rather than on the workflow, so one file can hold an Android job on a Linux runner and an iOS job on a macOS runner ([workflow syntax reference for runs-on](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidruns-on), checked on 2026-08-13). WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so both legs select a `warp-` label and the pair runs in parallel from the same trigger ([WarpBuild cloud runners](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link The shape that works is one small job that computes the release metadata, two platform jobs that consume it, and a final job that collects the outputs. Jobs run in parallel by default, and `needs` is what creates the ordering ([workflow syntax reference for needs](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidneeds)). Values move down the graph through job outputs ([workflow syntax reference for job outputs](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidoutputs)). Copy Text ``` name: mobile-release on: pull_request: push: branches: [main] jobs: version: runs-on: warp-ubuntu-latest-x64-2x outputs: build_number: ${{ steps.compute.outputs.build_number }} semver: ${{ steps.compute.outputs.semver }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - id: compute run: | echo "build_number=$(git rev-list --count HEAD)" >> "$GITHUB_OUTPUT" echo "semver=$(cat VERSION)" >> "$GITHUB_OUTPUT" android: needs: version runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" - uses: WarpBuilds/cache@v1 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', 'gradle/libs.versions.toml') }} - name: Assemble the release bundle run: | ./gradlew bundleRelease \ -PversionCode=${{ needs.version.outputs.build_number }} \ -PversionName=${{ needs.version.outputs.semver }} - uses: actions/upload-artifact@v4 with: name: android-aab path: app/build/outputs/bundle/release/app-release.aab ios: needs: version runs-on: warp-macos-latest-arm64-6x steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 with: path: | ~/Library/Developer/Xcode/DerivedData .build key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }} - name: Stamp the build number run: agvtool new-version -all ${{ needs.version.outputs.build_number }} - name: Archive run: | xcodebuild archive \ -scheme App \ -destination "generic/platform=iOS" \ -archivePath build/App.xcarchive \ MARKETING_VERSION=${{ needs.version.outputs.semver }} - uses: actions/upload-artifact@v4 with: name: ios-archive path: build/App.xcarchive release: needs: [android, ios] runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/download-artifact@v4 with: path: dist - run: ls -R dist ``` The `version` job runs once, both platform jobs read `needs.version.outputs.build_number`, and the two binaries carry the same version string without a second computation. Per-minute rates for every label are on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [The two legs bill at different rates](#the-two-legs-bill-at-different-rates)Copy Anchor Link The Android leg and the iOS leg pay different per-minute rates, so a mixed workflow has one job that carries most of the bill. Every WarpBuild rate below comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. Job minutes are a modeling assumption, so substitute your own. | Job | Label | Shape | Per minute | Minutes | Cost per run | | ------- | -------------------------- | ------------- | ---------- | ------- | ------------ | | version | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | 1 | $0.004 | | android | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | 9 | $0.144 | | ios | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08 | 14 | $1.12 | | release | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | 1 | $0.004 | That is $1.272 per run, of which the macOS job is $1.12, or 88 percent. At 300 runs a month the workflow costs $381.60, split $336.00 on macOS minutes and $45.60 on Linux minutes. Tuning the Android leg moves cents; tuning the iOS leg moves dollars. The same 14 macOS minutes on the largest GitHub-hosted macOS ARM64 runner, a 5 vCPU and 14 GB machine at $0.102 per minute, is $1.428 per run and $428.40 at 300 runs ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). `warp-macos-latest-arm64-6x` is $0.08 per minute on a machine with one more vCPU and the same memory, which is about 22 percent lower list price and $92.40 a month at this volume. Sizing up the iOS leg is arithmetic against that number. `warp-macos-26-arm64-12x` is 12 vCPU with 28 GB at $0.16 per minute, so it costs less per run than the 6 vCPU label only once the archive finishes under 7 minutes, since $1.12 divided by $0.16 is 7\. Measure the current job before you switch. Per-job durations and queue times are visible in CI observability, which sits alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger in the [WarpBuild product surface](/docs/ci/what-is-warpbuild). ### [What the two jobs can share](#what-the-two-jobs-can-share)Copy Anchor Link | Shared thing | Mechanism | Constraint | | ---------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Version numbers and release metadata | outputs on the upstream job, read through needs | Strings only, so structured data has to be serialized | | Build products such as an AAB and an xcarchive | actions/upload-artifact then actions/download-artifact | File permissions are not maintained during upload ([upload-artifact](https://github.com/actions/upload-artifact)), so re-apply an executable bit after download | | Secrets, variables, and concurrency groups | Repository or environment scope, declared once | Applies to the workflow, no per-platform copy needed | | Source code | actions/checkout@v4 in each job | Each job checks out separately; runners share no filesystem | The two platform jobs never share a machine. A job gets its own ephemeral runner, and everything that crosses the gap between jobs crosses it as an artifact or an output. ### [What the two jobs cannot share](#what-the-two-jobs-cannot-share)Copy Anchor Link Caches. Cache version is a hash over the compression tool the runner OS selects and the paths being cached, which means a cache created on `warp-macos-14-arm64-6x` cannot be restored on `warp-ubuntu-latest-x64-4x` ([WarpBuild caching documentation](/docs/ci/features/caching)). Even a shared key resolves to a different entry on the other platform. The paths diverge as well. The Android leg caches `~/.gradle/caches` and `~/.gradle/wrapper`; the iOS leg caches `~/Library/Developer/Xcode/DerivedData` and the SwiftPM `.build` directory. A single key covering both restores nothing useful on either. Put `runner.os` in every cache key and keep the two entries independent. Cache backends differ too. The WarpBuild cache is available on all Linux runners and enabled by default, and it is not supported on Windows runners ([cloud runners documentation](/docs/ci/cloud-runners)), so the Android leg can use `WarpBuilds/cache@v1` as a drop-in for `actions/cache@v4` while the iOS leg stays on `actions/cache@v4`. One more split is worth planning for. macOS runners do not support nested virtualization and cannot run Docker, and Android instrumented tests that need `/dev/kvm` require the `nested-virtualization.enabled=true` dynamic label on an x86-64 Linux runner ([cloud runners documentation](/docs/ci/cloud-runners)). Container fixtures and emulator jobs therefore belong on the Linux side of the file, which is where they already are in this shape. ## [Related Questions](#related-questions)Copy Anchor Link **Can one GitHub Actions workflow file target two operating systems?** Yes. `runs-on` is declared per job, so the Android job carries `warp-ubuntu-latest-x64-8x` and the iOS job carries `warp-macos-latest-arm64-6x` in the same file. Both start in parallel unless a `needs` edge orders them. The label catalog with sizes and rates is on the [macOS runner page](/runners/macos). **Should I use a matrix instead of two separate jobs?** No. A matrix repeats one list of steps across values, and the two legs run different toolchains, different caches, and different signing paths. Use a matrix inside a leg instead, such as several simulator destinations in the iOS job or several product flavors in the Android job. **Can the Android job restore a cache the iOS job saved?** No. Cache version differs by runner OS and cached paths, so entries do not cross platforms ([caching documentation](/docs/ci/features/caching)). Key each leg with `runner.os` and treat the two caches as unrelated. **Does this change for React Native or Flutter?** The job graph stays the same and the shared step grows. Both frameworks add a JavaScript or Dart dependency install that each platform leg repeats, so the version job often also produces a lockfile hash the two legs key their caches on. The [React Native workflow page](/solutions/react-native-github-actions) and the [Flutter workflow page](/solutions/flutter-github-actions) cover the framework-specific steps. --- # Can ARM64 and x64 Jobs Run in One Workflow? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-run-arm64-and-x64-jobs-in-the-same-workflow Description: Yes. runs-on is set per job, so one matrix leg can run on warp-ubuntu-latest-arm64-8x at $0.012 per minute and another on an x64 label in the same run. # Can ARM64 and x64 Jobs Run in One Workflow? Yes. runs-on is set per job, so one matrix leg can run on warp-ubuntu-latest-arm64-8x at $0.012 per minute and another on an x64 label in the same run. Last verified: 2026-08-13 Yes. `runs-on` is a per-job field, so one workflow file can place a job on an ARM64 label and another job on an x64 label in the same run, and a matrix is the normal way to write that ([GitHub workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)). WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so both legs resolve against one catalog: `warp-ubuntu-latest-arm64-8x` at $0.012 per minute and `warp-ubuntu-latest-x64-8x` at $0.016 per minute ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link A workflow is a set of jobs, and every job carries its own `runs-on`. A matrix expands into one job per entry, each entry can carry its own runner label, and the expanded jobs are scheduled independently. Nothing in GitHub Actions ties the architectures inside a workflow to a single value. The pattern below builds a container image on both architectures in one run. Each leg pushes an untagged image by digest, and a final job binds the two digests to one tag. Copy Text ``` name: build-both-architectures on: push: branches: [main] pull_request: jobs: build: strategy: fail-fast: false matrix: include: - arch: amd64 platform: linux/amd64 runner: warp-ubuntu-latest-x64-8x - arch: arm64 platform: linux/arm64 runner: warp-ubuntu-latest-arm64-8x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push by digest id: build uses: docker/build-push-action@v6 with: context: . platforms: ${{ matrix.platform }} outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true - name: Export the digest run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - uses: actions/upload-artifact@v4 with: name: digests-${{ matrix.arch }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 ``` `fail-fast: false` keeps the x64 leg running when the ARM64 leg fails, which is what you want while you work through architecture-specific breakage. Each entry maps to a label in the [cloud runners documentation](/docs/ci/cloud-runners), so the ARM64 leg executes on aarch64 hardware and the x64 leg on x86-64 hardware, with no translation layer on either side. Per-minute rates for every label are on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [Merging the two outputs into one image reference](#merging-the-two-outputs-into-one-image-reference)Copy Anchor Link The two jobs run on separate machines and share no filesystem, so the join happens through the registry. Each leg pushed a manifest by digest and wrote the digest name into an artifact. A third job downloads both artifacts and calls `docker buildx imagetools create`, which writes one manifest list pointing at both digests. The [Docker multi-platform GitHub Actions guide](https://docs.docker.com/build/ci/github-actions/multi-platform/) documents this shape. Copy Text ``` merge: needs: build runs-on: warp-ubuntu-latest-arm64-2x steps: - uses: actions/download-artifact@v4 with: path: /tmp/digests pattern: digests-* merge-multiple: true - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Create the manifest list working-directory: /tmp/digests run: | docker buildx imagetools create \ -t ghcr.io/${{ github.repository }}:${{ github.sha }} \ $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) - name: Inspect the result run: docker buildx imagetools inspect ghcr.io/${{ github.repository }}:${{ github.sha }} ``` After that job, `ghcr.io/OWNER/REPO:SHA` is one tag that resolves to the aarch64 layers on ARM64 hosts and the x86-64 layers everywhere else. The merge job does no building, so a 2 vCPU runner is enough. The [manifest list answer](/answers/how-do-i-push-a-manifest-list-from-github-actions) covers the digest bookkeeping in more detail. ### [Pricing a two-architecture pipeline](#pricing-a-two-architecture-pipeline)Copy Anchor Link Every rate below comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Shape | Linux x64 label | Per minute | Linux ARM64 label | Per minute | | --------------- | -------------------------- | ---------- | ---------------------------- | ---------- | | 2 vCPU, 8 GB | warp-ubuntu-latest-x64-2x | $0.004 | warp-ubuntu-latest-arm64-2x | $0.003 | | 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | $0.008 | warp-ubuntu-latest-arm64-4x | $0.006 | | 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | $0.016 | warp-ubuntu-latest-arm64-8x | $0.012 | | 16 vCPU, 64 GB | warp-ubuntu-latest-x64-16x | $0.032 | warp-ubuntu-latest-arm64-16x | $0.024 | | 32 vCPU, 128 GB | warp-ubuntu-latest-x64-32x | $0.064 | warp-ubuntu-latest-arm64-32x | $0.048 | The two legs bill separately, at the rate of the label each one lands on. **Worked model.** A repository runs the workflow above 400 times a month. Each build leg takes 7 minutes on the 8 vCPU size and the merge job takes 1 minute on the 2 vCPU ARM64 size. * x64 leg: 400 x 7 x $0.016 = **$44.80 per month**. * ARM64 leg: 400 x 7 x $0.012 = **$33.60 per month**. * Merge job: 400 x 1 x $0.003 = **$1.20 per month**. * Total: **$79.60 per month**, and the $10 in signup credits covers the first $10, so month one lands at $69.60. The same 2,800 minutes per leg against GitHub-hosted larger runners, using the list prices in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) checked on 2026-08-13: * warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. That leg is $61.60 a month at the GitHub list price against $44.80. * warp-ubuntu-latest-arm64-8x (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB): 14 percent lower list price. That leg is $39.20 a month at the GitHub list price against $33.60. The two build legs together are $78.40 a month here against $100.80 at the GitHub list prices for the same shapes and the same minutes. Shapes come from the [GitHub-hosted runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). ### [What differs between the two legs](#what-differs-between-the-two-legs)Copy Anchor Link Four documented differences account for most first-run failures on the ARM64 leg ([cloud runners documentation](/docs/ci/cloud-runners)). **The work directory.** The arm64 images for Ubuntu 24.04 set the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/` for the same instance. Any step with a hardcoded `/home/runner/work/...` path breaks on the ARM64 leg while it passes on the x64 leg. Use `${{ github.workspace }}` in YAML and `$GITHUB_WORKSPACE` in shell steps, and check Docker `-v` mounts and cache path globs for literals. **Ubuntu 22.04 has no ARM64 image.** The arm64 images for ubuntu-22.04 were deprecated on March 31, 2025, so a matrix pinned to 22.04 needs the ARM64 entry moved to a `warp-ubuntu-2404-arm64` or `warp-ubuntu-2604-arm64` label. The x64 entry can stay on 22.04. **Nested virtualization stays on x86-64.** Workloads that need `/dev/kvm`, including Android emulator jobs, are not supported on ARM64 runners. Route those steps to the x64 entry with the `nested-virtualization.enabled=true` dynamic label. **Architecture-specific downloads.** Steps that fetch a release binary by URL or pull a single-arch container tag need an aarch64 variant on the ARM64 leg. Drive the choice from the matrix value rather than hardcoding one architecture, for example `curl -L .../tool-linux-${{ matrix.arch }}.tar.gz`. Ten ARM64 labels are available across Ubuntu 24.04 and Ubuntu 26.04, from 2 vCPU to 32 vCPU, listed in the [Linux ARM64 runner catalog](/runners/linux-arm64). ### [The single-job alternative](#the-single-job-alternative)Copy Anchor Link A remote Docker builder changes the shape of this workflow. Instead of two matrix legs, one job sets `platforms: linux/amd64,linux/arm64` and sends the build to builder virtual machines with a persistent layer cache, and buildx pushes the manifest list directly, so no merge job is needed ([Docker builders documentation](/docs/ci/docker-builders)). Enable both architectures on the builder profile before setting `platforms`. Each architecture runs on a separate builder instance, producing one session per architecture, and each session is billed independently at the profile rate. The 16 vCPU, 32 GB, 100GB disk profile is $0.06 per minute per session, and arm64 and multi-arch profiles cap at 64 vCPU. The [multi-platform Docker build guide](/guides/multi-platform-docker-builds) walks through both routes and when each one pays off. ## [Related Questions](#related-questions)Copy Anchor Link **Do the ARM64 job and the x64 job share a filesystem?** No. Each job in a matrix gets its own ephemeral machine, so data moves between the legs through artifacts, job outputs, or a registry. For image builds, each leg pushes by digest and a later job merges the digests, as covered in the [manifest list answer](/answers/how-do-i-push-a-manifest-list-from-github-actions). **Do I need a separate merge job to get one image tag?** On the native runner route, yes. Each architecture pushes an untagged manifest by digest, and a final job runs `docker buildx imagetools create` to bind both digests to one tag. On a remote Docker builder with both architectures enabled, buildx emits the manifest list itself ([Docker builders documentation](/docs/ci/docker-builders)). **Can macOS and Windows jobs sit in the same matrix as the ARM64 and x64 legs?** Yes. `runs-on` is per job, so a matrix entry can name a macOS ARM64 label or a Windows label alongside the Linux labels. The Windows catalog is x86-64 only, so a Windows entry stays on a `warp-windows` label ([cloud runners documentation](/docs/ci/cloud-runners)). **Which ARM64 sizes can the ARM64 leg use?** Any of the ten labels in the [Linux ARM64 runner catalog](/runners/linux-arm64), from `warp-ubuntu-latest-arm64-2x` at $0.003 per minute to `warp-ubuntu-latest-arm64-32x` at $0.048 per minute. The two legs do not have to match in size, so a compile-heavy x64 leg can take 16 vCPU while the ARM64 leg takes 8 vCPU. Pick the labels for each leg from the [Linux ARM64 runner catalog](/runners/linux-arm64), price the pipeline against your own job minutes on the [pricing page](/pricing), and wire up the image merge with the [multi-platform Docker build guide](/guides/multi-platform-docker-builds). --- # Can I Run BYOC in More Than One Region? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-run-byoc-in-more-than-one-region Description: Yes. Create one BYOC stack per region under the same cloud connection, add custom runners to each stack, and the runs-on label picks the region a job lands in. # Can I Run BYOC in More Than One Region? Yes. Create one BYOC stack per region under the same cloud connection, add custom runners to each stack, and the runs-on label picks the region a job lands in. ## [Answer](#answer)Copy Anchor Link Yes. You run BYOC in more than one region by creating one stack per region inside the same cloud connection and then defining custom runner configurations against each stack, so a GitHub Actions job picks its region through the `warp-custom-` label in `runs-on` ([BYOC setup documentation](/docs/ci/byoc)). BYOC runs on AWS, GCP, and Azure, and on the WarpBuild-hosted fleet US and EU are the regions WarpBuild names publicly. The shape follows from how the three BYOC objects nest. The [BYOC setup documentation](/docs/ci/byoc) describes a cloud connection that grants WarpBuild the IAM role or service account it needs, a stack that is the group of infrastructure in one region, and custom runners that carry instance types, disks, and IP configuration. Only the middle object is pinned to a region. | Object | What it holds | Region bound | What a second region needs | | ---------------- | --------------------------------------------------------------------------------------------------------- | ------------------------- | ------------------------------------------- | | Cloud connection | The IAM role or service account WarpBuild uses in your account | No | Nothing. One connection serves every region | | Stack | Region, VPC, subnets, and the object storage bucket for cache, image layer cache, logs, and artifacts | Yes, fixed at creation | A second stack | | Custom runner | Instance types in priority order, disk size, IP configuration, standby disk pool, instance profile on AWS | Inherits the stack region | A second set of runner configurations | | Runner ID | The label a workflow puts in runs-on, prefixed warp-custom- | Through its stack | A second label per runner class | The documentation is explicit that the stack name, object storage location, and region cannot be changed after creation, which is why a region is added rather than moved. It also recommends naming stacks after their product and region, with `twitch-use1` and `alexa-use2` as the worked examples, and that convention is what makes a multi-region workflow file readable later. Pricing does not change with the count. BYOC Linux runners and BYOC Windows runners each carry a WarpBuild fee of $0.002 per runner minute wherever the stack sits ([pricing page](/pricing), checked on 2026-08-13). ## [Detail](#detail)Copy Anchor Link ### [What a second stack actually creates](#what-a-second-stack-actually-creates)Copy Anchor Link Each stack builds its own network and its own bucket in its own region, and the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config) sets the prerequisites you have to satisfy again per region rather than once per account. | Prerequisite | Per-region requirement | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | VPC and subnets | At least one public and one private subnet with internet connectivity, three of each across availability zones recommended | | Address headroom | Enough addresses per subnet for peak concurrent runners in that region, 250 per subnet recommended | | Object storage | A bucket in the same region as the stack, holding the artifact cache and runner telemetry under that stack | | Private route to the bucket | A network path from the VPC to the bucket so runners reach it without incurring data transfer charges | | Quotas | vCPU, EBS volume, and address capacity for the selected instance types in that region | Instance type availability is the one that surprises teams. A machine family you rely on in one region can be short in another, which is what the fallback instance list on a custom runner configuration is for: several instance types in priority order, chosen with roughly similar performance so that a fallback does not change job timings. ### [How labels map to regions](#how-labels-map-to-regions)Copy Anchor Link Region selection is a workflow-file decision because each custom runner belongs to exactly one stack. .github/workflows/ci.yml Copy Text ``` name: ci on: [push] jobs: unit-tests: strategy: matrix: region: [use1, euw1] runs-on: warp-custom-app-${{ matrix.region }}-8x steps: - uses: actions/checkout@v4 - run: make test eu-release: needs: unit-tests runs-on: warp-custom-app-euw1-16x steps: - uses: actions/checkout@v4 - run: make release ``` The matrix job runs the same test suite in both regions. The release job is pinned to the EU stack, so the checkout, the build workspace, and everything the job writes to cache stay inside that region's account footprint. ### [Keeping build data in the region that has to hold it](#keeping-build-data-in-the-region-that-has-to-hold-it)Copy Anchor Link Multi-region BYOC is usually a residency requirement rather than a capacity one. The bucket the stack creates holds the artifact cache, the container image layer cache, runner logs, and other workflow artifacts, and it lives in the stack region ([BYOC setup documentation](/docs/ci/byoc)). Because the region is fixed at creation, the placement is enforced by the stack rather than by a setting someone can change later, which is the property a reviewer wants to see. The macOS labels stay on WarpBuild-hosted capacity, so an EU-pinned estate keeps Xcode jobs on the hosted fleet. The vocabulary a review will use is set out in [data residency, defined](/glossary/data-residency), and the EU-specific paths are on [EU data residency for GitHub Actions runners](/enterprise/data-residency-eu). One per-cloud limit belongs in any plan. BYOC on Azure is currently enabled for East US only, and additional regions go through [support@warpbuild.com](mailto:support@warpbuild.com), so an Azure-only estate has no second-region option today without that conversation. AWS and GCP stacks are created in whichever region you configure. ### [What crossing a region costs](#what-crossing-a-region-costs)Copy Anchor Link A runner and the bucket it reads should sit in the same region. When they do, the private route to the bucket keeps that traffic off the data transfer meter, which is why the configuration guide asks for that route and for the bucket to be in the stack region. When they do not, every cache restore and every artifact upload becomes billable inter-region transfer on your own cloud bill. Take 2,000 jobs a month, each moving 5 GB of cache and image layers, with runners in one region and the bucket in another. AWS lists data transferred out from Amazon S3 in US East (N. Virginia) to another AWS Region at $0.02 per GB ([Amazon S3 pricing](https://aws.amazon.com/s3/pricing/), checked on 2026-08-13); rates differ by source region, so read your own. | Layout | Transfer per month | Rate | Monthly transfer charge | | ------------------------------------------------------------- | ----------------------- | ------------------ | ----------------------- | | Runners and bucket in the same region, private route in place | 10,000 GB, in region | No transfer charge | $0.00 | | Runners in one region, bucket in another | 10,000 GB, cross region | $0.02 per GB | $200.00 | The WarpBuild fee is $40.00 either way at 20,000 runner minutes, so the split layout adds $200.00 a month against a $40.00 platform fee, before any EC2 or storage line. One stack per region, each with its own bucket, is the arrangement that keeps that meter at zero. Deploy-heavy workflows that pull large images and artifacts from ECR, S3, and similar stores generate no egress charges on the WarpBuild enterprise tier, on BYOC and on WarpBuild-hosted runners; see [zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress). The full AWS setup, including the stack creation flow you repeat per region, is on the [BYOC on AWS page](/byoc/aws), and the operating detail for a running multi-region fleet is on [running BYOC runners in several regions](/byoc/multi-region). ## [Related Questions](#related-questions)Copy Anchor Link **How many stacks do I need to cover two regions?** One stack per region. A stack is the group of infrastructure in a single region of your cloud account, and the [BYOC setup documentation](/docs/ci/byoc) states that the stack name, object storage location, and region cannot be changed after creation. The cloud connection above it is shared, so a second region reuses the IAM role or service account you already granted. On AWS and GCP you pick any region you configure, and BYOC on Azure is currently enabled for East US only. The AWS walkthrough is on the [BYOC on AWS page](/byoc/aws). **Can one workflow run jobs in two regions at once?** Yes. Region selection lives in the `runs-on` label, because a custom runner belongs to exactly one stack and the label is the runner ID with the `warp-custom-` prefix. Point one job at a runner in the EU stack and another at a runner in the US stack in the same workflow file, or drive both from a matrix as in the example above. The operating patterns are covered in [running BYOC runners in several regions](/byoc/multi-region). **Does a second region change what I pay WarpBuild?** No. Your own cloud account bills the per-region compute, storage, and any transfer between regions. --- # Can BYOC Runners Run in a Private Subnet? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-run-byoc-runners-in-a-private-subnet Description: Yes. BYOC runners launch in private subnets when static IPs are on, provided route tables and security group rules let each instance reach its destinations. # Can BYOC Runners Run in a Private Subnet? Yes. BYOC runners launch in private subnets when static IPs are on, provided route tables and security group rules let each instance reach its destinations. Yes. WarpBuild BYOC runners launch in private subnets when you enable static IPs on the custom runner configuration, and the placement works as long as the route tables and the security group let each instance reach the destinations the runner needs. Those destinations and the network shape around them are listed in the [AWS BYOC configuration prerequisites](/docs/ci/byoc/aws/config). ## [Answer](#answer)Copy Anchor Link Subnet placement is a runner-level setting rather than a workflow setting. The [BYOC setup documentation](/docs/ci/byoc) states that enabling static IPs creates the runners in private subnets behind NAT gateways, and that runners with static IPs use the addresses of those gateways as their external addresses. Turn the setting off and the instances are created in public subnets with no gateway in the path. The prerequisites the private path depends on, all from the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config), checked on 2026-08-13: | Prerequisite | Documented requirement | Owner | | --------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | VPC and subnets | At least one public and one private subnet, both with internet connectivity | Your AWS account | | Availability zones | Three public and three private subnets in different zones for instance-type availability | Your AWS account | | Address headroom | Enough addresses per subnet for peak concurrent runners, 250 per subnet recommended | Your AWS account | | Private subnet egress | NAT gateways serving the private subnets | Your AWS account | | Route tables | Internet-bound traffic routed through the Internet Gateway, with outbound routes in place for the private subnets | Your AWS account | | Security group | All inbound blocked, outbound allowed to the destinations below | Stack in create mode, yours in import mode | | Quotas | vCPU, EBS, and address headroom in the region | Your AWS account | Nothing in the workflow changes. The job keeps the runner ID as its label, prefixed with `warp-custom-`, exactly as it reads on a public-subnet runner: Copy Text ``` name: build on: [push] jobs: test: runs-on: warp-custom-linux-private-4x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test ``` BYOC Linux runners carry a WarpBuild fee of $0.002 per runner minute and your own cloud account bills the compute, storage, and data transfer ([pricing page](/pricing), checked on 2026-08-13). ## [Detail](#detail)Copy Anchor Link ### [The outbound destinations a private-subnet runner has to reach](#the-outbound-destinations-a-private-subnet-runner-has-to-reach)Copy Anchor Link A private subnet only works when the route out of it terminates somewhere the runner needs. The [security hardening guide](/docs/ci/byoc/aws/security) lists the required outbound destinations, and each one fails in a different place when a route table or a security group rule blocks it. | Destination | Purpose | What breaks when it is blocked | | ----------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------- | | api.warpbuild.com | Runner registration and lifecycle | The instance boots and the job stays queued, because the runner never registers | | github.com and api.github.com | Git operations and the Actions API | Checkout and the Actions API calls fail after the job starts | | Package registries | Dependency downloads for your build steps | Install steps fail on network timeouts | | WarpBuild S3 storage | Runner telemetry over outbound HTTPS | Runner system logs stop arriving, so failures are harder to read | Cache traffic and image pulls are the exception, per the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config). The stack routes S3 cache access and ECR operations inside the AWS network for runners in both public and private subnets, so that traffic does not cross the managed gateway meter. Stacks on CloudFormation template versions before v1.4 can hit ECR authentication timeouts on public-subnet runners, which the dashboard upgrade to v1.4 fixes. ### [What the private path costs](#what-the-private-path-costs)Copy Anchor Link Traffic that leaves a private subnet through a managed gateway carries a per-gigabyte processing charge on top of the hourly charge for the gateway itself. In US East (N. Virginia), AWS charges $0.045 per hour per gateway plus $0.045 per GB of data processed in both directions ([Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13). Container image pulls and package downloads dominate that number. Take 2,000 jobs a month at 10 minutes each, each job pulling 2 GB of images and packages from destinations outside AWS, with two gateways for zone redundancy: | Line item | Rate | Monthly | | ----------------------------------------------- | ----------------- | ------- | | WarpBuild BYOC fee, 20,000 runner minutes | $0.002 per minute | $40.00 | | Gateway data processing, 4,000 GB | $0.045 per GB | $180.00 | | Gateway hourly charge, 2 gateways for 730 hours | $0.045 per hour | $65.70 | The gateway line items add up to $245.70 a month against a $40.00 platform fee, before EC2 and EBS. That ratio is the reason the [BYOC documentation](/docs/ci/byoc) recommends using static IPs minimally. Split the fleet instead: one runner configuration in private subnets for the jobs that call an allowlisted third party, and one in public subnets for everything else. The routing options and their operating costs are compared in [do BYOC runners need NAT gateways](/answers/do-byoc-runners-need-a-nat-gateway). ### [Coverage and placement notes](#coverage-and-placement-notes)Copy Anchor Link BYOC runs on AWS, GCP, and Azure, and the private-subnet behavior described here is the AWS path, which is also the one with import mode. The BYOC price list covers Linux runners on AWS, GCP, and Azure, and Windows runners on AWS and Azure. The stack lives in one region of your own account and cannot be moved after creation, so pick the region alongside the subnet layout. For WarpBuild-hosted runners rather than BYOC, US and EU are the regions WarpBuild names publicly. The full prerequisite checklist, including the security group shape and the failure signatures above, is laid out in [BYOC networking on AWS](/byoc/aws/networking), and the architecture around it in the [BYOC on AWS overview](/byoc/aws). ## [Related Questions](#related-questions)Copy Anchor Link **How do I put BYOC runners in a private subnet?** Enable static IPs on the custom runner configuration. Runners with static IPs are created in private subnets behind NAT gateways, and their external addresses are the addresses of those gateways. With static IPs disabled, the instances are created in public subnets, which is the documented default in the [BYOC setup guide](/docs/ci/byoc). **What has to be true of the VPC before a private subnet works?** The VPC needs at least one public and one private subnet, both with internet connectivity, and the private subnets need NAT gateways. Route tables send internet-bound traffic through the Internet Gateway, and each subnet needs enough addresses for peak concurrency, with 250 as the documented minimum recommendation. The wider network shape is covered in [running GitHub Actions runners in your own VPC](/guides/github-actions-runners-in-your-vpc). **Does a private subnet change the workflow file?** No. Placement belongs to the runner configuration, so the workflow keeps the same `runs-on` label. Moving a job between a public-subnet runner set and a private-subnet runner set means pointing the label at a different runner ID. **What does the private path add to the bill?** Managed gateway charges billed by your own cloud account: $0.045 per hour per gateway plus $0.045 per GB processed in both directions in US East (N. Virginia), checked on 2026-08-13 against [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/). Runners in public subnets skip that meter. Set the network up against the [AWS BYOC configuration prerequisites](/docs/ci/byoc/aws/config), harden the security group with the [security guide](/docs/ci/byoc/aws/security), and price the fleet against your own minutes on the [pricing page](/pricing). --- # Can I Run Docker Compose in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-run-docker-compose-in-github-actions Description: Yes, on a Linux runner. Docker Compose v2 ships in the Ubuntu runner images, and stacks fail on runner memory and CPU rather than on compose itself. # Can I Run Docker Compose in GitHub Actions? Yes, on a Linux runner. Docker Compose v2 ships in the Ubuntu runner images, and stacks fail on runner memory and CPU rather than on compose itself. Last verified: 2026-08-13 Yes. Docker Compose v2 is preinstalled on the Ubuntu runner images ([Ubuntu 24.04 image manifest](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md)), so `docker compose up` runs in a job on any Linux GitHub Actions runner with no setup action in front of it. The thing that usually breaks is resource pressure rather than compose itself: five containers and a test process starting at once on a 2 vCPU runner with 8GB of memory exhaust the memory budget or miss their health check deadlines, and both failures read as flaky tests in the report. ## [Answer](#answer)Copy Anchor Link Compose needs a Docker daemon on the runner, which decides the platform question. The [cloud runners documentation](/docs/ci/cloud-runners) states that macOS runners do not support nested virtualization and cannot run Docker, so a compose stack has nothing to connect to there. Windows runners carry a daemon that runs Windows containers, which does not help a compose file written against Linux images. Compose work belongs on the Linux labels listed in the [Linux x64 runner catalog](/runners/linux-x64). The second decision is compose against the built-in `services` key. GitHub's [service containers](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidservices) start stock images alongside the job and map their ports, which covers a job that wants Postgres and Redis and nothing more. Compose earns its place when the file already exists in the repository for local development, when services depend on each other in an order you have to declare, or when the stack includes your own application image. The [service containers guide](/guides/service-containers-github-actions) works through the split in detail. Here is the shape that works, with the pull timed on its own, startup gated on health rather than on `sleep`, and teardown in an `always()` block so a failed suite still releases the volumes: Copy Text ``` name: compose on: [pull_request] jobs: stack: runs-on: warp-ubuntu-latest-x64-8x timeout-minutes: 30 steps: - uses: actions/checkout@v5 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Pull images run: docker compose -f compose.ci.yaml pull --quiet - name: Start the stack run: docker compose -f compose.ci.yaml up -d --wait --wait-timeout 300 - name: Run tests run: npm run test:integration - name: Dump logs on failure if: failure() run: docker compose -f compose.ci.yaml logs --no-color --tail=200 - name: Tear down if: always() run: docker compose -f compose.ci.yaml down -v ``` `--wait` blocks until every service reporting a health check is healthy, and `--wait-timeout` is expressed in seconds ([docker compose up reference](https://docs.docker.com/reference/cli/docker/compose/up/)). Services with no `healthcheck` block count as ready the moment the container starts, which for Postgres is several seconds before it accepts connections, so each service in the compose file needs its own [healthcheck definition](https://docs.docker.com/reference/compose-file/services/#healthcheck) for `--wait` to mean anything. ## [Detail](#detail)Copy Anchor Link ### [Sizing the runner against the stack](#sizing-the-runner-against-the-stack)Copy Anchor Link Memory is the binding constraint, because every container holds its working set at the same time. Add the peak resident memory of each container, the test process, and headroom for the Docker daemon and the page cache the database depends on, then take the first row that clears the total. Rates come from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Stack size | Typical shape | Runner label | vCPU | Memory | Per minute | | ------------------------- | ------------------------------------------- | -------------------------- | ---- | ------ | ---------- | | 1 to 2 services | One database, one cache | warp-ubuntu-latest-x64-2x | 2 | 8GB | $0.004 | | 3 services | Database, cache, application | warp-ubuntu-latest-x64-4x | 4 | 16GB | $0.008 | | 5 to 6 services | Adds a broker and a search node | warp-ubuntu-latest-x64-8x | 8 | 32GB | $0.016 | | 7 or more services | Adds a browser or a second JVM service | warp-ubuntu-latest-x64-16x | 16 | 64GB | $0.032 | | Several stacks in one job | Parallel compose projects on separate ports | warp-ubuntu-latest-x64-32x | 32 | 128GB | $0.064 | Every Linux x64 size carries 150GB SSD, so disk rarely decides the row. Read your own numbers with `docker stats --no-stream` at the end of a local run instead of trusting the shapes above. The same ladder exists on ARM64 from $0.003 to $0.048 per minute when the stack images are multi-architecture, per the [cloud runners documentation](/docs/ci/cloud-runners). CPU matters in one specific way. Health checks are processes competing for the same cores as the services they probe, so a saturated 2 vCPU runner produces probe timeouts, compose retries, and a stack that reports unhealthy for reasons that look like application bugs. ### [What the size costs against GitHub list prices](#what-the-size-costs-against-github-list-prices)Copy Anchor Link `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), which is 27 percent lower list price. GitHub list price checked on 2026-08-13 in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), with shapes from the [GitHub-hosted runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). At 600 pull request runs per month and 9 minutes per run, that is 5,400 minutes: $86.40 on the WarpBuild 8x label against $118.80 at GitHub's 8-core rate. ### [When the stack builds its own images](#when-the-stack-builds-its-own-images)Copy Anchor Link A `build:` section in the compose file turns the job into a build job. Every layer is built cold on a fresh runner, the build competes with the containers already running, and the test suite waits behind it. The alternative is to keep the image build off the runner. Build and push it on a remote Docker builder, which holds a persistent layer cache across runs, then let compose reference the pushed tag and pull it like any other image ([Docker builders documentation](/docs/ci/docker-builders)): Copy Text ``` - name: Build and push the application image uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}/api:${{ github.sha }} profile-name: "compose-stack" - name: Start the stack env: API_TAG: ${{ github.sha }} run: docker compose -f compose.ci.yaml up -d --wait --wait-timeout 300 ``` The compose file reads that tag with `image: ghcr.io/acme/api:${API_TAG:-main}` and drops its `build:` section entirely. Builder profiles are billed per session on top of the runner, starting at $0.06 per minute for the 16 vCPU, 32GB, 100GB disk profile ([Docker builders documentation](/docs/ci/docker-builders)), so the two resources are counted separately. [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) covers the builder route end to end. ### [Teardown that survives a failure](#teardown-that-survives-a-failure)Copy Anchor Link `down -v` in an `always()` step removes the containers and the named volumes whatever the suite did. That matters on any runner where the disk outlives the job, and it matters for the failure step ordering above: dump `logs` before teardown, because the containers stop existing after it. [Docker Compose integration tests on GitHub Actions](/solutions/docker-compose-integration-tests-github-actions) covers seeding, digest pinning, and carrying prepared images between runs. ## [Related Questions](#related-questions)Copy Anchor Link **Does Docker Compose work on macOS or Windows GitHub Actions runners?** Not on macOS. WarpBuild macOS runners do not support nested virtualization and cannot run Docker, so a compose stack has no daemon to talk to ([cloud runners documentation](/docs/ci/cloud-runners)). Windows runners have a Docker daemon that runs Windows containers, so a compose file built on Linux images does not start there. Keep compose stacks on the Linux labels in the [Linux x64 runner catalog](/runners/linux-x64). **Should I use Docker Compose or the GitHub Actions `services` key?** Use the [services key](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidservices) when the job needs a few stock backing services on their own ports and nothing else. Use compose when the file already exists in the repository, when services depend on each other in a defined order, or when developers run the same stack locally and you want one definition for both. The [service containers guide](/guides/service-containers-github-actions) has the comparison, and [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) covers building your own application image once the stack needs one. **Why does my compose stack die with exit code 137?** The kernel out-of-memory killer stopped a container because the stack asked for more memory than the runner has. Sum the peak memory of every container plus the test process, then move up the size ladder until the total clears with headroom. A five service stack near 10GB needs `warp-ubuntu-latest-x64-8x` with 32GB, priced on the [pricing page](/pricing). [Docker Compose integration tests on GitHub Actions](/solutions/docker-compose-integration-tests-github-actions) covers the seeding and digest pinning patterns that keep a stack like this reproducible. --- # Can I Run Docker on Windows Runners? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-run-docker-on-windows-github-actions-runners Description: Yes, for Windows containers. Windows GitHub Actions runners run a Windows Docker daemon, so Linux images need a Linux warp- runner or a remote Docker builder. # Can I Run Docker on Windows Runners? Yes, for Windows containers. Windows GitHub Actions runners run a Windows Docker daemon, so Linux images need a Linux warp- runner or a remote Docker builder. Yes, for Windows containers. A Windows GitHub Actions runner carries a Docker daemon in Windows container mode, so `docker build` against a Windows base image works on a `warp-windows-` label, while a Linux container image needs a Linux runner or a remote Docker builder because Linux containers need a Linux kernel underneath ([WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link The Windows runner images carry the same tooling as the GitHub-hosted Windows runners ([cloud runners documentation](/docs/ci/cloud-runners)), which includes Docker, and the package list is published in [actions/runner-images](https://github.com/actions/runner-images/blob/main/images/windows/Windows2022-Readme.md). What that daemon can build is the part that surprises teams: it serves Windows containers, and it has no Linux virtual machine behind it to serve Linux images. Route the work by the image you are producing. | What you are building | Where it runs | Label or profile | Per minute | | ---------------------------------------------------------- | ---------------------------------------------- | --------------------------------- | ------------------------ | | Windows container on an ltsc2022 base | Windows runner, local daemon | warp-windows-latest-x64-8x | $0.032 | | Windows container on an ltsc2025 base | Windows runner, local daemon | warp-windows-2025-x64-8x | $0.032 | | Linux image, single architecture | Linux runner, local daemon | warp-ubuntu-latest-x64-4x | $0.008 | | Linux image with a shared layer cache or two architectures | Remote Docker builder, driven from a Linux job | 16 vCPU, 32GB, 100GB disk profile | $0.06 per session minute | Rates come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13\. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so both halves of a split workflow live under the same account and the same bill. Three GitHub Actions features are Linux-only whatever provider runs the machine: the `container:` job key, the `services:` block, and actions that declare `runs.using: docker` ([GitHub Actions workflow syntax reference](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions), checked on 2026-08-13). A Windows job that needs Postgres for an integration test installs it on the runner or talks to a service started by a Linux job in the same workflow. Here is the split. The Windows work stays on Windows, and the image build goes to a remote Docker builder through a second job: Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: windows-build: runs-on: warp-windows-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: "9.0.x" - name: Build and test run: | dotnet build --configuration Release dotnet test --configuration Release --no-build - name: Publish the service run: dotnet publish src/Api -c Release -o out - uses: actions/upload-artifact@v4 with: name: api-publish path: out/ image: needs: windows-build runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: api-publish path: out/ - name: Build and push on the remote builder uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: registry.example.com/api:${{ github.sha }} profile-name: api-builder ``` The `image` job sits on a 2 vCPU Linux runner because the build itself executes on the builder virtual machine while the runner only drives it. Keep that driving step on a Linux job: the WarpBuild builder actions are documented against Linux runner labels, and the Windows job in this workflow needs no Docker daemon at all. One caveat to plan around before you write the caching steps: WarpBuild cache is available on Linux-based runners and is not supported on Windows-based runners ([caching documentation](/docs/ci/features/caching), repeated on the [cloud runners documentation](/docs/ci/cloud-runners)). A Windows job caches through `actions/cache@v4` instead. ## [Detail](#detail)Copy Anchor Link ### [Why a Windows container is pinned to the host release](#why-a-windows-container-is-pinned-to-the-host-release)Copy Anchor Link Windows containers share the host kernel, so the base image build number and the host build number have to line up. Process isolation requires a matching pair; anything else needs Hyper-V isolation, which runs the container in its own lightweight virtual machine ([Microsoft container version compatibility reference](https://learn.microsoft.com/en-us/virtualization/windowscontainers/deploy-containers/version-compatibility), checked on 2026-08-13). Hyper-V isolation needs nested virtualization on the host, and the WarpBuild support matrix records nested virtualization as unavailable on cloud Windows runners ([nested virtualization documentation](/docs/ci/features/nested-virtualization)). The practical rule is one line: pick the label whose Windows Server release matches your base image tag. `warp-windows-latest-x64-` resolves to Windows Server 2022 for `ltsc2022` images, and `warp-windows-2025-x64-` runs Windows Server 2025 for `ltsc2025` images. Both families cover 4, 8, 16, and 32 vCPU with 256GB SSD storage, at $0.016, $0.032, $0.064, and $0.128 per minute ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). ### [What the remote builder changes](#what-the-remote-builder-changes)Copy Anchor Link A remote Docker builder is a dedicated build virtual machine with a persistent layer cache on its own disk, reached over the Buildx remote driver with TLS certificates that the action fetches for the job ([Docker builders documentation](/docs/ci/docker-builders)). Two properties matter when a Windows pipeline hands off an image build. Billing runs per session, measured from the first job start to the last job completion on a profile, so a fan-out of parallel image builds sharing one profile produces one billable stretch rather than one per job. And builder profiles reach 192 vCPU with 384GB of memory, well past the 32 vCPU ceiling of the runner catalog, so a heavy image build does not force you to size the runner for it ([Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13). ### [What each route costs](#what-each-route-costs)Copy Anchor Link Take a repository that merges 400 pull requests a month. Every run compiles and tests on Windows for 8 minutes, then produces a Linux image. Rates below come from the [pricing page](/pricing) and the documentation linked above, checked on 2026-08-13. * Windows build and test on `warp-windows-latest-x64-8x`: 400 x 8 x $0.032 = **$102.40 per month**. * Image built on a Linux runner's local daemon, 6 minutes cold on `warp-ubuntu-latest-x64-4x`: 400 x 6 x $0.008 = **$19.20 per month**. * Image built on a 16 vCPU builder profile in 3 minutes, driven from `warp-ubuntu-latest-x64-2x`: runner 400 x 3 x $0.004 = $4.80, builder 400 x 3 x $0.06 = $72.00, for **$76.80 per month**. The builder line buys the persistent layer cache and the session billing above it. Two image builds that run in parallel on the same profile share one session, so a monorepo that pushes three images per run does not triple that $72.00. ## [Related Questions](#related-questions)Copy Anchor Link **Can a Windows GitHub Actions runner build a Linux container image?** Not with its local daemon, which serves Windows containers. Put the Linux image build in a second job on a Linux label such as `warp-ubuntu-latest-x64-4x`, or send it to a [remote Docker builder](/runners/docker-builders) driven from that job. **Do the container and services workflow keys work on a Windows runner?** No. GitHub Actions restricts the `container:` job key, the `services:` block, and Docker container actions to Linux runners ([workflow syntax reference](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions)). Install the dependency on the Windows runner, or move those steps to a Linux job. **Which Windows base image tag matches a WarpBuild Windows runner?** Match the tag to the host release: `ltsc2022` on the `warp-windows-latest-x64` labels and `ltsc2025` on the `warp-windows-2025-x64` labels. The full label list with sizes and rates is on the [Windows runner page](/runners/windows). **Does the WarpBuild cache work on a Windows Docker build?** No. WarpBuild cache covers Linux-based runners, and Windows-based runners are outside it, as covered in [whether caches work on Windows runners](/answers/do-github-actions-caches-work-on-windows-runners). Use `actions/cache@v4` for Windows build state, and let the builder profile hold the layer cache for Linux images. Start with the labels and rates on the [Windows runner page](/runners/windows), price the split against your own job minutes on the [pricing page](/pricing), and set up the image half with the [Docker builders documentation](/docs/ci/docker-builders). --- # Can I Run macOS Runners in the EU? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-run-macos-runners-in-the-eu Description: US and EU are the regions WarpBuild names publicly, and region selection applies to the runner fleet your org is assigned. macOS instances sit outside it. # Can I Run macOS Runners in the EU? US and EU are the regions WarpBuild names publicly, and region selection applies to the runner fleet your org is assigned. macOS instances sit outside it. US and EU are the regions WarpBuild names publicly, and region selection applies to the runner fleet your organization is assigned rather than to an individual job or workflow. macOS is the platform that setting does not reach today: the [cloud runners documentation](/docs/ci/cloud-runners) records that region-specific infrastructure is an enterprise-tier deployment option and is not applicable to macOS instances, so Xcode and Swift jobs run on the shared macOS fleet. ## [Answer](#answer)Copy Anchor Link Region selection is a fleet-level property. On the enterprise tier, cloud runners can be deployed on region-specific infrastructure so that data you choose stays in that region ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). The assignment is made for your organization by support, and every job that lands on those runners inherits it. Nothing in the workflow file selects a region, and no `runs-on` label encodes one. The same documentation carries the exclusion in the same sentence: region-specific infrastructure is not applicable to macOS instances. That single line is the whole answer for an iOS or Mac team, and it is worth reading precisely, because it draws a line through a fleet rather than through the product. | Question a reviewer asks | What region selection covers | Source | | --------------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------- | | Which regions can be named? | US and EU | [Cloud runners documentation](/docs/ci/cloud-runners) | | What scope does the setting have? | The runner fleet the organization is assigned to, set on the enterprise tier | [Cloud runners documentation](/docs/ci/cloud-runners) | | Do Linux and Windows cloud runners follow it? | Yes, they run on the assigned infrastructure | [Cloud runners documentation](/docs/ci/cloud-runners) | | Do macOS instances follow it? | No. The documentation states it is not applicable to macOS instances | [Cloud runners documentation](/docs/ci/cloud-runners) | | Does the setting change runner labels? | No. Labels are identical across accounts | [Cloud runners documentation](/docs/ci/cloud-runners) | WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and three of those four platforms take a region assignment. If your residency scope covers only the repositories that build server code, that split is usually enough, because the regulated checkout never reaches a Mac. **The compliance path when build data has to stay in one region.** Scope the requirement by repository rather than by organization. Put the repositories that carry regulated source and regulated fixtures on Linux and Windows labels served by the assigned fleet, and hold the macOS repositories out of that scope with a written note about where they execute. [EU data residency for GitHub Actions runners](/enterprise/data-residency-eu) sets out the paths, the data classes that stay in region, and the classes the control plane holds, which is the structure an Article 28 review expects. [Keeping GitHub Actions build data in the EU only](/answers/can-i-keep-builds-in-the-eu-only) covers the narrower version of the question, where nothing at all may leave the region. **The support route for high macOS concurrency in a specific region.** Placement beyond what the public catalog states runs through a support conversation. Send [support@warpbuild.com](mailto:support@warpbuild.com) the peak concurrent macOS job count, the images you need, and the region requirement, and ask what can be committed. ## [Detail](#detail)Copy Anchor Link ### [The macOS catalog is the same wherever the rest of your fleet sits](#the-macos-catalog-is-the-same-wherever-the-rest-of-your-fleet-sits)Copy Anchor Link Five macOS labels register against your GitHub organization, and no label carries a region segment. Rates and specifications come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13. | Runner label | OS | vCPU | Memory | Storage | Rate | | ----------------------- | -------- | ---- | ------ | --------- | ---------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 per minute | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 per minute | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 per minute | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 per minute | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 per minute | The `warp-macos-15` labels also answer to the aliases `warp-macos-latest-arm64-6x` and `warp-macos-latest-arm64-12x`. The [macOS runner catalog](/runners/macos) carries the full label grammar and the image contents. ### [What a split workflow looks like](#what-a-split-workflow-looks-like)Copy Anchor Link Most teams that hit this question run one repository with both server jobs and app jobs. The server job goes to a label served by the assigned fleet; the macOS job goes to the shared macOS fleet and gets named as such so a later audit does not have to guess. .github/workflows/ci.yml Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: api-tests: # Runs on the cloud runner fleet this organization is assigned to runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.24" - run: go test ./... ios-tests: # Runs on the shared macOS fleet: region-specific infrastructure # is not applicable to macOS instances runs-on: warp-macos-26-arm64-12x steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination "platform=iOS Simulator,name=iPhone 17" test ``` ### [Controls that hold on every runner regardless of placement](#controls-that-hold-on-every-runner-regardless-of-placement)Copy Anchor Link Placement is one control and it is not the only one a reviewer counts. Each runner has its own encrypted storage volume, created on demand and destroyed after each build, and cache written during a job is encrypted and stored in a location accessible only to your runner ([security documentation](/docs/ci/security), checked on 2026-08-13). If your organization restricts inbound traffic to GitHub with an IP allow list, the same page points to the control plane egress IPs your network team needs. ### [What a macOS month costs while that conversation happens](#what-a-macos-month-costs-while-that-conversation-happens)Copy Anchor Link Rates below are WarpBuild list prices from the [pricing page](/pricing), checked on 2026-08-13\. Take an app team running 250 pull request builds a month at 14 minutes each, which is 3,500 macOS minutes. * `warp-macos-26-arm64-6x` at $0.08 per minute: 3,500 x $0.08 = **$280 per month**. * `warp-macos-26-arm64-12x` at $0.16 per minute: 3,500 x $0.16 = **$560 per month**, before any reduction in minutes from the larger machine. ## [Related Questions](#related-questions)Copy Anchor Link **Which regions can WarpBuild place GitHub Actions runners in?** US and EU are the regions WarpBuild names publicly. Region selection is an enterprise-tier setting applied to the runner fleet your organization is assigned, and the [cloud runners documentation](/docs/ci/cloud-runners) records that it is not applicable to macOS instances. [EU data residency for GitHub Actions runners](/enterprise/data-residency-eu) walks the paths in full. **Does the region setting change my macOS runner labels or workflow file?** No. The five macOS labels are the same on every account, so a job keeps `runs-on` set to a label such as `warp-macos-26-arm64-6x` whatever region the rest of your fleet is assigned to. The [macOS runner catalog](/runners/macos) lists every label with its size and rate. **How do I get high macOS concurrency in a specific region?** Write to [support@warpbuild.com](mailto:support@warpbuild.com) with the peak job count, the platform mix, and the region requirement. Price your own macOS minutes on the [pricing page](/pricing), read the label specifications in the [macOS runner catalog](/runners/macos), and take the residency scope to [EU data residency for GitHub Actions runners](/enterprise/data-residency-eu). --- # Can I Run More Jobs Than My Plan Allows? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-run-more-jobs-than-my-github-plan-allows Description: Yes. The concurrent job ceiling on your GitHub plan counts jobs on GitHub-hosted labels only, so jobs routed to warp- labels queue on WarpBuild capacity. # Can I Run More Jobs Than My Plan Allows? Yes. The concurrent job ceiling on your GitHub plan counts jobs on GitHub-hosted labels only, so jobs routed to warp- labels queue on WarpBuild capacity. ## [Answer](#answer)Copy Anchor Link Yes, because the concurrent job ceiling attached to your GitHub plan counts jobs running on GitHub-hosted runners, and it stops counting at the point where a job is dispatched somewhere else. That ceiling runs from 20 concurrent jobs on Free to 500 on Enterprise with a lower macOS sub-ceiling, per [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13; a job whose `runs-on` value resolves to a `warp-` label is handed to a WarpBuild runner and queues on WarpBuild capacity instead. The boundary is drawn per job, by the `runs-on` value on that job. There is no account setting and no plan upgrade involved. Two jobs in the same workflow file can sit on opposite sides of it. | Where the job runs | Draws from the GitHub plan ceiling | What decides when it starts | | -------------------------------------------------------------------------- | ---------------------------------- | ----------------------------------------- | | ubuntu-latest, windows-latest, macos-latest and other GitHub-hosted labels | Yes | Free slots under your plan ceiling | | warp- labels | No | WarpBuild pool capacity for that label | | Self-hosted labels you register yourself | No | Count of matching runners you keep online | Per the [cloud runners documentation](/docs/ci/cloud-runners). Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That statement carries the scope written in the same documentation: features that are Generally Available support unlimited concurrency on Linux and Windows runners, and features in beta may carry limits. macOS runs on a per-organization quota, so teams that need high macOS concurrency arrange it with support at [support@warpbuild.com](mailto:support@warpbuild.com). Slack support channels are also available on demand, per the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [Move the fan-out, keep the rest](#move-the-fan-out-keep-the-rest)Copy Anchor Link Routing per job means the migration is a label change on the jobs that fan out. The workflow below keeps a single lint job on the GitHub-hosted pool and sends a 40-way integration matrix to `warp-ubuntu-latest-x64-4x`, so exactly one job in this file draws from the plan ceiling. Copy Text ``` name: integration on: pull_request: push: branches: [main] 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: strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] suite: [api, web, worker, jobs] runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run test:${{ matrix.suite }} -- --shard=${{ matrix.shard }}/10 ``` The matrix produces 10 shards by 4 suites, so 40 jobs are eligible to start together. On a Free-plan account with a 20-job ceiling, the same 40 jobs on `ubuntu-latest` would run in two waves. Nothing else in the file changes: the same steps, the same actions, the same Node version. ### [Limits that survive the move](#limits-that-survive-the-move)Copy Anchor Link Three limits belong to the repository or the workflow rather than to the runner, and they hold after the labels change. All three were checked on 2026-08-13 against [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits). * **256 jobs per matrix per workflow run.** The matrix above sits at 40\. Past 256, split the suite across workflows or collapse a dimension. * **The `concurrency` key you wrote.** A group holds one running item and one pending item regardless of which runner the work lands on. The [guide to GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) covers the group shapes that serialize a workflow by accident. * **24 hours of queue time.** A job waiting on a self-hosted or managed runner is dropped after 24 hours. A fourth failure mode looks like a capacity problem and is a routing problem: a job whose labels match zero online runners waits without logs. Check the label spelling and the runner group permissions in the [common issues documentation](/docs/ci/common-issues) before you conclude that a pool is full. ### [What the extra parallelism costs](#what-the-extra-parallelism-costs)Copy Anchor Link Assume the 40 integration jobs above each take 5 minutes, with no retries. Total compute is 200 job-minutes per run whether those jobs run in one wave or two, so the fan-out buys back wall clock and leaves billed minutes alone. | Slots available | Waves | Wall clock per run | Billed job-minutes | | --------------- | ----- | ------------------ | ------------------ | | 20 | 2 | 10 min | 200 | | 40 | 1 | 5 min | 200 | Priced at the catalog rate for `warp-ubuntu-latest-x64-4x`, 200 minutes at $0.008 per minute is $1.60 per run. At 300 pull request runs a month that is 60,000 job-minutes and $480.00, and the second row returns 5 minutes of wall clock on every one of those runs. | runs-on label | vCPU | RAM | Rate per minute | | --------------------------- | ---- | ---- | --------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | $0.008 | | warp-ubuntu-latest-arm64-4x | 4 | 16GB | $0.006 | | warp-windows-latest-x64-4x | 4 | 16GB | $0.016 | | warp-macos-latest-arm64-6x | 6 | 14GB | $0.080 | Full per-minute rates by runner type are on the [pricing page](/pricing). ### [Confirm the ceiling moved](#confirm-the-ceiling-moved)Copy Anchor Link Measure queue wait per runner label before and after the label change rather than reading the running job count alone. A label whose P90 queue wait stays flat while the number of jobs running together climbs past the old plan ceiling is the signal that the ceiling stopped binding. A P90 that climbs on one label while other labels start on time points at capacity for that label. The guide to [GitHub Actions queue times](/guides/github-actions-queue-times) walks through each reading and the fix that matches it. ## [Related Questions](#related-questions)Copy Anchor Link ### [Which jobs count against my GitHub plan ceiling?](#which-jobs-count-against-my-github-plan-ceiling)Copy Anchor Link Jobs whose `runs-on` resolves to a GitHub-hosted label such as `ubuntu-latest`, `windows-latest`, or `macos-latest`. The ceiling runs from 20 concurrent jobs on Free to 500 on Enterprise, checked on 2026-08-13 against [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits). Jobs that target `warp-` labels are dispatched to WarpBuild runners and queue on WarpBuild capacity instead. The full breakdown of every ceiling between a queued job and a running one is in [how many GitHub Actions jobs can run at once](/answers/how-many-github-actions-jobs-can-run-at-once). ### [Do I have to move every job to raise the ceiling?](#do-i-have-to-move-every-job-to-raise-the-ceiling)Copy Anchor Link No. Routing is per job, so a workflow can keep a small lint job on `ubuntu-latest` and send a 40-way test matrix to `warp-ubuntu-latest-x64-4x`. Only the jobs still on GitHub-hosted labels draw from the plan ceiling after that, which makes the migration reversible one job at a time. ### [Which limits still apply after the move?](#which-limits-still-apply-after-the-move)Copy Anchor Link Repository-level limits stay in force. A matrix still generates at most 256 jobs per workflow run, your `concurrency` key still throttles the groups you named, and a job queued for a self-hosted or managed runner is dropped after 24 hours. The [guide to GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) maps each of those to the symptom it produces. Price your own fan-out against the per-minute rates on the [pricing page](/pricing), then change the `runs-on` value on one matrix job and watch where the queue moves. --- # Can I Run Unit Tests in Parallel on Windows Runners? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-run-unit-tests-in-parallel-on-windows-runners Description: Yes. Raise the test runner's worker count on one larger Windows runner, or shard the suite across several jobs. The two shapes carry different cost profiles. # Can I Run Unit Tests in Parallel on Windows Runners? Yes. Raise the test runner's worker count on one larger Windows runner, or shard the suite across several jobs. The two shapes carry different cost profiles. Last verified: 2026-08-13 Yes. A Windows GitHub Actions job runs unit tests in parallel two ways: raise the test runner's worker count so one larger machine executes several tests at once, or shard the suite across several jobs that each run a slice. Both are supported on WarpBuild Windows runners, and they carry different cost profiles, so the choice is an arithmetic question rather than a capability question. ## [Answer](#answer)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Windows fleet is x86-64 in four shapes, from 4 vCPU with 16GB of memory to 32 vCPU with 128GB, all on 256GB SSD storage ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). The 2 vCPU Windows shapes were removed on June 8, 2026, so 4 vCPU is the smallest machine a test job can start on. Worker count is the first lever. A test runner that spawns one worker per vCPU turns the machine's cores into parallel test execution, and the memory each worker gets falls out of the shape you pick. | Runner label | vCPU | Memory | Per minute | Workers at one per vCPU | Memory per worker | Cost per worker-minute | | --------------------------- | ---- | ------ | ---------- | ----------------------- | ----------------- | ---------------------- | | warp-windows-latest-x64-4x | 4 | 16GB | $0.016 | 4 | 4GB | $0.004 | | warp-windows-latest-x64-8x | 8 | 32GB | $0.032 | 8 | 4GB | $0.004 | | warp-windows-latest-x64-16x | 16 | 64GB | $0.064 | 16 | 4GB | $0.004 | | warp-windows-latest-x64-32x | 32 | 128GB | $0.128 | 32 | 4GB | $0.004 | Rates and shapes come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The last column is the row's per-minute rate divided by its vCPU count, and it is $0.004 at every size. That constant is what makes the two parallel shapes comparable: 16 vCPU of Windows compute bills $0.064 per minute whether it arrives as one 16 vCPU job or as four 4 vCPU jobs. Sharding is the second lever, and it needs concurrency. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and generally available features support unlimited concurrency on Linux and Windows runners ([cloud runners documentation](/docs/ci/cloud-runners)), so a matrix of eight Windows shards starts eight machines rather than queueing behind a fleet limit. One Windows detail applies to both shapes. WarpBuild caches are not supported for Windows-based runners ([cloud runners documentation](/docs/ci/cloud-runners)), so every Windows job restores packages with `actions/cache` or over the network. That restore is fixed work per job, which is what makes shard count the expensive variable. Every label, image family, and alias is on the [Windows runners page](/runners/windows). ## [Detail](#detail)Copy Anchor Link ### [Shape one: workers on a single larger runner](#shape-one-workers-on-a-single-larger-runner)Copy Anchor Link One job, one machine, one checkout, one restore, one build. The test runner does the parallel work. Copy Text ``` name: windows-tests-single-machine on: pull_request: jobs: test: runs-on: warp-windows-latest-x64-16x steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: "9.0.x" - uses: actions/cache@v4 with: path: ~\.nuget\packages key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} - run: dotnet restore --locked-mode - run: dotnet build --configuration Release --no-restore - name: Run the suite with 16 workers env: MSBUILDDISABLENODEREUSE: 1 run: > dotnet test --configuration Release --no-build -- xUnit.MaxParallelThreads=16 xUnit.ParallelizeAssembly=true ``` `xUnit.MaxParallelThreads` and `xUnit.ParallelizeAssembly` are runner configuration values passed through `dotnet test` after the `--` separator ([xUnit parallelism documentation](https://xunit.net/docs/running-tests-in-parallel), [dotnet test reference](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-test)). Without `ParallelizeAssembly`, xUnit parallelizes test collections inside one assembly and runs assemblies one after another, which caps the useful worker count at the widest collection in the slowest assembly. ### [Shape two: shards across several smaller runners](#shape-two-shards-across-several-smaller-runners)Copy Anchor Link Four jobs, four machines, a shard index selecting the slice each one runs. The matrix comes from `strategy.matrix` in the workflow syntax ([GitHub Actions workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)). Copy Text ``` name: windows-tests-sharded on: pull_request: jobs: test: strategy: fail-fast: false matrix: shard: [0, 1, 2, 3] runs-on: warp-windows-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: "9.0.x" - uses: actions/cache@v4 with: path: ~\.nuget\packages key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} - run: dotnet restore --locked-mode - run: dotnet build --configuration Release --no-restore - name: Run shard ${{ matrix.shard }} of 4 shell: pwsh env: SHARD_INDEX: ${{ matrix.shard }} SHARD_TOTAL: 4 MSBUILDDISABLENODEREUSE: 1 run: | $projects = Get-ChildItem -Recurse -Filter *.Tests.csproj | Sort-Object FullName for ($i = 0; $i -lt $projects.Count; $i++) { if ($i % [int]$env:SHARD_TOTAL -ne [int]$env:SHARD_INDEX) { continue } dotnet test $projects[$i].FullName --configuration Release --no-build ` -- xUnit.MaxParallelThreads=4 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } ``` `fail-fast: false` keeps the other three shards running when one fails, which is what you want while a suite is still being made parallel-safe. Sorting the project list keeps the assignment stable across runs so a shard failure is reproducible. Modulo over a sorted project list is the simplest split and the least balanced one. Splitting by measured duration is what keeps the slowest shard from setting wall clock for everyone; the [matrix sharding guide](/guides/matrix-sharding-github-actions) covers building that list from previous run timings. ### [What the two shapes cost](#what-the-two-shapes-cost)Copy Anchor Link Assume a suite with 60 core-minutes of test execution and a fixed phase of 4 minutes per job for checkout, package restore, and build. The fixed phase is mostly single-threaded, so it does not shrink when the machine gets bigger. Billing is per minute of runner time, so each job rounds up to a whole minute. | Shape | Machines | Total vCPU | Billed minutes per job | Machine-minutes per run | Cost per run | Wall clock | | ------------------------------- | ------------------------------- | ---------- | --------------------------- | ----------------------- | ------------ | ---------- | | One 16x, 16 workers | 1 x warp-windows-latest-x64-16x | 16 | 4 + 60/16 = 7.75, billed 8 | 8 | $0.512 | 8 min | | Two 8x shards, 8 workers each | 2 x warp-windows-latest-x64-8x | 16 | 4 + 30/8 = 7.75, billed 8 | 16 | $0.512 | 8 min | | Four 4x shards, 4 workers each | 4 x warp-windows-latest-x64-4x | 16 | 4 + 15/4 = 7.75, billed 8 | 32 | $0.512 | 8 min | | Eight 4x shards, 4 workers each | 8 x warp-windows-latest-x64-4x | 32 | 4 + 7.5/4 = 5.875, billed 6 | 48 | $0.768 | 6 min | The first three rows land on the same bill because they buy the same 16 vCPU at the same $0.004 per vCPU-minute. The fourth row is the real trade: eight shards finish 2 minutes sooner and cost $0.256 more per run, because the 4-minute fixed phase is paid eight times instead of four. At 600 pull request runs a month that is $307.20 against $460.80, a difference of $153.60\. Substitute your own fixed phase and core-minutes; the shape of the answer only changes when the fixed phase changes. Two corrections to apply to your own numbers. First, workers on one machine share one disk, one network interface, and one memory bus, so the test phase rarely divides cleanly by worker count once workers are I/O heavy. Second, shards that finish early stop billing while a single large machine bills its full duration, so a skewed suite loses less money on shards than the table suggests and more wall clock. Per-minute rates set what all of those minutes are worth. `warp-windows-latest-x64-16x` (16 vCPU, 64 GB) costs $0.064 per minute against $0.082 per minute for the 16-core Windows larger runner (16 vCPU, 64 GB): 22 percent lower list price. GitHub list price checked on 2026-08-13, from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) with shapes from the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Every Windows rate is on the [pricing page](/pricing). ### [Where parallel tests break on Windows](#where-parallel-tests-break-on-windows)Copy Anchor Link A suite that passes serially and fails at 16 workers is usually hitting one of three things. **Port collisions.** Tests that start a listener on a fixed port collide the moment two workers do it at once, and the failure surfaces as a socket exception saying only one usage of each socket address is permitted. Bind to port 0 and read the assigned port back from the listener. Windows also holds closed sockets in TIME\_WAIT, so a suite that opens and closes many listeners across 16 workers can drain the ephemeral range; run `netsh int ipv4 show dynamicport tcp` on the runner to print the range the image is configured with ([Windows dynamic port range documentation](https://learn.microsoft.com/en-us/troubleshoot/windows-server/networking/default-dynamic-port-range-tcpip-chang)). **Shared temp paths.** Every worker in one job inherits the same `TEMP` and `TMP`, so a fixture writing a fixed filename under the temp directory races against itself. Inside a single machine the fixture has to create a unique subdirectory per worker. Across shards, isolate at the job level instead, using `RUNNER_TEMP` ([GitHub Actions variables reference](https://docs.github.com/en/actions/reference/workflows-and-actions/variables)): Copy Text ``` - name: Give the shard its own scratch directory shell: pwsh run: | $scratch = Join-Path $env:RUNNER_TEMP "shard-${{ matrix.shard }}" New-Item -ItemType Directory -Force -Path $scratch | Out-Null "TMP=$scratch" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "TEMP=$scratch" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 ``` **File locking.** Windows holds a mandatory lock on an open file, so a worker deleting or overwriting a file another worker still has open fails with an IOException saying the process cannot access the file. The usual sources are a shared fixture directory, a test that rewrites an assembly on disk, and MSBuild worker nodes that outlive the build and keep assemblies loaded. `MSBUILDDISABLENODEREUSE: 1` in the job environment, or `-nodeReuse:false` on the command line, closes the last one ([MSBuild command-line reference](https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-command-line-reference)). Once the suite is parallel-safe, size it from data rather than from guesses. The Jobs report gives duration P75 and P90 plus CPU P75 and memory P75 for each repository, workflow, and job name, and the CPU and memory columns need Observability enabled ([reports documentation](/docs/ci/features/reports)). Raise the worker count while CPU P75 climbs, and stop when memory P75 approaches the shape's 4GB per vCPU. ## [Related Questions](#related-questions)Copy Anchor Link ### [How many test workers should I run on one Windows runner?](#how-many-test-workers-should-i-run-on-one-windows-runner)Copy Anchor Link Start at one worker per vCPU, which is 4 workers on `warp-windows-latest-x64-4x` and 16 on `warp-windows-latest-x64-16x`. Every Windows shape carries 4GB of memory per vCPU, so that setting also gives each worker 4GB. Then read CPU P75 and memory P75 for the job in the [reports documentation](/docs/ci/features/reports) surfaces and move the number until CPU sits high without memory pressure. Shapes and rates for each label are on the [Windows runners page](/runners/windows). ### [Does sharding across jobs cost more than one larger runner?](#does-sharding-across-jobs-cost-more-than-one-larger-runner)Copy Anchor Link Not by itself. The Windows ladder prices at a constant $0.004 per vCPU-minute, so 16 vCPU costs $0.064 per minute whether it is one 16 vCPU job or four 4 vCPU jobs. The cost rises when you add shards, because every extra shard repeats the checkout, restore, and build phase and pays for it again. Model your own split against the rates on the [pricing page](/pricing) and build a balanced split with the [matrix sharding guide](/guides/matrix-sharding-github-actions). ### [Can I use the WarpBuild cache to skip package restore on every shard?](#can-i-use-the-warpbuild-cache-to-skip-package-restore-on-every-shard)Copy Anchor Link No. WarpBuild caches are not supported for Windows-based runners ([cloud runners documentation](/docs/ci/cloud-runners)). Windows shards use `actions/cache` for the NuGet package folder and any build output you want to carry between jobs, the same way they would on a GitHub-hosted Windows runner. The [.NET test parallelism guide](/guides/dotnet-test-parallelism-github-actions) covers keying that cache off `packages.lock.json` so every shard restores from the same entry. --- # Can I Set a Spending Limit on GitHub Actions Runners? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-set-a-spending-limit-on-github-actions-runners Description: A spending limit on GitHub Actions runners lives in the workflow file: runner labels, timeout-minutes, concurrency groups, and path filters cap billed minutes. # Can I Set a Spending Limit on GitHub Actions Runners? A spending limit on GitHub Actions runners lives in the workflow file: runner labels, timeout-minutes, concurrency groups, and path filters cap billed minutes. A spending limit on GitHub Actions runners is set in the workflow file rather than on the invoice, and the four controls that put a real ceiling on the bill are the runner label, `timeout-minutes`, the concurrency group, and path filters. Each one caps minutes before they are spent, and each one is a few lines of YAML you can merge today. ## [Answer](#answer)Copy Anchor Link A budget or spending limit configured in GitHub billing settings applies to the products GitHub meters. Jobs that run on self-hosted or third-party runner labels are not billed by GitHub at all, so they never draw that limit down ([GitHub Actions billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13). A number entered in billing settings therefore does nothing to a job already running on a `warp-` label. On the WarpBuild side the bill is billed minutes multiplied by the rate of the label the job holds. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the only two terms available to bound are how many minutes run and what each minute costs. All four controls below act on one of those two terms. | Control | Which term it caps | Ceiling it sets | Where it goes | | ----------------------------------- | ------------------------------------------------------ | ---------------------------------------------------- | ---------------------------- | | Runner label | The rate of every billed minute | Fixed by the label, from $0.003 to $0.128 per minute | runs-on | | timeout-minutes | The minutes one job can bill | The value you set; 360 minutes if you set nothing | Job level, or per step | | concurrency with cancel-in-progress | The minutes burned by runs a newer push has superseded | One run per group in flight | Workflow or job level | | paths filters | How many runs start at all | Runs only on commits touching the listed paths | on.push and on.pull\_request | The 360 minute default and the concurrency behavior come from the [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidtimeout-minutes), checked on 2026-08-13\. A `paths` filter is evaluated before the run is created, so a filtered out workflow produces no run and no billable job ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax), checked on 2026-08-13). WarpBuild rates come from the [pricing page](/pricing), also checked on 2026-08-13. ## [Detail](#detail)Copy Anchor Link ### [The rate the label locks in](#the-rate-the-label-locks-in)Copy Anchor Link The price of a job minute is decided the moment the label is written. The last column is the worst case for a single job that hangs and rides the 360 minute default with no `timeout-minutes` set. | Runner label | Shape | Rate | One job at the 360 minute default | | --------------------------- | --------------- | ---------- | --------------------------------- | | warp-ubuntu-latest-arm64-2x | 2 vCPU, 8 GB | $0.003/min | $1.08 | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004/min | $1.44 | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006/min | $2.16 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008/min | $2.88 | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016/min | $5.76 | | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08/min | $28.80 | | warp-windows-latest-x64-32x | 32 vCPU, 128 GB | $0.128/min | $46.08 | Rates from the [pricing page](/pricing), checked on 2026-08-13\. A test suite pinned to the 4 vCPU x64 label that would finish in the same wall-clock time on the ARM64 shape of the same size pays $0.002 more for every minute it runs, which is the cheapest structural change on the list. ### [What each of the other three controls is worth](#what-each-of-the-other-three-controls-is-worth)Copy Anchor Link `timeout-minutes` bounds the worst case per job. The two hung jobs in the table above cost $2.88 and $28.80 at the default. The same two jobs with `timeout-minutes: 20` and `timeout-minutes: 45` bill at most $0.16 and $3.60\. One line per job converts an open-ended exposure into a number you can multiply by the number of jobs in the repository. Concurrency bounds redundancy. A pull request that receives five pushes in an afternoon starts five runs of the same 20 minute macOS job, which is 100 minutes and $8.00 at $0.08 per minute if every run finishes. With `cancel-in-progress: true`, the four superseded runs bill only the minutes they ran before cancellation reached them, roughly 3 minutes each, so the afternoon is 12 minutes of cancelled work at $0.96 plus $1.60 for the run that survives: $2.56 in total. Path filters bound volume. In a monorepo doing 400 pull request runs a month where iOS code changes on 90 of them, an ungated 20 minute macOS job bills 8,000 minutes, or $640.00 at $0.08 per minute. Gating the same job on `ios/**` brings it to 1,800 minutes, or $144.00, and the 310 filtered runs create no job at all. ### [One expensive job carrying three ceilings](#one-expensive-job-carrying-three-ceilings)Copy Anchor Link Copy Text ``` name: ios on: pull_request: paths: - "ios/**" - "Package.resolved" - ".github/workflows/ios.yml" concurrency: group: ios-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: xcode-build: runs-on: warp-macos-latest-arm64-6x timeout-minutes: 45 steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build ``` The ceiling on this job is now arithmetic you can hand to a finance team. Forty five minutes at $0.08 per minute is $3.60 for the worst run, only pull requests touching `ios/**` start it, and one run per branch is in flight at a time. Ten branches pushing at once bound the wave at $36.00\. Without the timeout, the same worst run is $28.80. ### [The monitoring loop that watches the number](#the-monitoring-loop-that-watches-the-number)Copy Anchor Link Ceilings bound the worst case. Daily data tells you the actual case. The Billing section of [Reports](/docs/ci/features/reports) carries a daily cost chart grouped by repository or by runner label, summary cards for total, runner, and snapshot cost, and a per-job table with execution time, billed time, and the cost split. Every tab exports to CSV. Create an API key with the `ci` and `cache` scopes and authenticate with an `Authorization: Bearer wkey-...` header, as described in the [automation documentation](/docs/ci/api-keys/automation). Then run the check on a schedule. Copy Text ``` name: runner-spend-guard on: schedule: - cron: "0 13 * * *" jobs: check: runs-on: warp-ubuntu-latest-x64-2x timeout-minutes: 5 steps: - uses: actions/checkout@v4 - run: ./scripts/check-runner-spend.sh env: WARPBUILD_API_KEY: ${{ secrets.WARPBUILD_API_KEY }} MONTHLY_BUDGET_USD: "800" SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} ``` The guard job runs on a 2 vCPU runner at $0.004 per minute and finishes inside a minute, so 30 daily runs cost about $0.12 a month. Two alert rules cover most of what goes wrong: month-to-date total against the budget, and any single runner label whose daily cost doubles day over day. The second rule catches the matrix that grew in a merged pull request nobody costed. ### [What a ceiling cannot do](#what-a-ceiling-cannot-do)Copy Anchor Link Ceilings cap minutes. They do not make a slow job fast, and a timeout that fires turns a cost problem into a failed run, so set the value above the p95 duration of the job rather than at its median. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so nothing else on the invoice moves when headcount or repository count changes. Signup includes $10 free credits, which is enough to run the four controls above against a real repository before an invoice exists. The full rate list is on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does a spending limit in GitHub billing settings cover third-party runner minutes?](#does-a-spending-limit-in-github-billing-settings-cover-third-party-runner-minutes)Copy Anchor Link No. GitHub meters Actions minutes for GitHub-hosted runners, and jobs on self-hosted or third-party labels are not billed by GitHub ([GitHub Actions billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13), so a limit set there never sees them. Budget the workflow instead, using the ceilings above and the process in the [GitHub Actions minutes budget guide](/guides/github-actions-minutes-budget). ### [What is the default job timeout in GitHub Actions?](#what-is-the-default-job-timeout-in-github-actions)Copy Anchor Link 360 minutes, per the [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidtimeout-minutes), checked on 2026-08-13\. At that ceiling one hung job bills $2.88 on `warp-ubuntu-latest-x64-4x` and $28.80 on `warp-macos-latest-arm64-6x` at the rates on the [pricing page](/pricing). The [job timeout definition](/glossary/job-timeout) covers step level timeouts and how cancellation interacts with billed time. ### [How do I get alerted before the runner bill grows?](#how-do-i-get-alerted-before-the-runner-bill-grows)Copy Anchor Link Pull the daily cost figures from [Reports](/docs/ci/features/reports), compare month-to-date spend against your budget, and post to Slack on the crossing. The [runner spend forecast answer](/answers/how-do-i-forecast-runner-spend-for-next-quarter) turns the same daily series into a quarterly number, and the rates behind both models are on the [pricing page](/pricing). --- # Can I Share a Cache Between GitHub Actions Workflows? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-share-a-cache-between-github-actions-workflows Description: Yes. Cache entries are addressed by key, version, and branch rather than by workflow, so any workflow in the same repository can restore what another saved. # Can I Share a Cache Between GitHub Actions Workflows? Yes. Cache entries are addressed by key, version, and branch rather than by workflow, so any workflow in the same repository can restore what another saved. Last verified: 2026-08-13 Yes. A cache entry is addressed by three things, its key, its version, and the branch that wrote it, and the name of the workflow that created it is absent from that address, so any workflow in the same repository can restore an entry that another workflow saved. The work is in making the address match: two workflows that ask for the same key but cache a different list of paths compute a different version and miss each other silently. ## [Answer](#answer)Copy Anchor Link The address of a cache entry has exactly three parts. **The key** is the string you write in the `key` input. It is yours to choose, and it is the only part of the address you control directly. **The version** is a hash generated over the compression tool in use and the list of paths being cached. It is computed by the caching client rather than written by you, and it is matched alongside the key. The hash function lives in the [actions/toolkit cache package](https://github.com/actions/toolkit), and the [WarpBuild caching documentation](/docs/ci/features/caching) describes the same behavior for `WarpBuilds/cache@v1`. **The branch** scopes visibility. An entry written on a branch is restorable from that branch, and entries written on the default branch are restorable from branches cut off it, per GitHub's [dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching). Everything else about the run is absent from the address. Here is the full split. | Property of the run | Part of the cache address | What it means in practice | | ------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------- | | key input | Yes, directly | Two workflows sharing an entry must write the same key or a matching restore-keys prefix | | List of cached path entries | Yes, through the version hash | Adding one directory to the list in one workflow breaks sharing with the other | | Compression tool (zstd or gzip) | Yes, through the version hash | A container without zstd falls back to gzip and computes a different version | | Branch that saved the entry | Yes | Feature branches read the default branch; sibling feature branches do not read each other | | Workflow file name | No | build.yml and test.yml reach the same entry | | Job name and job id | No | Renaming a job leaves the address unchanged | | Runner size | No | An entry saved on an 8 vCPU runner restores on a 2 vCPU runner | | Runner OS and architecture | Indirectly | Different platforms mean different compression tooling, so different versions | So the answer to the question is yes, within one repository, subject to the branch rule. Cross-repository sharing is out of scope for the cache, and the [share data between GitHub Actions jobs guide](/guides/share-data-between-github-actions-jobs) covers the handoff patterns that do cross those boundaries. WarpBuild's cache follows the same addressing model. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4`, accepting the same `key`, `path`, and `restore-keys` inputs, so a workflow that already shares a cache correctly keeps sharing it after the swap. The cache is enabled by default on Linux runners and is not supported on WarpBuild Windows runners, which is documented under limitations in the [caching documentation](/docs/ci/features/caching). ## [Detail](#detail)Copy Anchor Link ### [What actually blocks sharing](#what-actually-blocks-sharing)Copy Anchor Link Nearly every failed sharing attempt is a version mismatch rather than a key mismatch, because the key is visible in the workflow file and the version is not. Consider two workflows that both want the dependency cache. The first caches one directory: Copy Text ``` - uses: WarpBuilds/cache@v1 with: path: node_modules key: deps-${{ hashFiles('bun.lock') }} ``` The second caches two: Copy Text ``` - uses: WarpBuilds/cache@v1 with: path: | node_modules .cache/turbo key: deps-${{ hashFiles('bun.lock') }} ``` The keys are identical. The versions are not, because the path list feeds the hash. The second workflow will never restore what the first one saved, and neither run reports an error. The restore step logs a miss and the job carries on doing the work the cache was supposed to remove. The same mechanism explains the platform rule. An entry saved on `warp-macos-latest-arm64-6x` cannot restore on `warp-ubuntu-latest-x64-4x`, because the compression tool differs between the two and the version therefore differs. A key containing `${{ runner.os }}` makes that separation visible in the key as well, which is worth doing purely so the miss is legible in the log. Containers are the third case. WarpBuild runners ship `zstd`, but a job running inside a custom container image that lacks it falls back to gzip. The restore step logs `zstd version: null` followed by a 404 warning, which is the version mismatch surfacing as a missing entry. Installing `zstd` in the container image restores the match. ### [The branch rule, and how to work with it](#the-branch-rule-and-how-to-work-with-it)Copy Anchor Link Branch scope is the second constraint, and it is the one that decides where the entry should be written. An entry saved on a feature branch is not readable from another feature branch. An entry saved on the default branch is readable from branches based on it. That asymmetry has one clean consequence for cross-workflow sharing: the workflow that writes the shared entry should run on the default branch, and the workflows that consume it should restore only. Splitting write and read is what `WarpBuilds/cache/restore@v1` and `WarpBuilds/cache/save@v1` exist for. The consuming workflows use the restore half and never write, so pull request runs cannot poison the shared entry, and storage does not grow one entry per branch. ### [A shared prefix and restore-keys across two workflows](#a-shared-prefix-and-restore-keys-across-two-workflows)Copy Anchor Link This is the pattern the question is usually asking for. One workflow on the default branch owns the entry. Another workflow on pull requests reads it. The `path` block and the key prefix are byte-identical in both files, which is the part that has to be right. Copy Text ``` # .github/workflows/build.yml name: build on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - name: Restore or seed dependency cache uses: WarpBuilds/cache@v1 with: path: | node_modules .cache/turbo key: deps-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('bun.lock') }} restore-keys: | deps-${{ runner.os }}-${{ runner.arch }}- - run: bun install --frozen-lockfile - run: bun run build ``` Copy Text ``` # .github/workflows/test.yml name: test on: pull_request jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Restore dependency cache id: deps uses: WarpBuilds/cache/restore@v1 with: path: | node_modules .cache/turbo key: deps-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('bun.lock') }} restore-keys: | deps-${{ runner.os }}-${{ runner.arch }}- - run: bun install --frozen-lockfile - run: bun run test ``` Three details make this work. The `restore-keys` prefix is what carries the sharing across lockfile changes. When `bun.lock` moves, the exact key misses, and the prefix `deps-Linux-X64-` matches the most recent entry written under that prefix instead. The job starts from a nearly correct dependency tree and `bun install --frozen-lockfile` reconciles the difference. The runner sizes differ on purpose. `build.yml` uses `warp-ubuntu-latest-x64-8x` and `test.yml` uses `warp-ubuntu-latest-x64-4x`, and they still share, because machine size is absent from the cache address. Only the platform matters. The install step is unguarded on purpose. Skipping it on a cache hit is tempting and wrong when the hit came through `restore-keys`, because a prefix match means the entry is stale by definition. Let the package manager reconcile. ### [Setup actions that handle the key for you](#setup-actions-that-handle-the-key-for-you)Copy Anchor Link When the shared content is a language toolchain or a package manager directory, the key and the path list are both computed by the setup action, which means two workflows using the same action against the same lockfile path share automatically with no key management at all. WarpBuild maintains cache-enabled forks of the common setup actions, listed in the [setup actions documentation](/docs/ci/features/caching/setup-actions). They accept the same inputs as their upstream counterparts and route caching to WarpBuild Cache when they run on WarpBuild runners. | Action | Replaces | What it caches | | ----------------------------------------- | -------------------- | -------------------------------------------- | | WarpBuilds/setup-node@v6 | actions/setup-node | npm, yarn, and pnpm dependencies | | WarpBuilds/setup-python@v6 | actions/setup-python | pip, pipenv, and poetry dependencies | | WarpBuilds/setup-go@v6 | actions/setup-go | Go modules and build outputs | | WarpBuilds/setup-java@v5 | actions/setup-java | Maven, Gradle, and sbt dependencies | | WarpBuilds/rust-cache@v2 | Swatinem/rust-cache | Cargo registry, git dependencies, and target | | WarpBuilds/gradle-actions/setup-gradle@v5 | gradle/actions | Gradle User Home | | WarpBuilds/mise-action@v2 | jdx/mise-action | Tools installed by mise | The catch is the same one as before, one level down. `WarpBuilds/setup-node@v6` builds its key from `cache-dependency-path`, so two workflows that point that input at different lockfiles get different keys and do not share. Keeping the input identical across the workflow files is the whole requirement. ### [What a shared cache costs](#what-a-shared-cache-costs)Copy Anchor Link Entries expire after 7 days without use, so the storage bill tracks the entries a repository actually touches. Rates below are from the [pricing page](/pricing) and the caching documentation, checked on 2026-08-13. | Metric | Hosted runners | BYOC | | ----------------------------- | --------------------- | ---- | | Cache storage | $0.20 per GB-month | Free | | Cache write, restore, or list | $0.0001 per operation | Free | Here is a month for a repository with three workflows sharing one entry. The assumptions are stated so you can substitute your own: 40 pull request pushes per weekday across 22 weekdays, which is 880 pushes; three workflows per push, each restoring the shared entry once; 120 merges to the default branch per month, each writing one entry; a 2.5 GB entry on the default branch held all month; and 30 feature branches that each write their own 2.5 GB entry and let it expire after 7 days. | Line | Monthly quantity | Rate | Cost | | ---------------------------------- | ---------------- | --------------------- | ----- | | Restores across three workflows | 2,640 operations | $0.0001 per operation | $0.26 | | Writes from the default branch | 120 operations | $0.0001 per operation | $0.01 | | Shared default-branch entry | 2.5 GB-months | $0.20 per GB-month | $0.50 | | Per-branch entries, 7-day lifetime | 17.5 GB-months | $0.20 per GB-month | $3.50 | | Total | $4.27 | | | Set that against the runner minutes the shared entry removes. If a restore saves 2 minutes of dependency installation per job, the 2,640 jobs above skip 5,280 runner minutes, which on `warp-ubuntu-latest-x64-4x` at $0.008 per minute is $42.24 of runner billing that never happens. The per-branch entries are the sensitive line: dropping them by making pull request workflows restore-only, as `test.yml` above does, removes $3.50 of the $4.27 and leaves the sharing intact. Two structural notes belong with any of these numbers. The [persistent caches guide](/guides/persistent-caches-github-actions) covers the cases where a whole-disk approach beats path-level caching, and [what GitHub Actions cache storage costs](/answers/what-does-github-actions-cache-storage-cost) works the storage arithmetic in more detail. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can two jobs in the same workflow share a cache?](#can-two-jobs-in-the-same-workflow-share-a-cache)Copy Anchor Link Yes, with one ordering rule. A cache entry becomes visible to other jobs only after the job that wrote it finishes its save step, so a job that needs the entry must declare the writing job in its `needs` list. Jobs that run in parallel with the writer will miss the entry and fall back to `restore-keys`. The [share data between GitHub Actions jobs guide](/guides/share-data-between-github-actions-jobs) covers the fan-out shapes where that ordering gets expensive. ### [Should I use artifacts instead of a cache to pass files between workflows?](#should-i-use-artifacts-instead-of-a-cache-to-pass-files-between-workflows)Copy Anchor Link Use an artifact when a specific run's output has to reach a specific downstream run, such as a built binary going to a release workflow. Use a cache when the content is reproducible input that any run can rebuild, such as `node_modules` or a Cargo `target` directory. Artifacts are addressed by run and name, so they are the right tool for handoffs. Caches are addressed by key, version, and branch, so they are the right tool for reuse. ### [What does sharing a cache across workflows cost?](#what-does-sharing-a-cache-across-workflows-cost)Copy Anchor Link On WarpBuild hosted runners, cache storage is $0.20 per GB-month and each cache write, restore, or list is $0.0001 per operation. On BYOC both are free. Sharing one entry across three workflows costs three restore operations instead of three separate stored entries, so it lowers both lines. The [pricing page](/pricing) carries the full rate sheet, and [how long GitHub Actions caches last](/answers/how-long-do-github-actions-caches-last) explains the 7-day expiry that sets the storage window. ### [Why does the same key miss on a different runner platform?](#why-does-the-same-key-miss-on-a-different-runner-platform)Copy Anchor Link The cache version is a hash over the compression tool and the list of cached paths, and it is matched alongside the key. A macOS runner and a Linux runner produce different versions, so an entry saved on `warp-macos-latest-arm64-6x` cannot restore on `warp-ubuntu-latest-x64-4x` even when the key string is identical. The [caching documentation](/docs/ci/features/caching) describes the version hash and the cross-platform workarounds. --- # Can I Snapshot a Runner on a Feature Branch? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-snapshot-a-runner-on-a-feature-branch Description: Yes. Publish a clean snapshot on the default branch and let feature branches boot from it, or give a branch its own alias and sweep it on close. # Can I Snapshot a Runner on a Feature Branch? Yes. Publish a clean snapshot on the default branch and let feature branches boot from it, or give a branch its own alias and sweep it on close. Yes. Snapshot behavior is requested by a label on the job, so any branch can boot from a snapshot or write one, and the two documented shapes are a clean snapshot published on the default branch that feature branches read with `snapshot.key=`, or an incremental alias that the branch refreshes on every push. Per branch aliases need a naming scheme that keeps them from overwriting each other and a cleanup job that removes them when the branch closes, because storage bills for every hour a snapshot exists. ## [Answer](#answer)Copy Anchor Link Snapshot labels are appended to the runner label with a semicolon and are read per job. `snapshot.enabled=true` turns the feature on and always boots from the base image. `snapshot.key=` turns the feature on and boots from the existing snapshot for that alias when one exists, falling back to the base image when none does, per the [snapshot runners documentation](/docs/ci/features/snapshot-runners). Neither label carries any notion of a branch, so branch policy lives in the workflow expression that picks the label. The documentation writes both shapes out as full examples. Here they are side by side. | Clean snapshot on the default branch | Incremental snapshot on the branch | | | ------------------------------------ | ------------------------------------------------------- | ------------------------------------------------------------ | | Label on the producing job | warp-ubuntu-latest-x64-4x;snapshot.enabled=true | warp-ubuntu-latest-x64-4x;snapshot.key= | | Label on the reading job | warp-ubuntu-latest-x64-4x;snapshot.key= | the same label, every run | | Boots from | base image on the producer, published image on branches | the previous run under that alias | | Refreshed by | pushes to the default branch | every push on the branch | | Live aliases | one per repository | one per branch that runs the workflow | | Storage per 30 day month | $18.00 | $18.00 for each alias kept alive | | Failure mode | branch-only state is missing from the image | one bad run poisons the alias for that branch | | Fits | shared dependency trees and toolchains | long incremental builds and fixture data local to one branch | Both shapes cover the same part of the catalog. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, per the [cloud runners documentation](/docs/ci/cloud-runners). A snapshot label on a Windows, macOS, or BYOC runner is silently ignored, so the job runs with no snapshot behavior and no error. Billing is identical across the two shapes: $0.04 per job that restores a snapshot and $0.025 per snapshot-hour of storage, from the [pricing page](/pricing), checked on 2026-08-13\. Restore fees follow job count, so the branch decision is a storage decision, and the alias count is what moves it. ## [Detail](#detail)Copy Anchor Link ### [The default branch publishes, feature branches read](#the-default-branch-publishes-feature-branches-read)Copy Anchor Link This is the first documented example. Pushes to `main` boot clean and republish the image, and pull request runs boot from it. Copy Text ``` jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-4x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-4x;snapshot.key=web-app-deps' }} steps: - uses: actions/checkout@v5 - name: Install system packages run: | if [ -z "$WARPBUILD_SNAPSHOT_KEY" ]; then sudo apt-get update sudo apt-get install -y libvips-dev protobuf-compiler fi - run: npm ci - run: npm run build - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "web-app-deps" fail-on-error: true ``` A runner created from a snapshot sets `WARPBUILD_SNAPSHOT_KEY` to the alias it booted from, which is the signal the guard above reads. One alias exists for the whole repository, the image is rebuilt from a clean base on every merge, and feature branch work never enters it. ### [One alias per branch](#one-alias-per-branch)Copy Anchor Link The second documented example drops the producer and reader split. Every run boots from the alias and writes a new snapshot to it, so state compounds run over run on that branch. Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-4x;snapshot.key=web-app-deps-pr-4821 steps: - uses: actions/checkout@v5 - run: npm ci - run: cargo build --tests - name: Cleanup credentials run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - uses: WarpBuilds/snapshot-save@v1 with: alias: "web-app-deps-pr-4821" fail-on-error: false ``` Compounding is the point for a branch that rebuilds a large `target` directory or seeds fixture data, and it is also the risk. A run that leaves a broken toolchain on disk writes that disk to the alias, and the fix is to change the alias string, which sends the next job back to the base image. ### [An alias scheme that keeps branches from colliding](#an-alias-scheme-that-keeps-branches-from-colliding)Copy Anchor Link Three things collide when aliases are generated per branch. Two branches sharing an alias overwrite each other. Two workflows in one repository sharing an alias mix cache shapes, so a test fixture disk lands where a dependency disk was expected. And branch names carry `/` and `.`, which you would rather keep out of a label string. A pull request number sidesteps the last one, since it is already a safe token and unique within the repository. | Segment | Example | Why it is there | | --------------- | ------- | ------------------------------------------------------------------------------------- | | Project prefix | web-app | Keeps repositories in one organization apart | | Cache shape | deps | One alias per kind of disk state, so a fixture alias never lands on a dependency job | | Branch identity | pr-4821 | Unique per repository and safe inside a label, from github.event.pull\_request.number | | Shape version | v2 | Bump it to force a base image boot after a toolchain change | That gives `web-app-deps-pr-4821-v2`. Building the label in a setup job keeps the expression readable and lets the same workflow serve both shapes, since `runs-on` accepts expressions including `needs` outputs per the [GitHub workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax). Copy Text ``` jobs: setup: runs-on: warp-ubuntu-latest-x64-2x outputs: runner: ${{ steps.label.outputs.runner }} alias: ${{ steps.label.outputs.alias }} steps: - id: label run: | if [ "$GITHUB_REF" = "refs/heads/main" ]; then alias="web-app-deps-main-v2" echo "runner=warp-ubuntu-latest-x64-4x;snapshot.enabled=true" >> "$GITHUB_OUTPUT" else alias="web-app-deps-pr-${{ github.event.pull_request.number }}-v2" echo "runner=warp-ubuntu-latest-x64-4x;snapshot.key=$alias" >> "$GITHUB_OUTPUT" fi echo "alias=$alias" >> "$GITHUB_OUTPUT" build: needs: setup runs-on: ${{ needs.setup.outputs.runner }} steps: - uses: actions/checkout@v5 - run: npm ci - name: Cleanup credentials run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - uses: WarpBuilds/snapshot-save@v1 with: alias: ${{ needs.setup.outputs.alias }} fail-on-error: false ``` The setup job runs on `warp-ubuntu-latest-x64-2x` at $0.004 per minute, from the [pricing page](/pricing), checked on 2026-08-13, and finishes inside a minute, so the label indirection costs well under a cent per run. ### [Deleting the alias when the branch closes](#deleting-the-alias-when-the-branch-closes)Copy Anchor Link Doing nothing has a bounded cost. Snapshots are deleted after 15 days, so an alias that stops being refreshed when its branch merges caps out at $9.00, which is 15 days times 24 hours times $0.025\. Reclaiming the storage sooner uses the snapshotter endpoints, [GET /snapshots](/docs/api/snapshotter/ListSnapshots) and [DELETE /snapshots/{id}](/docs/api/snapshotter/DeleteSnapshot), both marked alpha in the API reference. The delete call takes a snapshot id. `WarpBuilds/snapshot-save` publishes no outputs, and the documented Snapshot object carries `id`, `status`, `provider`, `created_at`, and `external_id` with no alias field, so a cleanup job matches on age rather than on the alias string. Copy Text ``` name: snapshot-sweep on: pull_request: types: [closed] schedule: - cron: "0 3 * * *" jobs: sweep: runs-on: warp-ubuntu-latest-x64-2x steps: - name: Delete snapshots not refreshed in 3 days env: WARPBUILD_API_KEY: ${{ secrets.WARPBUILD_API_KEY }} run: | set -euo pipefail cutoff=$(date -u -d '3 days ago' +%Y-%m-%dT%H:%M:%SZ) curl -sf -H "Authorization: Bearer $WARPBUILD_API_KEY" \ -H "Accept: application/json" \ https://api.warpbuild.com/api/v1/snapshots \ | jq -r --arg cutoff "$cutoff" \ '.[] | select(.created_at < $cutoff) | .id' \ | while read -r id; do curl -sf -X DELETE \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/snapshots/$id" done ``` The window is the safety rule: every alias you intend to keep has to be refreshed inside it. A shared alias republished on every merge clears a three day window easily. An alias written by a weekly workflow does not, so widen the window or skip that id explicitly. The `pull_request: closed` trigger is documented with the rest of the [workflow events](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), and running the sweep on close as well as on a schedule keeps merged branches from waiting for the nightly pass. The arithmetic decides how much any of this matters. One shared alias kept alive costs $18.00 per 30 day month. Twelve branch aliases open at once cost $216.00 over the same period, and forty merged branches whose aliases nobody deletes add $360.00 as they age out at $9.00 each. Restore fees stay the same across all three cases, since they track job count at $0.04 per job. The [snapshot runners hub](/runners/snapshot) carries the label reference and per-size rates, and the [incremental builds guide](/guides/incremental-builds-github-actions) covers the build systems that gain most from a warm disk. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can two feature branches share one snapshot alias?](#can-two-feature-branches-share-one-snapshot-alias)Copy Anchor Link Yes, and the shared alias is the shape the documentation leads with. Every job that names the alias boots from the latest snapshot saved under it, so two branches saving to one alias overwrite each other's disk state run by run. Share an alias when the state is a dependency tree both branches want, and split the alias when one branch would poison the other. The [snapshot runners hub](/runners/snapshot) lists the Ubuntu labels that accept snapshot keys. ### [What happens if a branch asks for an alias that was never created?](#what-happens-if-a-branch-asks-for-an-alias-that-was-never-created)Copy Anchor Link The runner boots from the base image and the job runs normally. `snapshot.key=` falls back to the base image when no snapshot exists for the alias, which is also what happens after the 15 day lifetime deletes one, so the first run on a new branch alias costs its usual cold setup time and nothing fails. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) is the reference for the fallback and the lifetime. ### [Does a branch alias have to be deleted when the branch merges?](#does-a-branch-alias-have-to-be-deleted-when-the-branch-merges)Copy Anchor Link No. Snapshots are deleted after 15 days, which caps an abandoned alias at $9.00 of storage at $0.025 per snapshot-hour, from the [pricing page](/pricing), checked on 2026-08-13\. Deleting on merge reclaims that sooner, and it matters once branch aliases are counted in dozens rather than in ones. [How do I invalidate a runner snapshot?](/answers/how-do-i-invalidate-a-runner-snapshot) covers the delete path and the alias rename that retires an image immediately. --- # Can I SSH Into a GitHub Actions Runner? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-ssh-into-a-github-actions-runner Description: Yes. Add the open-source Action Debugger step to a job and the workflow pauses, an SSH session opens on the runner, and the URL lands in the logs. # Can I SSH Into a GitHub Actions Runner? Yes. Add the open-source Action Debugger step to a job and the workflow pauses, an SSH session opens on the runner, and the URL lands in the logs. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Yes. You add the open-source Action Debugger step to the job, and the workflow pauses at that step while an SSH session opens on the runner machine. The SSH URL is printed in the action logs and posted as a check on the corresponding GitHub run, and the job stays paused on that step until someone connects and exits the session. One step is the whole setup: Copy Text ``` name: build on: [push] jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Setup interactive ssh session uses: Warpbuilds/action-debugger@v1.3 with: limit-access-to-actor: true ``` Action Debugger is free to use and open source, published at [Warpbuilds/action-debugger](https://github.com/Warpbuilds/action-debugger), and it plugs into any GitHub workflow. The step runs on whatever machine the job already runs on, so a GitHub-hosted label and a `warp-` label both work. The reference for every input is the [WarpBuild Action Debugger documentation](/docs/ci/tools/action-debugger). Two things to settle before the first session, both covered in detail below. Access to the session is governed by the SSH keys on the triggering GitHub account, so a workflow that skips `limit-access-to-actor` can leave the session reachable by anyone holding the URL. And the runner keeps billing while the session sits open, which makes `timeout-minutes` part of the configuration rather than an optional extra. The Action Debugger is one of the WarpBuild product surfaces, alongside snapshot runners, remote Docker builders, CI observability, and an MCP server. The debugger step works on each of them; the catalog and per-minute rates are in the [cloud runners documentation](/docs/ci/cloud-runners) and on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [The step and its inputs](#the-step-and-its-inputs)Copy Anchor Link Action Debugger takes four inputs. Everything else about the session is controlled by ordinary GitHub Actions workflow keys. | Input | Type | What it does | Default | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------- | ------- | | limit-access-to-actor | boolean | Restricts the session to the GitHub user who triggered the run, whether or not that account has SSH keys | false | | detached | boolean | Runs the remaining steps normally and pauses at the end of the job instead of at the debugger step | false | | named-session-name | string | Requests a deterministic SSH URL of the form username/session-name@gha.warp.build | none | | named-session-api-key | string | API key that authorizes named sessions, issued by WarpBuild support | none | `timeout-minutes` is a GitHub Actions step key rather than an action input, so it sits beside `uses` and not under `with`. It is the only reliable way to bound the session, because the workflow otherwise waits for a human to connect and exit. ### [A workflow that opens a session only when the job fails](#a-workflow-that-opens-a-session-only-when-the-job-fails)Copy Anchor Link Pausing every run is rarely what you want. The `if: ${{ failure() }}` condition makes the debugger step run only when an earlier step in the job has failed, which turns it into a break-glass path rather than a permanent gate on the pipeline. Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x permissions: contents: read checks: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test - name: Setup interactive ssh session if: ${{ failure() }} uses: Warpbuilds/action-debugger@v1.3 timeout-minutes: 15 with: limit-access-to-actor: true ``` The failing job now holds its runner for up to 15 minutes with the exact filesystem, environment variables, and half-written build outputs that produced the failure. On a green run the step is skipped and costs nothing. Detached mode is the other useful shape. With `detached: true`, every remaining step executes as normal and the pause happens at the end of the job, so you inspect the machine after the full workflow has run instead of freezing it mid-sequence. Copy Text ``` jobs: integration: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - run: ./scripts/integration-tests.sh - name: Setup interactive ssh session uses: Warpbuilds/action-debugger@v1.3 timeout-minutes: 30 with: detached: true limit-access-to-actor: true ``` ### [Where the SSH URL appears](#where-the-ssh-url-appears)Copy Anchor Link The URL shows up in two places: inline in the action logs at the point the debugger step runs, and as a check on the GitHub run, which is the faster of the two to find from the pull request page. If the check never appears, the cause is almost always token permissions. Creating a check requires write access to the `checks` scope, so `read and write permissions` has to be enabled under Workflow permissions. On many organizations that setting is enforced centrally and has to be changed at the organization level rather than on the repository, per [GitHub's documentation on GITHUB\_TOKEN permissions](https://docs.github.com/en/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization#setting-the-permissions-of-the-github%5Ftoken-for-your-organization). The per-job `permissions:` block in the workflow above sets the same thing at the job scope. ### [Who can connect to the session](#who-can-connect-to-the-session)Copy Anchor Link This is the part worth reading twice. By default, if the GitHub user who triggered the action has added SSH keys to their GitHub account, then only that user is allowed to connect to the session. If that account has no SSH keys on it, anyone on the internet who has or guesses the generated SSH URL can connect to that session. The URL is a long random string precisely so that guessing is impractical, but the URL appears in the run logs, and run logs on a public repository are public. Setting `limit-access-to-actor: true` forces the restriction regardless of the triggering account's key state. Treat it as the default for every workflow that includes the step. Named sessions trade some of that unpredictability for convenience. Supplying `named-session-name` and `named-session-api-key` produces a stable URL of the form `username/session-name@gha.warp.build` on every run, which is handy when a script or an editor config expects a fixed host. The API key is issued by WarpBuild support on request. Because the URL is now guessable by construction, `limit-access-to-actor` must be set to `true` whenever named sessions are used. ### [What an open session costs](#what-an-open-session-costs)Copy Anchor Link The runner is a running machine for the entire time the session is open, and it bills per minute at the rate of the label in `runs-on`. GitHub kills workflows after 6 hours, so that is the ceiling on a session nobody closes. Rates below come from the [WarpBuild pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Runner label | Per minute | 15-minute session | 30-minute session | 60-minute session | 6-hour ceiling | | --------------------------- | ---------- | ----------------- | ----------------- | ----------------- | -------------- | | warp-ubuntu-latest-x64-2x | $0.004 | $0.06 | $0.12 | $0.24 | $1.44 | | warp-ubuntu-latest-x64-4x | $0.008 | $0.12 | $0.24 | $0.48 | $2.88 | | warp-ubuntu-latest-x64-8x | $0.016 | $0.24 | $0.48 | $0.96 | $5.76 | | warp-ubuntu-latest-arm64-8x | $0.012 | $0.18 | $0.36 | $0.72 | $4.32 | | warp-windows-latest-x64-4x | $0.016 | $0.24 | $0.48 | $0.96 | $5.76 | | warp-macos-latest-arm64-6x | $0.08 | $1.20 | $2.40 | $4.80 | $28.80 | | warp-macos-latest-arm64-12x | $0.16 | $2.40 | $4.80 | $9.60 | $57.60 | Read the last column as the price of forgetting. A macOS session left open on `warp-macos-latest-arm64-12x` runs to the 6-hour GitHub ceiling and bills 360 x $0.16 = $57.60 for a machine nobody is typing into. A `timeout-minutes` value on the step caps that at whatever number you chose. Worked monthly model. Take a team that opens 20 debug sessions per month at 20 minutes each, which is 400 billed minutes: * On `warp-ubuntu-latest-x64-4x` at $0.008 per minute: 400 x $0.008 = $3.20 per month. * On `warp-windows-latest-x64-4x` at $0.016 per minute: 400 x $0.016 = $6.40 per month. * On `warp-macos-latest-arm64-6x` at $0.08 per minute: 400 x $0.08 = $32.00 per month. Detached sessions add their minutes on top of the job's normal execution time, because the workflow has already done its work by the time the pause starts. Sessions gated behind `if: ${{ failure() }}` bill only on the runs that failed. Full rates by runner type are on the [pricing page](/pricing). ### [When an SSH session is the wrong tool](#when-an-ssh-session-is-the-wrong-tool)Copy Anchor Link An interactive shell answers questions about one machine at one moment. It is the right instrument for a build that fails only on the runner, a missing tool on the image, a permissions error nobody can reproduce locally, or a test that reads a path that exists on a laptop and not in the job. It answers nothing about patterns. A test that fails on one run in twenty is a sampling problem, and connecting to the one failing machine tells you what that machine looked like after the fact rather than why the sample failed. That case belongs to the [guide to flaky GitHub Actions jobs](/guides/flaky-github-actions-jobs). A job that got slower over a month is a percentile question, covered by [GitHub Actions observability](/solutions/github-actions-observability). The ordered procedure for a single failure, of which opening a shell is the last step rather than the first, is in the [guide to debugging GitHub Actions failures](/guides/debug-github-actions-failures), with the short version in [how to debug a failed GitHub Actions job](/answers/how-do-i-debug-a-failed-github-actions-job). ### [Troubleshooting the session itself](#troubleshooting-the-session-itself)Copy Anchor Link A blank screen on connect is usually `tmux`. Pressing `ctrl+c` drops you into a shell, at the cost of the multiplexing behavior `tmux` was providing. The session otherwise behaves like any shell on the runner: the workspace is where the workflow left it, the runner user is the same user the steps ran as, and exiting the session releases the job to continue or to finish. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does SSH access work on GitHub-hosted runners as well as WarpBuild runners?](#does-ssh-access-work-on-github-hosted-runners-as-well-as-warpbuild-runners)Copy Anchor Link Yes. Action Debugger is an ordinary open-source GitHub Action, so it runs wherever the job runs. Point `runs-on` at a GitHub-hosted label or at a `warp-` label such as `warp-ubuntu-latest-x64-4x` and the step behaves the same way, pausing the job and opening an SSH session on that runner machine. ### [Who can connect to the SSH session that Action Debugger opens?](#who-can-connect-to-the-ssh-session-that-action-debugger-opens)Copy Anchor Link If the GitHub user who triggered the run has SSH keys on their GitHub account, only that user can connect. If that account has no SSH keys, anyone on the internet who has or guesses the generated SSH URL can connect to the session. Set `limit-access-to-actor` to `true` to force the restriction in every case. ### [How much does an open SSH debug session cost?](#how-much-does-an-open-ssh-debug-session-cost)Copy Anchor Link The runner bills for the whole time the session is open, at the per-minute rate of the label in `runs-on`. A 30-minute session costs $0.24 on `warp-ubuntu-latest-x64-4x` at $0.008 per minute and $2.40 on `warp-macos-latest-arm64-6x` at $0.08 per minute. GitHub kills workflows after 6 hours, so a forgotten session bills up to that ceiling. ### [Can I get the same SSH URL on every workflow run?](#can-i-get-the-same-ssh-url-on-every-workflow-run)Copy Anchor Link Yes, through named sessions. Set `named-session-name` and `named-session-api-key` and the URL takes the form `username/session-name@gha.warp.build` on every run. The API key is issued by WarpBuild support, and `limit-access-to-actor` must be set to `true` when named sessions are in use. --- # Can You Run Docker on macOS GitHub Actions Runners? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-use-docker-on-macos-runners Description: No. macOS GitHub Actions runners cannot run Docker and have no nested virtualization. Send container steps to a Linux warp- label or a remote Docker builder. # Can You Run Docker on macOS GitHub Actions Runners? No. macOS GitHub Actions runners cannot run Docker and have no nested virtualization. Send container steps to a Linux warp- label or a remote Docker builder. Last verified: 2026-08-13 No. macOS GitHub Actions runners cannot run Docker, because they do not support nested virtualization and so cannot host the Linux virtual machine that Linux containers need ([WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). The working pattern is a split workflow: the macOS job handles Xcode, Swift, and simulator work, and a second job on a Linux `warp-` label or a remote Docker builder handles the container work. ## [Answer](#answer)Copy Anchor Link The limit sits below the runner image. A macOS runner is itself a virtualized guest on Apple Silicon hardware. Docker on macOS runs Linux containers inside a Linux virtual machine, so a Docker daemon on that runner would need a second layer of virtualization inside the first. That second layer is nested virtualization, and the macOS fleet does not have it. The documented statement is short: macOS runners do not support nested virtualization and cannot run Docker ([cloud runners documentation](/docs/ci/cloud-runners)). The same constraint applies to GitHub-hosted macOS runners, which run macOS on virtualized Apple Silicon as well ([GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13). Four things fail on a macOS job as a result: 1. Any step that shells out to `docker build`, `docker run`, `docker compose`, or `docker login`. 2. The `container:` job key, which asks GitHub Actions to run every step inside a container. 3. The `services:` block, which starts sidecar containers such as Postgres or Redis for the job. 4. Container actions, meaning actions whose `action.yml` declares `runs.using: docker`. Items 2, 3, and 4 are Linux-only in GitHub Actions by design, independent of the runner provider ([GitHub Actions workflow syntax reference](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions)). JavaScript and composite actions run fine on macOS, which covers `actions/checkout`, the language setup actions, and the artifact actions. Two configurations replace the missing daemon, and both keep everything in one workflow file. **Run container work on a Linux `warp-` label in the same workflow.** Add a second job with `runs-on: warp-ubuntu-latest-x64-4x`. It gets a normal Docker daemon, so `services:`, `container:`, and every `docker` command behave as they do on any Linux runner. The two jobs run in parallel unless one declares `needs` on the other. **Build images on a remote Docker builder while the macOS job handles Apple toolchain work.** Remote Docker builders are dedicated build virtual machines that live outside the runner and keep a persistent layer cache on local disk ([Docker builders documentation](/docs/ci/docker-builders)). The job that drives the build stays on a Linux runner, the build itself executes on the builder, and repeat builds reuse cached layers instead of rebuilding them. Here is the split, with real labels: Copy Text ``` name: release on: push: tags: ["v*"] jobs: ios-archive: runs-on: warp-macos-26-arm64-6x steps: - uses: actions/checkout@v4 - name: Record the selected toolchain run: | xcodebuild -version xcrun simctl list runtimes - name: Archive the app run: | xcodebuild -scheme Example \ -destination "generic/platform=iOS" \ -archivePath build/Example.xcarchive \ archive - uses: actions/upload-artifact@v4 with: name: ios-archive path: build/Example.xcarchive api-image: runs-on: warp-ubuntu-latest-x64-4x services: postgres: image: postgres:17 env: POSTGRES_PASSWORD: test ports: ["5432:5432"] steps: - uses: actions/checkout@v4 - name: Integration tests against the service container run: ./scripts/integration-tests.sh env: DATABASE_URL: postgres://postgres:test@localhost:5432/postgres - name: Build and push the API image uses: Warpbuilds/build-push-action@v6 with: context: ./services/api push: true tags: ghcr.io/acme/api:${{ github.ref_name }} profile-name: "api-builder" ``` The `ios-archive` job never sees a container, and the `api-image` job never sees Xcode. Nothing in the macOS job needs a Docker daemon, and the container work runs where containers work. Pricing on WarpBuild is purely usage based. ## [Detail](#detail)Copy Anchor Link ### [What macOS runners are for](#what-macos-runners-are-for)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The macOS fleet exists for the work that has to touch the Apple toolchain: `xcodebuild`, Swift package resolution, simulator runs, code signing, notarization, and archive export. Those steps cannot run on Linux, which is why the platform stays in the catalog even though it carries no container support. Five labels cover three macOS releases, in two sizes. Every row below comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Runner label | OS | vCPU | Memory | Storage | Per minute | Alias | | ----------------------- | -------- | ---- | ------ | --------- | ---------- | --------------------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | none | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | none | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | none | Multiple sizes and configurations exist per chip. The 12x configuration doubles the cores, doubles the memory, and takes disk from 120GB to 270GB, which matters on repositories where derived data, resolved Swift packages, and several simulator runtimes share one disk. The macOS 26 labels ship Xcode 27.0 with its iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes on top of the upstream GitHub macOS 26 image, while GitHub's upstream macOS 27 runner image is in beta; a dedicated macOS 27 image follows once that image is released. On price, `warp-macos-latest-arm64-6x` (6 vCPU, 14 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price. GitHub list price checked on 2026-08-13 in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The full label list with per-size arithmetic is on the [macOS runner catalog page](/runners/macos). Remote Docker builders are the piece that matters here, because they carry the container work a macOS job cannot. ### [Nested virtualization is available on Linux x64 only](#nested-virtualization-is-available-on-linux-x64-only)Copy Anchor Link Nested virtualization and `/dev/kvm` access exist on the Linux x64 fleet through the `nested-virtualization.enabled=true` dynamic label ([nested virtualization documentation](https://www.warpbuild.com/docs/ci/features/nested-virtualization)). That label is what Android emulator jobs and similar workloads use. It has no macOS equivalent, and it has no Linux ARM64 equivalent either. The practical consequence for a mobile repository is that iOS simulator tests and Android emulator tests land on different runners. Simulator tests stay on a macOS label because the simulator is part of Xcode. Emulator tests go to a Linux x64 label with the nested virtualization flag set. Neither job needs Docker to be present on the macOS machine. ### [Passing work between the macOS job and the container job](#passing-work-between-the-macos-job-and-the-container-job)Copy Anchor Link Two jobs do not share a filesystem, so anything the container job needs from the macOS job travels as an artifact. `actions/upload-artifact` in the macOS job and `actions/download-artifact` in the Linux job cover the common cases: a built `.ipa`, a `.xcarchive`, a dSYM bundle, or a test report that a later job packages into an image or uploads to a release. Order is a choice. Leave `needs` off and the jobs run in parallel, so the pipeline takes the length of the longer job. Add `needs: ios-archive` to the container job and the pipeline runs them in sequence, which is what you want when the image being built has to contain an artifact the macOS job produced. Runner storage on both platforms is ephemeral and is deleted when the runner terminates, so any state that has to outlive a job goes to an artifact, a cache, or a registry. ### [Building the image on a remote Docker builder](#building-the-image-on-a-remote-docker-builder)Copy Anchor Link A builder profile maps to one dedicated build virtual machine with a persistent layer cache. Multiple jobs can share a profile and run on it in parallel, and profiles carry amd64, arm64, and multi-architecture options ([Docker builders documentation](/docs/ci/docker-builders)). The `Warpbuilds/build-push-action` action is a drop-in replacement for the standard build and push action: point it at a profile with `profile-name` and drop the local buildx setup step. Builders also work from runners outside WarpBuild, using an API key in `api-key`, which is useful when part of a pipeline already runs somewhere else. The [Docker builds on GitHub Actions page](/solutions/docker-builds-github-actions) covers the full configuration, and the [remote Docker builder catalog](/runners/docker-builders) lists the profile sizes and their rates. ### [A worked monthly cost model](#a-worked-monthly-cost-model)Copy Anchor Link Take a repository that ships an iOS app and the API image behind it, running 400 pipelines a month. The macOS job takes 14 minutes and the container job takes 6 minutes. Every WarpBuild rate comes from the [cloud runners documentation](/docs/ci/cloud-runners) and every GitHub rate from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), both checked on 2026-08-13. | Job | Runner | Minutes per run | Rate per minute | Monthly at 400 runs | | --------------------------------- | ------------------------- | --------------- | --------------- | ------------------- | | Xcode archive | warp-macos-26-arm64-6x | 14 | $0.08 | $448.00 | | Image build and integration tests | warp-ubuntu-latest-x64-4x | 6 | $0.008 | $19.20 | | **Total** | **$467.20** | | | | The same two jobs on GitHub-hosted runners, at GitHub's published list prices: | Job | GitHub-hosted runner | Minutes per run | Rate per minute | Monthly at 400 runs | | --------------------------------- | -------------------------------------------- | --------------- | --------------- | ------------------- | | Xcode archive | largest macOS ARM64 runner, 5 vCPU and 14 GB | 14 | $0.102 | $571.20 | | Image build and integration tests | 4-core Linux larger runner | 6 | $0.012 | $28.80 | | **Total** | **$600.00** | | | | The gap is $132.80 a month and $1,593.60 over twelve months, which works out to 22 percent lower list price on the same job shapes (GitHub list prices, checked on 2026-08-13). The macOS row also buys a larger machine: 6 vCPU and 14 GB against 5 vCPU and 14 GB. Moving the image build to a remote Docker builder changes the second row rather than the first. A 3 minute build on the 16 vCPU, 32 GB, 100GB disk profile at $0.06 per minute ([pricing page](/pricing), checked on 2026-08-13) costs 400 x 3 x $0.06 = $72.00 a month, and it comes with a layer cache that persists across every job sharing that profile. Whether that trade pays depends on how much of the Dockerfile is dependency installation, which is the part a warm layer cache skips. ## [Related Questions](#related-questions)Copy Anchor Link **Can one GitHub Actions job use a macOS runner and a Docker step together?** No. A job runs on exactly one runner, and a macOS runner has no Docker daemon and no nested virtualization. Split the work into two jobs in the same workflow file, one on a macOS label such as `warp-macos-26-arm64-6x` and one on a Linux label such as `warp-ubuntu-latest-x64-4x`. **How do I structure a workflow that needs both an Xcode build and a container build?** Declare two jobs. The macOS job runs `xcodebuild` and uploads its archive with `actions/upload-artifact`. The Linux job builds and pushes the image. They run in parallel unless one declares `needs` on the other, so the pipeline takes the length of the longer job rather than the sum. The [Docker builds on GitHub Actions page](/solutions/docker-builds-github-actions) covers the image half in detail. **Can a macOS job use services or a job container for integration tests?** No. The `services:` block and the `container:` job key run on Linux runners. Move those tests to a Linux job, or have the macOS job talk to a service started by a Linux job that runs earlier in the workflow. **Do GitHub-hosted macOS runners run Docker?** No. Nested virtualization is unavailable on macOS runners on both fleets, so macOS runners on GitHub and on WarpBuild alike lack a working Docker daemon. See the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13. Pick a macOS label from the [macOS runner catalog](/runners/macos), check what else the platform covers in [whether GitHub Actions runners support macOS](/answers/do-github-actions-runners-support-macos), and price the split against your own job minutes on the [pricing page](/pricing). --- # Can I Use My Existing VPC With BYOC Runners? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-use-my-own-vpc-with-byoc Description: Yes. Import mode on AWS builds the BYOC stack against your existing VPC, subnets, and security group once they meet the documented network prerequisites. # Can I Use My Existing VPC With BYOC Runners? Yes. Import mode on AWS builds the BYOC stack against your existing VPC, subnets, and security group once they meet the documented network prerequisites. Yes. On AWS you create the BYOC stack in import mode, which takes an existing VPC, subnets, and security group as inputs instead of having the CloudFormation template provision them, and the import works once that network meets the prerequisites in the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config). BYOC runs on AWS, GCP, and Azure, and import mode is the AWS path; on the other two clouds the stack creates its own network resources. ## [Answer](#answer)Copy Anchor Link Import mode is a choice made once, at stack creation. The [configuration guide](/docs/ci/byoc/aws/config) and the [security hardening guide](/docs/ci/byoc/aws/security) both state the same split: in create mode the CloudFormation template provisions the security group and the surrounding network, and in import mode you select an existing security group when creating the stack. That makes the import a compliance question for whoever owns the network. Here is the documented prerequisite list as a checklist, drawn from the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config), checked on 2026-08-13. | Prerequisite | Documented requirement | Owner in import mode | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | | CloudFormation permissions | The user creating the stack can run a CloudFormation stack, which provisions the WarpBuild IAM role | Your AWS account | | VPC and subnets | At least one public and one private subnet, both with internet connectivity | Your existing VPC | | Availability zones | Three public and three private subnets in different zones, recommended for instance-type availability | Your existing VPC | | Address headroom | Enough addresses per subnet for peak concurrent runners, 250 per subnet recommended as the minimum | Your existing VPC | | Private subnet egress | NAT gateways serving the private subnets | Your existing VPC | | Route tables | Internet-bound traffic routed through the Internet Gateway, with outbound routes in place for NAT gateways in private subnets | Your existing VPC | | Security group | All inbound blocked, outbound allowed to WarpBuild, the GitHub API, and your package registries | You select an existing group | | S3 gateway endpoint | An S3 gateway endpoint on the VPC so runners reach the bucket without incurring data transfer charges | Your existing VPC | | S3 bucket | In the same region as the stack, with a lifecycle policy for cache and telemetry, 7 days retention recommended | Created by the stack | | ECR endpoints | VPC endpoints for ECR operations from public and private subnet runners, configured automatically | WarpBuild | | Quotas | Address, EBS volume, and vCPU headroom in the region for the instance types you select | Your AWS account | Nothing on that list is specific to WarpBuild except the outbound destinations. A VPC already carrying production workloads usually satisfies most of it, and the two rows that fail most often are address headroom and the private subnet routes. Pricing does not change with the network you bring. BYOC Linux runners carry a WarpBuild fee of $0.002 per runner minute and BYOC Windows runners the same $0.002 per runner minute, with your own AWS account billing EC2, EBS, and data transfer at your rates ([pricing page](/pricing), checked on 2026-08-13). ## [Detail](#detail)Copy Anchor Link ### [What the stack still creates around your VPC](#what-the-stack-still-creates-around-your-vpc)Copy Anchor Link Importing the network does not make the stack empty. It still creates an S3 bucket in the stack region for the artifact cache, the container image layer cache, and runner telemetry, and it still creates the per-job EC2 instances, EBS volumes, and launch templates. The connection role holds `ec2:CreateVpcEndpoint` and `ec2:DeleteVpcEndpoints`, and the configuration guide lists those network permissions as the ones supporting stack creation in import mode, so the endpoints the runners need can be added to the VPC you handed over. Three stack properties are fixed at creation and cannot be changed afterwards: the stack name, the S3 bucket, and the region. An imported VPC pins the region for you, so the decision left is the bucket and a stack name that still reads clearly later. Resource naming makes the imported network auditable afterwards. Buckets are named `warpbuild-*`, instances and EBS volumes `warp-*`, and launch templates `tmpl-warp-*`, and every provisioned resource carries a `managed-by: warpbuild` tag that AWS IAM tag-based access control can key on. ### [When a dedicated account beats importing the VPC you have](#when-a-dedicated-account-beats-importing-the-vpc-you-have)Copy Anchor Link The [security hardening guide](/docs/ci/byoc/aws/security) recommends isolating GitHub Actions workloads from production infrastructure by deploying the stack in a separate VPC within the same account, or in a separate AWS account dedicated to CI/CD, because that limits the blast radius if a runner is compromised and simplifies audit boundaries. The separation is worth the setup when workflow jobs run untrusted code, such as pull requests from outside collaborators, when the shared VPC has routes to production databases that a build step has no business reaching, or when an auditor wants the boundary drawn at the account rather than at a security group rule. Import the existing VPC when the opposite is true: the network is already segmented, the platform team manages it under Terraform, or an organizational policy forbids a template from creating network resources. Either choice keeps the same default posture. Each job runs on a distinct, freshly provisioned EC2 instance that is terminated when the job finishes, and the permissions available to a runner are the ones you attach through the instance profile, since WarpBuild injects no additional permissions into runner workloads. ### [The two prerequisites that fail most, and what the failure looks like](#the-two-prerequisites-that-fail-most-and-what-the-failure-looks-like)Copy Anchor Link | Failed prerequisite | Where it shows up | Signature | | ------------------------------------------------------------------------------ | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Private subnets with no NAT gateway, or route tables missing the outbound path | The instance launches and the GitHub Actions job stays queued | The runner never registers, because registration needs outbound access to api.warpbuild.com, and github.com and api.github.com for git and API calls. Runner system logs also stop arriving, since telemetry needs outbound HTTPS | | Address or quota headroom below peak concurrency | The launch itself fails, so no instance appears | AWS returns the client error codes InsufficientFreeAddressesInSubnet when a subnet has no free addresses and VcpuLimitExceeded when the regional vCPU limit is reached ([Amazon EC2 API error codes](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/errors-overview.html), checked on 2026-08-13) | One more failure is worth pre-empting on an imported network. Stacks created with CloudFormation template versions before v1.4 may hit ECR authentication failures, seen as an ECR login timing out, for runners in public subnets. The documented fix is upgrading the stack to template v1.4 from the WarpBuild dashboard ([AWS BYOC configuration guide](/docs/ci/byoc/aws/config)). ### [The workflow does not know which mode you picked](#the-workflow-does-not-know-which-mode-you-picked)Copy Anchor Link Stack mode is invisible to the workflow file. The job references the runner ID as its label, prefixed with `warp-custom-`, on an imported VPC exactly as it would on a created one. Copy Text ``` name: build on: [push] jobs: test: runs-on: warp-custom-linux-imported-4x steps: - uses: actions/checkout@v4 - run: make test ``` Platform coverage is also unchanged by the import. On AWS BYOC the runners that land in your VPC are Linux and Windows, so Apple platform jobs keep a hosted macOS label in the same workflow. ## [Related Questions](#related-questions)Copy Anchor Link ### [How do I point a BYOC stack at a VPC I already have?](#how-do-i-point-a-byoc-stack-at-a-vpc-i-already-have)Copy Anchor Link Choose import mode when you create the stack on AWS. Import mode takes an existing VPC, subnets, and security group as inputs, where create mode has the CloudFormation template provision those resources instead. BYOC runs on AWS, GCP, and Azure, and import mode is the AWS path. The stack creation flow is walked through on the [AWS BYOC page](/byoc/aws). ### [What does my existing VPC have to look like before it works?](#what-does-my-existing-vpc-have-to-look-like-before-it-works)Copy Anchor Link At least one public and one private subnet with internet connectivity, NAT gateways serving the private subnets, route tables sending internet-bound traffic through the Internet Gateway, a security group that blocks all inbound and allows the required outbound destinations, and enough addresses, vCPU, and EBS headroom in the region. The documented recommendation is three public and three private subnets across availability zones with at least 250 addresses each. The row-by-row reference is on the [BYOC networking page](/byoc/aws/networking), and [what a VPC decides](/glossary/vpc) covers the parts if the network is new to you. ### [Should I import a shared VPC or build a separate one for runners?](#should-i-import-a-shared-vpc-or-build-a-separate-one-for-runners)Copy Anchor Link The [security hardening guide](/docs/ci/byoc/aws/security) recommends a dedicated VPC within the account or a separate AWS account dedicated to CI/CD, because it limits the blast radius if a runner is compromised and simplifies audit boundaries. Importing a shared production VPC is supported and puts every runner instance inside a network your production workloads also use, which is a reasonable trade when the network is already segmented and centrally managed. ### [What still gets created in my account when I import a VPC?](#what-still-gets-created-in-my-account-when-i-import-a-vpc)Copy Anchor Link An S3 bucket in the same region as the stack for the artifact cache, container layer cache, and runner telemetry, plus VPC endpoints and the per-job EC2 instances, EBS volumes, and launch templates. The stack name, the bucket, and the region are fixed once the stack exists. The full resource inventory and the IAM surface behind it are on the [AWS BYOC page](/byoc/aws). --- # Can I Use Podman Instead of Docker in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/can-i-use-podman-instead-of-docker-in-github-actions Description: Yes, on Linux runners, for building and running OCI images. The tradeoff is that Buildx cache backends and remote builders stay Docker-side features. # Can I Use Podman Instead of Docker in GitHub Actions? Yes, on Linux runners, for building and running OCI images. The tradeoff is that Buildx cache backends and remote builders stay Docker-side features. Yes, Podman builds and runs OCI images on Linux GitHub Actions runners, it is already present on the Ubuntu runner images, and swapping `docker build` for `podman build` in a workflow is a change to the run steps rather than to the job. The tradeoff is narrow and worth naming up front: the Buildx cache backends and remote builders are BuildKit client features, so a podman-only workflow keeps its layer cache in a registry and keeps the build on the runner. ## [Answer](#answer)Copy Anchor Link Podman covers the part of a Docker workflow that most GitHub Actions jobs actually use: build an image from a Dockerfile, tag it, log in to a registry, push it. Podman runs rootless and without a daemon, so no privileged socket has to exist on the runner for a build to work ([containers/podman README](https://github.com/containers/podman#rootless)). Podman is preinstalled on the Ubuntu runner images: the Ubuntu 24.04 x64 image lists Podman 4.9.3, Buildah 1.33.7, and Skopeo 1.13.3 ([actions/runner-images](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md)), and the ARM64 partner image lists Podman and associated container tools ([actions/partner-runner-images](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md)). WarpBuild Ubuntu runners carry the same tooling as the GitHub-hosted images ([preinstalled software documentation](/docs/ci/preinstalled-software)), so the labels in the [cloud runners documentation](/docs/ci/cloud-runners) run a podman build with no install step. Here is where the two toolchains match and where they diverge, for the parts a GitHub Actions job depends on. | Capability | podman build and buildah | docker buildx build | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | Rootless execution | Rootless and daemonless by default ([podman README](https://github.com/containers/podman#rootless)) | Client talks to a BuildKit instance, usually behind a daemon | | Cache backends | Registry repositories only, through \--cache-from and \--cache-to ([buildah build manual](https://github.com/containers/buildah/blob/main/docs/buildah-build.1.md)) | inline, registry, local, gha, s3, azblob ([BuildKit README](https://github.com/moby/buildkit#export-cache)) | | Local layer reuse | \--layers caches intermediate images on the runner disk, which dies with the job ([buildah build manual](https://github.com/containers/buildah/blob/main/docs/buildah-build.1.md)) | BuildKit local cache in the builder's state directory | | Multi-platform | \--platform repeated or comma separated, with \--manifest instead of \--tag; cross-architecture RUN needs qemu-user-static ([buildah build manual](https://github.com/containers/buildah/blob/main/docs/buildah-build.1.md)) | \--platform with QEMU or with native builder nodes | | Image format | OCI v1 by default, \--format docker for schema 2 manifests ([buildah build manual](https://github.com/containers/buildah/blob/main/docs/buildah-build.1.md)) | OCI or Docker manifest, selectable per exporter | | Remote builders | No BuildKit client, so builds stay on the runner | \--driver remote over TLS ([Docker builders documentation](/docs/ci/docker-builders)) | | Job containers and services | Runner drives these through the Docker API ([GitHub workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)) | Supported directly | A working podman build and push job, with the registry login step that most workflows get from a login action: Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Log in to the registry run: | echo "${{ secrets.GITHUB_TOKEN }}" | podman login ghcr.io \ --username "${{ github.actor }}" \ --password-stdin - name: Build run: | podman build \ --layers \ --format docker \ --cache-from ghcr.io/${{ github.repository }}/buildcache \ --cache-to ghcr.io/${{ github.repository }}/buildcache \ --tag ghcr.io/${{ github.repository }}:${{ github.sha }} \ . - name: Push run: podman push ghcr.io/${{ github.repository }}:${{ github.sha }} ``` `--format docker` is there for registries and downstream tools that expect schema 2 manifests; drop it if OCI v1 is fine everywhere the image lands. The same three steps exist as actions if you prefer that shape: [redhat-actions/podman-login](https://github.com/redhat-actions/podman-login), [redhat-actions/buildah-build](https://github.com/redhat-actions/buildah-build), and [redhat-actions/push-to-registry](https://github.com/redhat-actions/push-to-registry). ## [Detail](#detail)Copy Anchor Link ### [The cache is what you are trading](#the-cache-is-what-you-are-trading)Copy Anchor Link Buildah accepts one cache shape: `--cache-from` names repositories to pull cache images from, and `--cache-to` names repositories to push newly built cache images to ([buildah build manual](https://github.com/containers/buildah/blob/main/docs/buildah-build.1.md)). BuildKit accepts six exporters, including `gha` against the GitHub Actions cache, `s3`, and `azblob` ([BuildKit README](https://github.com/moby/buildkit#export-cache)). Leaving Buildx behind means every cache read and write becomes a registry round trip on a fresh runner, and the cache is only as fresh as the last successful push. Two practical consequences follow. First, cache correctness now depends on registry retention and tag hygiene, so a cache repository that gets pruned quietly turns every build into a full rebuild. Second, cache transfer time scales with image layer size rather than with what changed, because the runner has no local layer store to fall back on. A private registry in the same region as the runner is the usual mitigation. The alternative is to leave the build where a warm cache already lives. Remote Docker builders run on dedicated builder virtual machines that keep the layer cache on local disk across jobs, and the runner attaches to one through a buildx remote driver over TLS ([Docker builders documentation](/docs/ci/docker-builders)). That path is Docker-side by construction. Teams that want the cache behavior and are not tied to podman for policy reasons move the build there instead, which the [Docker builds solution page](/solutions/docker-builds-github-actions) walks through end to end. ### [Multi-platform images](#multi-platform-images)Copy Anchor Link Podman builds multi-architecture images without Buildx. Pass `--platform` more than once or as a comma separated list, and use `--manifest` in place of `--tag` so the result is a manifest list. Cross-architecture `RUN` instructions need emulation software on the host from packages like `qemu-user-static`, and building on the native architecture is the documented preference ([buildah build manual](https://github.com/containers/buildah/blob/main/docs/buildah-build.1.md)). On GitHub Actions that argues for a matrix with one native job per architecture, pushing by digest, then a small job that merges the digests with `podman manifest`. Both legs of that matrix run on native hardware from the [cloud runners documentation](/docs/ci/cloud-runners). ### [Where podman does not substitute](#where-podman-does-not-substitute)Copy Anchor Link Job containers, service containers, and Docker container actions are driven by the runner itself. GitHub documents that workflows using them require a Linux runner, and that a self-hosted Linux runner must have Docker installed ([GitHub workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)). Podman can expose a Docker-compatible API socket through `podman system service` and point clients at it with `DOCKER_HOST` ([podman-system-service manual](https://github.com/containers/podman/blob/main/docs/source/markdown/podman-system-service.1.md)), which is a setup task on the image rather than a flag in your workflow. macOS is the other boundary. macOS runners do not support nested virtualization and cannot run Docker ([cloud runners documentation](/docs/ci/cloud-runners)), and podman on macOS runs containers inside a Linux virtual machine of its own ([podman-machine manual](https://github.com/containers/podman/blob/main/docs/source/markdown/podman-machine.1.md)), so container image builds belong on the Linux labels either way. ### [What each placement costs](#what-each-placement-costs)Copy Anchor Link Rates below come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13. | Placement | Label or profile | Per minute | | -------------------------- | ------------------------------------------ | ---------- | | podman build on the runner | warp-ubuntu-latest-x64-2x, 2 vCPU, 8 GB | $0.004 | | podman build on the runner | warp-ubuntu-latest-x64-4x, 4 vCPU, 16 GB | $0.008 | | podman build on the runner | warp-ubuntu-latest-x64-8x, 8 vCPU, 32 GB | $0.016 | | podman build on the runner | warp-ubuntu-latest-arm64-4x, 4 vCPU, 16 GB | $0.006 | | Remote builder session | 16 vCPU, 32 GB, 100GB disk | $0.06 | Worked model, holding the minute count equal so the rate is the only variable. A repository builds 400 images a month at 8 minutes each, which is 3,200 minutes. * podman on `warp-ubuntu-latest-x64-4x`: 3,200 x $0.008 = **$25.60 per month**, plus registry pulls and pushes for the cache repository on every build. * Remote builder session on the 16 vCPU profile: 3,200 x $0.06 = **$192.00 per month** in builder session minutes, on top of the runner minutes the job still consumes, with the layer cache held on the builder disk. Minute counts do not stay equal in practice once a warm layer cache is involved, and that side of the arithmetic lives on the [Docker builds solution page](/solutions/docker-builds-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link **Is Podman already installed on GitHub Actions runners?** On Ubuntu images, yes. The Ubuntu 24.04 x64 image lists Podman 4.9.3, Buildah 1.33.7, and Skopeo 1.13.3 ([actions/runner-images](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md)), and the ARM64 partner image lists Podman and associated container tools. WarpBuild Ubuntu runners carry the same tooling as the GitHub-hosted images, so `podman build` works with no install step. **Can Podman use a remote Docker builder?** No. A remote builder is attached through a buildx remote driver over TLS ([Docker builders documentation](/docs/ci/docker-builders)), which is a BuildKit client feature, and podman has no BuildKit client. A podman-only workflow builds on the runner and keeps its cache in a registry. The profiles and their rates are in the [remote Docker builder catalog](/runners/docker-builders). **Do job containers and service containers work with Podman?** Not on their own. GitHub documents that Docker container actions, job containers, and service containers require a Linux runner with Docker installed ([GitHub workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)). Podman can serve a compatible API socket with `podman system service` and `DOCKER_HOST` ([podman-system-service manual](https://github.com/containers/podman/blob/main/docs/source/markdown/podman-system-service.1.md)), which is extra setup on the runner image. **What replaces docker/build-push-action in a Podman workflow?** A `podman login` step plus `podman build` and `podman push`, or [redhat-actions/buildah-build](https://github.com/redhat-actions/buildah-build) with [redhat-actions/push-to-registry](https://github.com/redhat-actions/push-to-registry) if you want the same step shape. Both routes take `--cache-from` and `--cache-to` against a registry repository, which is the only cache backend Buildah supports. For how layer caching works underneath either tool, see [what BuildKit is](/glossary/buildkit). Pick the labels for your build jobs from the [cloud runners documentation](/docs/ci/cloud-runners), price the minutes on the [pricing page](/pricing), and compare the runner-side build with a warm builder cache on the [Docker builds solution page](/solutions/docker-builds-github-actions). --- # Can Two Jobs Share One Docker Builder? | WarpBuild URL: https://www.warpbuild.com/answers/can-two-jobs-share-one-docker-builder Description: Yes. Jobs that name the same builder profile land on one builder VM, share its layer cache, and bill as one session. Concurrency, sizing, and cost. # Can Two Jobs Share One Docker Builder? Yes. Jobs that name the same builder profile land on one builder VM, share its layer cache, and bill as one session. Concurrency, sizing, and cost. Yes. Two GitHub Actions jobs that pass the same `profile-name` to the WarpBuild build action land on the same remote builder VM, share that VM's persistent layer cache, and bill as a single session. The [Docker builders documentation](/docs/ci/docker-builders) states that each builder profile corresponds to one dedicated builder virtual machine with caching, and that multiple jobs using the same builder profile run on that VM in parallel. ## [Answer](#answer)Copy Anchor Link The builder profile is the unit of sharing. Two jobs share a builder when they name the same profile, whatever runner label they carry and whatever workflow file they live in. Three things are shared the moment two jobs name the same profile: * **The VM.** One profile maps to one dedicated builder virtual machine. Two jobs pointing at it run their builds on that machine at the same time. * **The layer cache.** The cache lives on the builder's local disk, so the second job's build reads layers the first job's build wrote. This is why `cache-from` and `cache-to` are unnecessary with a builder profile, per the [Docker builders documentation](/docs/ci/docker-builders), and why the runner's own [cache](/docs/ci/features/caching) is a separate mechanism with a separate lifetime. * **The session.** Builders bill per session. Jobs with overlapping execution on one profile share one session, measured from the first job's builder start to the last job's completion. The concurrency behavior has two documented halves. There is no limit on the number of builds that can run concurrently on a builder profile, and the recommended minimum resource requirement is approximately 8 vCPU and 16GB memory per build job. So the profile will accept the fifth concurrent build; the size you picked decides whether that build gets the resources the [Docker builders documentation](/docs/ci/docker-builders) recommends. Cache consistency between two builds running at the same instant is the part teams get wrong. The [Docker builders documentation](/docs/ci/docker-builders) describes the cache on a profile as shared but eventually consistent: a layer produced by one concurrent build may not be visible to another concurrent build, and it becomes available to later builds after synchronization. Two jobs that start together and build the same base image will each build those base layers once. The same two jobs staggered by a few minutes, or run again tomorrow, read them from the profile. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. The builder is sized independently of the runner: the [Docker builders documentation](/docs/ci/docker-builders) is explicit that the size of the Docker machine is not related to the size of the GitHub runner the job uses. Both resources are billed, so a job that runs on a WarpBuild runner and builds on a WarpBuild builder pays runner minutes and builder minutes. ## [Detail](#detail)Copy Anchor Link ### [Two jobs on one profile, a third on its own](#two-jobs-on-one-profile-a-third-on-its-own)Copy Anchor Link The profile is named per build step, so a workflow can mix them. Here `api` and `worker` share `acme-services`, and `docs-site` uses a separate profile with a separate cache and a separate session. Copy Text ``` name: images on: push: branches: [main] jobs: api: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Build and push api uses: Warpbuilds/build-push-action@v6 with: context: ./services/api push: true tags: acme/api:${{ github.sha }} profile-name: "acme-services" worker: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Build and push worker uses: Warpbuilds/build-push-action@v6 with: context: ./services/worker push: true tags: acme/worker:${{ github.sha }} profile-name: "acme-services" docs-site: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Build and push docs site uses: Warpbuilds/build-push-action@v6 with: context: ./sites/docs push: true tags: acme/docs:${{ github.sha }} profile-name: "acme-docs" ``` The `api` and `worker` images share a Debian base and a set of shared Go modules, which is the reason to put them on one profile: the first build to pull that base populates the disk for the other. The docs site shares nothing with either, so it gets its own profile and stops competing for the same builder disk. No `api-key` input appears in this workflow because all three jobs run on WarpBuild runners, where the key is not required. Jobs on GitHub-hosted runners or any other runner need `api-key: ${{ secrets.WARPBUILD_API_KEY }}` added to each step, which is how the same profile can be shared by jobs that run nowhere near each other. ### [How the shared session is billed](#how-the-shared-session-is-billed)Copy Anchor Link The [Docker builders documentation](/docs/ci/docker-builders) gives the timeline explicitly. Two jobs, J1 and J2, use the same x64 profile: J1's builder starts at t1 and the job completes at t2, J2's builder starts at t3 and the job completes at t4, with t1 < t3 < t2 < t4\. That is billed as one session from t1 to t4. Put minutes on it. Say J1 runs from minute 0 to minute 9 and J2 from minute 4 to minute 13, both on the 32 vCPU, 64GB, 200GB profile at $0.12 per minute from the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13. | Setup | Sessions | Billed builder minutes | Cost | | ----------------------------- | ----------------------------- | ---------------------- | ----- | | One shared profile | 1, from minute 0 to minute 13 | 13 | $1.56 | | Two profiles of the same size | 2, of 9 minutes each | 18 | $2.16 | Sharing costs $0.60 less on this run and gives both builds one warm cache instead of two cold ones. The shape generalizes: two overlapping builds on one profile bill the union of their spans, and two builds on separate profiles bill the sum. The union is always smaller when the builds overlap. ### [Sizing the profile you share](#sizing-the-profile-you-share)Copy Anchor Link Divide the profile's vCPU by the documented 8 vCPU per concurrent build to get the number of builds it serves at the recommendation. Rates below come from the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13. | Builder size | Price per minute | Concurrent builds at 8 vCPU and 16GB each | Architectures | | --------------------------- | ---------------- | ----------------------------------------- | ------------------- | | 16 vCPU, 32GB, 100GB disk | $0.06 | 2 | amd64, arm64, multi | | 32 vCPU, 64GB, 200GB disk | $0.12 | 4 | amd64, arm64, multi | | 64 vCPU, 128GB, 200GB disk | $0.24 | 8 | amd64, arm64, multi | | 96 vCPU, 192GB, 600GB disk | $0.36 | 12 | amd64 only | | 96 vCPU, 192GB, 2TB disk | $0.52 | 12 | amd64 only | | 192 vCPU, 384GB, 600GB disk | $0.72 | 24 | amd64 only | | 192 vCPU, 384GB, 2TB disk | $0.88 | 24 | amd64 only | A three job matrix that builds all three images at once sits inside the 32 vCPU profile. A ten job matrix wants the 64 vCPU size. arm64 and multi-architecture profiles top out at 64 vCPU, so a wide matrix that also builds arm64 has to split across profiles rather than buy a larger one. ### [When a second profile is worth its own session](#when-a-second-profile-is-worth-its-own-session)Copy Anchor Link The break-even is stated in builder minutes, and it is strict. Splitting one profile into two replaces a session of N minutes with two sessions of N' and S minutes at the same rate, so the split pays only when the shared session loses at least S minutes: N - N' >= S. In the example above, the second session lasts 9 minutes, so the shared session would have to fall from 13 minutes to 4 minutes to break even. That is shorter than either build, so on billing alone the second profile does not pay. Here is what 10 minutes of second session costs at each size, so you can compare it against the minutes contention is costing you on the shared one. | Builder size | Price per minute | Cost of a 10 minute second session | | --------------------------- | ---------------- | ---------------------------------- | | 16 vCPU, 32GB | $0.06 | $0.60 | | 32 vCPU, 64GB | $0.12 | $1.20 | | 64 vCPU, 128GB | $0.24 | $2.40 | | 96 vCPU, 192GB, 600GB disk | $0.36 | $3.60 | | 96 vCPU, 192GB, 2TB disk | $0.52 | $5.20 | | 192 vCPU, 384GB, 600GB disk | $0.72 | $7.20 | | 192 vCPU, 384GB, 2TB disk | $0.88 | $8.80 | Three situations clear that bar without needing a stopwatch: 1. **Architecture.** A multi-architecture build runs each architecture on a separate builder instance, so one multi-arch profile already opens two sessions that bill independently. Splitting amd64 and arm64 into their own profiles changes the cache layout, not the session count. 2. **Cache competition.** Images that share no base layers still share the profile's disk, from 100GB on the smallest size to 2TB on the largest. Unrelated images evicting each other's layers is a cache problem that a bigger disk or a second profile both solve, and the second profile is usually cheaper. 3. **Blast radius.** A profile can be reset through the cache reset endpoint, and its cache is dropped automatically after a 10 day TTL of no use. One profile per image family keeps a reset or an expiry from cooling every build in the organization at once. Sizing up the shared profile is the other move, and it is worth pricing against the split. Going from 32 vCPU to 64 vCPU adds $0.12 per minute on one session; adding a second 32 vCPU profile adds $0.12 per minute for however long that second session runs. The larger single profile keeps one cache, which is the reason to prefer it when the images are related. ## [Related Questions](#related-questions)Copy Anchor Link ### [What happens when two builds hit the same builder profile at the same time?](#what-happens-when-two-builds-hit-the-same-builder-profile-at-the-same-time)Copy Anchor Link They run in parallel on the same builder VM. There is no documented limit on the number of builds that can run concurrently on one profile, and the documented recommended minimum is approximately 8 vCPU and 16GB memory per concurrent build job, per the [Docker builders documentation](/docs/ci/docker-builders). The [concurrent Docker builds guide](/guides/concurrent-docker-builds-github-actions) works through matrix builds that exceed that recommendation. ### [Do concurrent builds see each other's cache layers?](#do-concurrent-builds-see-each-others-cache-layers)Copy Anchor Link The cache is shared but eventually consistent. A layer written by one build may not be available to another build running at the same moment, and it becomes available to later builds after synchronization. Sequential builds on the profile get the full reuse. The [guide to sharing a Docker cache between jobs](/guides/share-docker-cache-between-jobs) covers the ordering patterns that make this predictable. ### [How is one profile billed when two jobs share it?](#how-is-one-profile-billed-when-two-jobs-share-it)Copy Anchor Link Builders are billed per session, measured from the first job's builder start to the last job's completion when the jobs overlap. A multi-architecture profile opens one session per architecture and each is billed independently. The [remote Docker builders page](/runners/docker-builders) lists every size and rate, and [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) covers the full setup from workflow file to builder profile. --- # Do ARM64 Runners Support Docker Buildx? | WarpBuild URL: https://www.warpbuild.com/answers/do-arm64-runners-support-docker-buildx Description: Yes. Buildx runs on ARM64 GitHub Actions runners and builds linux/arm64 natively, and an ARM64 job can attach a remote Docker builder just as an x64 job does. # Do ARM64 Runners Support Docker Buildx? Yes. Buildx runs on ARM64 GitHub Actions runners and builds linux/arm64 natively, and an ARM64 job can attach a remote Docker builder just as an x64 job does. Yes. Docker Buildx runs on ARM64 GitHub Actions runners, so `docker buildx build --platform linux/arm64` on a label such as `warp-ubuntu-latest-arm64-4x` produces arm64 layers on aarch64 hardware with no emulation in the path ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). An ARM64 job can also attach a remote Docker builder exactly as an x64 job does, because the builder is reached over a TLS endpoint and the runner architecture never constrains which builder the job drives ([Docker builders documentation](/docs/ci/docker-builders)). ## [Answer](#answer)Copy Anchor Link Buildx is a CLI plugin that talks to a builder. Two things decide whether it works on an ARM64 runner: whether the plugin binary exists for aarch64, and whether the builder it points at can produce the platform you asked for. The plugin exists. The Ubuntu 24.04 ARM64 runner images are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the package list for those images is published at [actions/partner-runner-images](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md), including Docker and the Buildx plugin. Ubuntu 26.04 ARM64 tracks [actions/runner-images](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2604-Arm64-Readme.md). [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) also publishes a `linux/arm64` release, so a pinned setup step behaves the same on both architectures. The builder side has two shapes. The default `docker` driver on the runner itself builds `linux/arm64` natively, since the host is already aarch64\. The `remote` driver points buildx at a builder virtual machine over `tcp://`, and WarpBuild's `docker-configure` action performs that `docker buildx create --driver remote` handshake for you inside the job ([Docker builders documentation](/docs/ci/docker-builders)). Attaching the remote builder from an ARM64 job is one action and a profile name: Copy Text ``` name: build-arm64-image on: push: branches: [main] jobs: image: runs-on: warp-ubuntu-latest-arm64-4x steps: - uses: actions/checkout@v4 - name: Confirm the runner architecture run: uname -m - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push on a remote builder uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: linux/arm64 tags: ghcr.io/${{ github.repository }}:${{ github.sha }} profile-name: "arm64-builder" ``` `uname -m` prints `aarch64`. `Warpbuilds/build-push-action` is a drop-in replacement for `docker/build-push-action` that sets up the remote builder itself, so the `docker/setup-buildx-action` step comes out when it was only there to create a builder ([Docker builders documentation](/docs/ci/docker-builders)). On WarpBuild runners the API key is not required; off them, pass `api-key: ${{ secrets.WARPBUILD_API_KEY }}`. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and pricing is purely usage based. Per-minute rates are on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [The local builder on an ARM64 runner](#the-local-builder-on-an-arm64-runner)Copy Anchor Link With no remote builder attached, the build runs on the runner. Ten ARM64 labels are available across Ubuntu 24.04 and Ubuntu 26.04, from 2 vCPU at $0.003 per minute to 32 vCPU at $0.048 per minute, each with 150GB SSD of ephemeral storage ([Linux ARM64 runner catalog](/runners/linux-arm64), checked on 2026-08-13). The build, the layer cache, and the context all live inside that disk and disappear when the job ends, so cache continuity has to come from somewhere external: Copy Text ``` jobs: image: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - name: Show the active builder run: docker buildx inspect --bootstrap - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . platforms: linux/arm64 push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:arm64-buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:arm64-buildcache,mode=max ``` `docker buildx inspect --bootstrap` prints the driver and the platforms the builder advertises, which is the fastest way to confirm what a failing job is actually pointed at. On a remote builder, `cache-from` and `cache-to` are not required, because the builder disk holds the layer cache across jobs ([Docker builders documentation](/docs/ci/docker-builders)). One caveat is specific to ARM64 runners: the arm64 images for Ubuntu 24.04 set the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/` for the same instance ([cloud runners documentation](/docs/ci/cloud-runners)). Any `docker run -v /home/runner/work/...` mount or hardcoded context path breaks there, so use `${{ github.workspace }}` in YAML and `$GITHUB_WORKSPACE` in shell steps. ### [Builder profile rates, and the arithmetic that picks a route](#builder-profile-rates-and-the-arithmetic-that-picks-a-route)Copy Anchor Link Remote Docker builder profiles bill per session, measured from when the builder action starts until the job completes, and jobs sharing a profile share one session. Every rate below comes from the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13. | Profile size | Disk | Per minute | Architectures | | --------------- | ----- | ---------- | ------------------- | | 16 vCPU, 32GB | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU, 64GB | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU, 128GB | 200GB | $0.24 | amd64, arm64, multi | | 96 vCPU, 192GB | 600GB | $0.36 | amd64 only | | 96 vCPU, 192GB | 2TB | $0.52 | amd64 only | | 192 vCPU, 384GB | 600GB | $0.72 | amd64 only | | 192 vCPU, 384GB | 2TB | $0.88 | amd64 only | arm64 and multi-arch profiles stop at 64 vCPU. The 96 vCPU and 192 vCPU sizes are amd64 only, so an arm64 profile tops out at the $0.24 row. The runner keeps billing while the remote build runs, so the two rates add up. A job on `warp-ubuntu-latest-arm64-8x` at $0.012 per minute driving the 16 vCPU profile at $0.06 per minute costs $0.072 for every minute the build is in flight, against $0.012 per minute when the same build runs on the runner itself. For one job in isolation, the remote build has to finish inside a sixth of the local wall clock time before the pair costs less. Two structural facts move that arithmetic, and both are why teams with heavy Dockerfiles pick the remote route anyway: 1. **Session billing across concurrent jobs.** Ten jobs that overlap on one profile produce one session, not ten. Eight minutes of overlap on the 16 vCPU profile is $0.48 of builder time for the whole fan-out, so the per-job builder cost falls as concurrency rises. 2. **The cache and the disk are persistent.** The builder disk carries the layer cache between jobs and between pull requests, and it runs from 100GB to 2TB against the runner's 150GB SSD. A profile that goes unused for 10 days has its cache reset automatically, and concurrent builds see an eventually consistent cache, so a layer written by one in-flight build may only be visible to the next one ([Docker builders documentation](/docs/ci/docker-builders)). Builder size is chosen independently of runner size, so a 2 vCPU ARM64 runner can drive a 64 vCPU arm64 profile. The runner and the builder are separate resources and both appear on the bill. The [remote Docker builder catalog](/runners/docker-builders) lists every profile size with its rate. ### [Where the arm64 architecture setting bites](#where-the-arm64-architecture-setting-bites)Copy Anchor Link The most common failure is `exec format error` during an arm64 or multi-platform build, and the cause is a profile without arm64 enabled ([Docker builders documentation](/docs/ci/docker-builders)). Enable both architectures on the profile before a job sets `platforms: linux/amd64,linux/arm64`. A multi-architecture build runs each architecture on a separate builder instance, so it opens one session per architecture and each session bills independently. The [multi-platform builder answer](/answers/do-remote-docker-builders-support-multi-platform-builds) covers that split. ## [Related Questions](#related-questions)Copy Anchor Link **Is Docker Buildx preinstalled on ARM64 runners?** Yes. The Ubuntu 24.04 ARM64 images are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the published package list at [actions/partner-runner-images](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md) includes Docker and the Buildx CLI plugin. `docker/setup-buildx-action` installs a pinned `linux/arm64` release when you want a specific version. Sizes and labels are in the [Linux ARM64 runner catalog](/runners/linux-arm64). **Does attaching a remote builder change what the ARM64 job needs?** The job needs the builder profile name, plus `api-key` when it runs off WarpBuild runners. The build executes on the builder virtual machine, so the runner size stops driving build time, and the profile must have arm64 enabled before the job sets `platforms: linux/arm64` ([Docker builders documentation](/docs/ci/docker-builders)). **Why does an arm64 build fail with exec format error?** The builder profile is missing the arm64 architecture, so the build lands on an amd64 builder instance. Enable arm64 on the profile and rerun. The same setting governs multi-architecture builds, covered in the [multi-platform builder answer](/answers/do-remote-docker-builders-support-multi-platform-builds). **Do I pay for the ARM64 runner and the builder at the same time?** Yes. They are two separate resources on two lines. The runner bills per minute at its label rate, and the builder bills per session from the first job start to the last job completion on that profile, so overlapping jobs on one profile share one billable stretch ([remote Docker builder catalog](/runners/docker-builders)). Pick an ARM64 label from the [Linux ARM64 runner catalog](/runners/linux-arm64), size the builder profile from the [remote Docker builder catalog](/runners/docker-builders), and price both against your own build minutes on the [pricing page](/pricing). --- # Do ARM64 Runners Use a Different Work Directory? | WarpBuild URL: https://www.warpbuild.com/answers/do-arm64-runners-use-a-different-work-directory Description: Yes. Ubuntu 24.04 ARM64 runners set the work directory to /runner/_work instead of the /home/runner/work path GitHub-hosted runners use. Read GITHUB_WORKSPACE. # Do ARM64 Runners Use a Different Work Directory? Yes. Ubuntu 24.04 ARM64 runners set the work directory to /runner/\_work instead of the /home/runner/work path GitHub-hosted runners use. Read GITHUB\_WORKSPACE. Yes. The arm64 images for Ubuntu 24.04 set the runner work directory to `/runner/_work`, which differs from GitHub's work directory `/home/runner/work/` for the same instance ([WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). The layout under that prefix is unchanged and every path variable still resolves, so a workflow breaks only where it hardcodes the literal `/home/runner/work` string. ## [Answer](#answer)Copy Anchor Link The prefix moves. Nothing under it does. | Value | GitHub-hosted runner | Ubuntu 24.04 ARM64 runner | | ------------------------------------------ | ----------------------------------------- | -------------------------------------- | | Runner work directory | /home/runner/work | /runner/\_work | | Checkout of a repository named api | /home/runner/work/api/api | /runner/\_work/api/api | | A file at dist/app.tar.gz in that checkout | /home/runner/work/api/api/dist/app.tar.gz | /runner/\_work/api/api/dist/app.tar.gz | The work directory is a property of how the runner agent was configured when the image was built, not a property of the architecture. The agent takes the directory as a configuration argument at registration time ([actions/runner](https://github.com/actions/runner)), and GitHub then exposes the resolved paths to your steps through default environment variables ([GitHub variables reference](https://docs.github.com/en/actions/reference/workflows-and-actions/variables)). A step that reads those variables gets the right answer on any runner. A step that repeats the GitHub-hosted string gets the wrong answer on ARM64. Four things in a typical repository carry that literal: 1. `run:` steps that build an absolute path by hand, usually to hand a file to a tool that wants one. 2. `actions/cache` entries whose `path:` was copied from a log line rather than written relative to the workspace. 3. Docker `-v` mounts and `--mount` sources in jobs that shell out to `docker run`. 4. Config files checked into the repository: `sonar-project.properties`, coverage exclusion lists, `Makefile` variables, and anything else holding a build path. The replacement is one of two forms depending on where you are writing: | Do not write | In YAML, write | In a shell step, write | | ------------------------- | ------------------------- | ---------------------- | | /home/runner/work/api/api | ${{ github.workspace }} | $GITHUB\_WORKSPACE | | /home/runner/work/\_temp | ${{ runner.temp }} | $RUNNER\_TEMP | | A hosted tool cache path | ${{ runner.tool\_cache }} | $RUNNER\_TOOL\_CACHE | The `github` and `runner` contexts are documented in the [GitHub contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts). Both forms are populated by the runner agent, so the same workflow file runs unchanged on `warp-ubuntu-latest-arm64-4x`, on `warp-ubuntu-latest-x64-4x`, and on a GitHub-hosted runner. Copy Text ``` name: build on: pull_request: jobs: build: runs-on: warp-ubuntu-latest-arm64-4x steps: - uses: actions/checkout@v4 - name: Print the paths this runner reports run: | echo "workspace: $GITHUB_WORKSPACE" echo "temp: $RUNNER_TEMP" echo "arch: ${{ runner.arch }}" - uses: actions/cache@v4 with: path: ~/.cargo/registry key: cargo-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock') }} - name: Build run: cargo build --release --target-dir "$GITHUB_WORKSPACE/target" - name: Stage the archive run: | tar -czf "$RUNNER_TEMP/app.tar.gz" -C "$GITHUB_WORKSPACE/target/release" app - uses: actions/upload-artifact@v4 with: name: app-linux-arm64 path: ${{ runner.temp }}/app.tar.gz ``` WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the ARM64 line runs from `warp-ubuntu-latest-arm64-2x` at 2 vCPU and 8 GB for $0.003 per minute to `warp-ubuntu-latest-arm64-32x` at 32 vCPU and 128 GB for $0.048 per minute ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). Every label and rate is on the [Linux ARM64 runner catalog](/runners/linux-arm64) and the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [What the home directory difference is not](#what-the-home-directory-difference-is-not)Copy Anchor Link Two paths get confused here. The work directory holds checkouts, the temp directory, and the per-job scratch space. The home directory holds per-user caches such as `~/.cargo`, `~/.npm`, `~/.m2`, and `~/.gradle`. Moving the work directory does not move the home directory, so a cache entry written as `~/.npm` or `$HOME/.gradle/caches` behaves the same on both images. Audit the literals that begin with `/home/runner/work`, and leave the `~` entries alone. ### [The grep pass to run before the migration](#the-grep-pass-to-run-before-the-migration)Copy Anchor Link Run this from the repository root. It takes a minute and it finds the entries that would otherwise fail on the first ARM64 job. Copy Text ``` # 1. Workflow files and composite actions grep -rn --include='*.yml' --include='*.yaml' \ -e '/home/runner' -e '_work' .github/ # 2. Anything checked in that a build tool reads grep -rn -e '/home/runner' \ --include='*.properties' --include='*.toml' --include='*.cfg' \ --include='*.json' --include='Makefile' --include='Dockerfile*' . # 3. Shell and build helper scripts grep -rn -e '/home/runner' -e 'hostedtoolcache' scripts/ ci/ 2>/dev/null # 4. Docker volume mounts written inline grep -rn -e 'docker run' -A2 .github/ | grep -e '-v /' -e '--mount' ``` Work through the hits in this order: * Workflow `run:` steps. Replace the literal with `$GITHUB_WORKSPACE` or `$RUNNER_TEMP`. * `actions/cache` and `actions/upload-artifact` path lists. Rewrite them relative to the workspace, which is the default working directory for a step, so a bare `dist/` is usually enough. * Docker mounts. Use `-v "$GITHUB_WORKSPACE:/src"` and keep the container side of the mount fixed. * Checked-in config. Where a tool insists on an absolute base directory, pass it as a flag from the workflow rather than storing it in the file, for example `-Dsonar.projectBaseDir="$GITHUB_WORKSPACE"`. * Cached tool state that records absolute paths, such as compiler caches and coverage reports. Those entries are keyed to the path they were written under, so add `runner.arch` to the cache key and let the ARM64 leg build its own entry. ### [Proving it before you flip a production workflow](#proving-it-before-you-flip-a-production-workflow)Copy Anchor Link Add one job that runs on both architectures and prints what each runner reports. A matrix leg costs a few cents and it removes the guesswork. Copy Text ``` jobs: paths: strategy: fail-fast: false matrix: runner: - warp-ubuntu-latest-x64-2x - warp-ubuntu-latest-arm64-2x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - name: Report paths run: | echo "runner: ${{ matrix.runner }}" echo "pwd: $(pwd)" echo "workspace: $GITHUB_WORKSPACE" echo "temp: $RUNNER_TEMP" test "$(pwd)" = "$GITHUB_WORKSPACE" ``` The final `test` line turns an assumption into a failing step. Keep it in the repository as a smoke job while the migration is in flight. When a real job fails and the log does not say why, the surrounding product surface is the faster route than adding print statements. The Action Debugger opens a shell on the failed runner so you can `ls /runner/_work` and read the actual layout, and CI observability shows whether the failure is on one architecture or both. Jobs that never reach a runner at all are a different problem, covered in the [common issues documentation](/docs/ci/common-issues). ### [What stays the same](#what-stays-the-same)Copy Anchor Link `actions/checkout` clones into `GITHUB_WORKSPACE` on both images, so the relative layout of the checkout is unchanged. Marketplace actions that use the toolkit path helpers resolve through the same variables. Service containers, job containers, artifacts, and the default working directory for `run:` steps all follow the workspace. The rest of the x64 to ARM64 move is covered in [what changes when a workflow moves from x64 to ARM64](/answers/what-changes-when-i-move-a-workflow-from-x64-to-arm64), and the full sequence is in the [ARM64 migration checklist for GitHub Actions](/guides/arm64-migration-checklist-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does the work directory difference apply to Ubuntu 26.04 ARM64 runners as well?](#does-the-work-directory-difference-apply-to-ubuntu-2604-arm64-runners-as-well)Copy Anchor Link The difference is recorded against the arm64 Ubuntu 24.04 images, which the `warp-ubuntu-latest-arm64` labels resolve to ([cloud runners documentation](/docs/ci/cloud-runners)). Rather than track the value per image, read the path from `GITHUB_WORKSPACE` and `RUNNER_TEMP` in shell steps and from `${{ github.workspace }}` and `${{ runner.temp }}` in YAML. Those resolve correctly on every image and on GitHub-hosted runners, which is why the variables exist. Both Ubuntu releases and all ten ARM64 labels are listed on the [Linux ARM64 runner catalog](/runners/linux-arm64). ### [Do I need to change my actions/cache paths when I move a job to ARM64?](#do-i-need-to-change-my-actionscache-paths-when-i-move-a-job-to-arm64)Copy Anchor Link Only the entries whose `path:` is a literal string starting with `/home/runner/work`. Paths written relative to the workspace or relative to the home directory keep working, because the work directory setting does not move the home directory. Add `${{ runner.arch }}` to the cache key so the x64 and ARM64 legs of a matrix stop overwriting each other, and treat any cache holding compiled output or absolute-path metadata as per-architecture. ### [Does actions/checkout put the repository somewhere else on an ARM64 runner?](#does-actionscheckout-put-the-repository-somewhere-else-on-an-arm64-runner)Copy Anchor Link It clones into `GITHUB_WORKSPACE`, so the absolute path changes with the work directory while the layout under it stays the same. A repository named `api` lands at `/runner/_work/api/api` on the ARM64 image and at `/home/runner/work/api/api` on a GitHub-hosted runner. Steps that use relative paths see no difference at all. --- # Do BYOC Runners Need a NAT Gateway? | WarpBuild URL: https://www.warpbuild.com/answers/do-byoc-runners-need-a-nat-gateway Description: Only when the runners sit in private subnets. BYOC runners need an outbound route to the documented destinations, and on AWS NAT gateways provide it. # Do BYOC Runners Need a NAT Gateway? Only when the runners sit in private subnets. BYOC runners need an outbound route to the documented destinations, and on AWS NAT gateways provide it. Only when the runners sit in private subnets. BYOC runners need an outbound route to the destinations listed in the [AWS BYOC configuration prerequisites](/docs/ci/byoc/aws/config), and on AWS managed NAT gateways are how a private subnet gets that route; runners in public subnets reach the same destinations through the Internet Gateway with no gateway meter attached. ## [Answer](#answer)Copy Anchor Link The documented requirement is internet connectivity. Which device provides it depends on where the runner instance sits. The [AWS BYOC configuration prerequisites](/docs/ci/byoc/aws/config) state it directly: the VPC must have at least one public and one private subnet, the subnets must have internet connectivity, and the private subnets must route through NAT gateways. Route tables send internet-bound traffic through the Internet Gateway, and private subnets carry outbound traffic to the managed NAT service. Runners configured with static IPs use the addresses of those gateways as their external addresses, which is the one case where the managed service is mandatory rather than optional. The route exists because a runner talks outbound before it does any work: it reaches the WarpBuild control plane, the GitHub API, and package managers before it can pull an image or run a step. The documented security group blocks all inbound traffic and allows outbound traffic to WarpBuild servers, the GitHub API, and package managers, so nothing about the design needs an inbound path into your VPC ([AWS BYOC configuration](/docs/ci/byoc/aws/config)). | Outbound route | What the runner gets | What it costs to operate | | ------------------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Public subnet, Internet Gateway route | Reach to the WarpBuild control plane, the GitHub API, registries, and package mirrors | No hourly or per-GB charge for the gateway. Data transfer in from the internet is $0.00 per GB ([Amazon EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/), checked on 2026-08-13) | | Private subnet, managed NAT service | The same reach, plus a stable outbound address per availability zone | $0.045 per gateway-hour plus $0.045 per GB processed in both directions in US East (N. Virginia) ([Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13) | | S3 endpoint for the stack bucket | Cache and telemetry reads and writes to the stack bucket without leaving the VPC | Documented as connecting to the bucket without incurring data transfer charges ([AWS BYOC configuration](/docs/ci/byoc/aws/config)) | | ECR endpoints inside the VPC | Registry authentication and image pulls from both public and private subnet runners | Configured by WarpBuild. Stacks on CloudFormation template versions before v1.4 can hit ECR login timeouts from public subnets ([AWS BYOC configuration](/docs/ci/byoc/aws/config)) | | No outbound route | Nothing. The agent cannot reach the control plane or the GitHub API | The job sits queued until it times out | BYOC runs on AWS, GCP, and Azure, and the shape is the same on all three: public subnet runners route straight out, private subnet runners route through the provider's managed translation service. The numbers on this page are the AWS ones, so price the GCP and Azure equivalents from their own published pricing pages before you commit to a layout. ## [Detail](#detail)Copy Anchor Link ### [What the prerequisites actually ask for](#what-the-prerequisites-actually-ask-for)Copy Anchor Link | Prerequisite | Documented shape | | ---------------------- | ---------------------------------------------------------------------------------------------------- | | Subnets | At least one public and one private subnet, both with internet connectivity | | Private subnet routing | NAT gateways required; static IP runners use the gateway addresses as external addresses | | Availability zones | Three public and three private subnets in different zones, for instance type availability | | Subnet size | At least 250 IPs per subnet, sized to peak concurrent runners | | Security group | Block all inbound traffic; allow outbound to WarpBuild servers, the GitHub API, and package managers | | Stack bucket | An S3 endpoint for the VPC, so bucket traffic carries no data transfer charge | | Registry | WarpBuild configures ECR endpoints inside the VPC for public and private subnet runners | Rows come from the [AWS BYOC configuration prerequisites](/docs/ci/byoc/aws/config), checked on 2026-08-13\. The full network breakdown, including quota headroom per subnet, is on [network requirements for BYOC runners on AWS](/byoc/aws/networking), and the [BYOC on AWS overview](/byoc/aws) covers the stack around it. ### [What fills the gateway bill](#what-fills-the-gateway-bill)Copy Anchor Link Two traffic sources dominate: container image pulls and package downloads. Everything else a runner sends is small by comparison. Take a fleet of 2,000 jobs a month, half of them pulling a container image. | Input | Value | Source | | ----------------------------------- | ------------------------------ | --------------------------------------------------------- | | Jobs per month | 2,000 | Your workflow run history | | Jobs that pull a container image | 1,000 | Your workflow run history | | Compressed image size | 1.2 GB | Your registry metrics | | Package bytes downloaded per job | 0.4 GB | Your dependency manifest and cache hit rate | | Agent, API, and log traffic per job | 0.05 GB | Your runner telemetry | | Job duration | 8 minutes | Your workflow run history | | NAT gateways in the stack | 3, one per availability zone | [AWS BYOC configuration](/docs/ci/byoc/aws/config) | | Managed NAT hourly charge | $0.045 per gateway-hour | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/) | | Managed NAT data processing | $0.045 per GB, both directions | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/) | | BYOC Linux runner rate | $0.002 per minute | [pricing page](/pricing) | Image pull bytes: 1,000 times 1.2 equals 1,200 GB. Package bytes: 2,000 times 0.4 equals 800 GB. Agent, API, and log traffic: 2,000 times 0.05 equals 100 GB. | Traffic source | Monthly GB | Data processing at $0.045 per GB | | --------------------------- | ---------- | -------------------------------- | | Container image pulls | 1,200 | $54.00 | | Package downloads | 800 | $36.00 | | Agent, GitHub API, and logs | 100 | $4.50 | | Total | 2,100 | $94.50 | The fixed side is 3 gateways times 730 hours times $0.045, which is $98.55 a month whether one job runs or every job does. Total AWS network charge on this profile: $193.05 a month, of which image pulls and package downloads account for 2,000 of the 2,100 metered gigabytes. Set that next to the WarpBuild line for the same fleet. 2,000 jobs times 8 minutes is 16,000 minutes, and BYOC Linux runners are $0.002 per minute, which is $32.00 ([pricing page](/pricing), checked on 2026-08-13). On a private-subnet layout carrying this much image traffic, your own network line is the larger of the two, which is why the placement decision deserves a look before the runner size does. ### [Four levers on that number](#four-levers-on-that-number)Copy Anchor Link 1. Put only the workflows that need an allowlisted source address on private-subnet runners. Everything else runs in public subnets, where inbound bytes are $0.00 per GB. 2. Keep the documented S3 endpoint in place, so the artifact cache and runner telemetry stay off the meter. 3. Cut the bytes. Smaller images and higher cache hit rates move both of the dominant lines at once; the arithmetic is worked through in [cut ECR pull costs in GitHub Actions](/guides/ecr-pull-costs-github-actions). 4. Weigh zone count deliberately. One gateway is $32.85 a month against $98.55 for three, and the three-zone layout is the documented recommendation because it widens instance type availability. On the enterprise tier, egress costs from your cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores during deployments, on BYOC and on WarpBuild-hosted runners alike; the terms are on the [zero egress page](/enterprise/zero-egress). One clarification that comes up in reviews: joining runners to a private network with the [networking addon](/docs/ci/features/networking) does not replace the outbound route. The addon gives a job reach into your own private services. Registration with WarpBuild and GitHub still travels the public path. ## [Related Questions](#related-questions)Copy Anchor Link **Can I run BYOC runners without NAT gateways?** Yes, when the runners sit in public subnets and the route table sends internet-bound traffic through the Internet Gateway. The documented prerequisite is that private subnets route through NAT gateways, and runners configured with static IPs use the gateway addresses as their external addresses ([AWS BYOC configuration](/docs/ci/byoc/aws/config)). Subnet placement is part of the setup covered on the [BYOC on AWS overview](/byoc/aws). **How many NAT gateways does a WarpBuild AWS stack use?** Three in the recommended layout, one per availability zone. At $0.045 per gateway-hour in US East (N. Virginia), three gateways are $98.55 a month before any data moves ([Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13). The per-subnet detail sits on [network requirements for BYOC runners on AWS](/byoc/aws/networking). **What fills the gateway bill on a runner fleet?** Container image pulls and package downloads. On the model above they are 2,000 of the 2,100 GB a fleet moves in a month, and agent traffic, GitHub API calls, and log shipping are the remaining 100 GB. For the definition of the device doing the metering, see [NAT gateways explained](/glossary/nat-gateway). Size your own layout against the prerequisites in the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config), price the runner minutes on the [pricing page](/pricing), and start on the [BYOC on AWS overview](/byoc/aws). --- # Do Caches Carry Across Branches? | WarpBuild URL: https://www.warpbuild.com/answers/do-caches-carry-across-branches-in-github-actions Description: Caches carry in one direction. A run reads entries from its own branch and from the default branch, so a feature branch starts from whatever main last wrote. # Do Caches Carry Across Branches? Caches carry in one direction. A run reads entries from its own branch and from the default branch, so a feature branch starts from whatever main last wrote. Caches carry across branches in one direction. A workflow run can restore entries written on its own Git ref and entries written on the repository's default branch, and nothing else, so a brand new feature branch starts from whatever the last run on `main` wrote, while two sibling feature branches are invisible to each other. ## [Answer](#answer)Copy Anchor Link The restore path is short and worth memorizing. GitHub documents it under restrictions for accessing a cache in the [dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13. * A run searches its own ref first. A push to `feature-a` records `refs/heads/feature-a`, a pull request run records `refs/pull//merge`, and a tag run records `refs/tags/`. * It then searches the default branch, usually `refs/heads/main`. * A pull request run also reaches the base branch it targets, including when the pull request arrives from a fork. * The search stops there. No other ref is consulted, and no other repository is consulted. Scope is checked before the key. An entry whose key matches your request byte for byte still returns a miss when it sits outside the searched scopes, and the log reports an ordinary cache miss with no indication that a matching entry exists elsewhere. ### [Three branches, and what each one can read](#three-branches-and-what-each-one-can-read)Copy Anchor Link Take a repository whose default branch is `main`, with two feature branches, and a pull request opened from `feature-b` into `main`. Each row is a run; each column is a set of entries the run may read. | Run | Entries on refs/heads/main | Entries on refs/heads/feature-a | Entries on refs/heads/feature-b | Entries on refs/pull/318/merge | | ------------------------------- | ---------------------------- | ------------------------------- | ------------------------------- | ------------------------------ | | Push to main | Readable | Out of scope | Out of scope | Out of scope | | Push to feature-a | Readable | Readable | Out of scope | Out of scope | | Push to feature-b | Readable | Out of scope | Readable | Out of scope | | Pull request 318 from feature-b | Readable, as the base branch | Out of scope | Out of scope | Readable | Three consequences fall out of that grid. Every run reads `main`. That column is the only one with a mark in every row, which makes the default branch the single place worth seeding. A branch cannot borrow from a sibling. `feature-a` and `feature-b` may install the identical dependency set from the identical lockfile, and each pays for its own install and stores its own copy. A pull request and a push on the same branch are separate scopes. Pushing to `feature-b` writes `refs/heads/feature-b`, while the pull request run writes `refs/pull/318/merge`, so the two event types on one branch keep two sets of entries and neither refreshes the other. The [cache scope definition](/glossary/cache-scope) works through the ref identity in more detail. WarpBuild Cache follows the same model. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` with the same `key`, `path`, and `restore-keys` inputs, and the entry address is the key, the version hash, and the branch, per the [WarpBuild caching documentation](/docs/ci/features/caching). The cache is enabled by default on Linux runners. ## [Detail](#detail)Copy Anchor Link ### [The branch rule decides where restore-keys are useful](#the-branch-rule-decides-where-restore-keys-are-useful)Copy Anchor Link `restore-keys` widens the key match, and it does nothing to widen the scope. A prefix such as `deps-Linux-X64-` searches for the most recent matching entry inside the refs the run may already read, which means a feature branch falling back on a prefix is almost always landing on an entry written by `main`. That is the useful behavior, and it sets the quality bar. When the entry on `main` is a week stale, every feature branch in the repository restores a week-stale tree and pays the reconcile cost. The entry on the default branch is shared infrastructure, and its freshness is the number that moves branch job durations. Pull request runs cannot fix this themselves. A pull request run writes into its own merge ref, so however good the tree it builds, the next branch that starts fresh will never see it. Only a run on the default branch can refresh what everyone reads. ### [Warm the default branch on a schedule](#warm-the-default-branch-on-a-schedule)Copy Anchor Link The pattern that follows from the grid is a small workflow that runs on the default branch on a timer, restores the shared entry, brings it up to date, and saves it back. Feature branches then inherit a current entry on their first run instead of a stale one. Two GitHub behaviors make the schedule trigger the right fit. Scheduled workflows run only on the default branch, so the entry lands in the scope every branch can read without any branch conditionals. Scheduled workflows are also disabled automatically after 60 days of repository inactivity, per the [events that trigger workflows reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), so add `workflow_dispatch` for a manual restart. Copy Text ``` name: warm-cache on: schedule: - cron: "17 4 * * *" push: branches: [main] workflow_dispatch: concurrency: group: warm-cache cancel-in-progress: true jobs: warm: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Restore dependency cache id: deps uses: WarpBuilds/cache/restore@v1 with: path: | node_modules .cache/turbo key: deps-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('bun.lock') }} restore-keys: | deps-${{ runner.os }}-${{ runner.arch }}- - run: bun install --frozen-lockfile - run: bun run build - name: Save dependency cache if: steps.deps.outputs.cache-hit != 'true' uses: WarpBuilds/cache/save@v1 with: path: | node_modules .cache/turbo key: deps-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('bun.lock') }} ``` Four details carry the design. The cron minute is `17` rather than `0`. GitHub delays scheduled workflows during periods of high load, and names the start of every hour as one of those windows, per the [events that trigger workflows reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule), so an off-peak minute reduces the drift between the schedule and the actual start. The save step is guarded on `cache-hit`. An exact hit means the entry already exists under that key and a second write would be rejected, so on a quiet day the run restores, confirms, and exits. That restore is what matters for lifetime: WarpBuild Cache entries expire 7 days after their last use, per the [caching documentation](/docs/ci/features/caching), and a restore counts as a use, so a daily schedule keeps the shared entry from aging out during a slow week. The `push` trigger sits alongside the schedule so that a merged dependency bump refreshes the entry immediately rather than waiting for the next timer tick. The `concurrency` group prevents two warming runs from racing when a merge lands close to a scheduled tick. Both would compute the same key and one would waste its runner minutes losing the write. When the shared content is a language toolchain or a package manager directory, the key and the path list can come from a setup action instead of from your workflow file. `WarpBuilds/setup-node@v6`, `WarpBuilds/setup-go@v6`, and the rest of the list in the [setup actions documentation](/docs/ci/features/caching/setup-actions) compute both, which removes the risk of the warming job and the branch jobs drifting to different path lists and therefore different version hashes. ### [What the warming job costs](#what-the-warming-job-costs)Copy Anchor Link Rates below are from the [pricing page](/pricing), checked on 2026-08-13\. The assumptions are stated so you can substitute your own: one warming run per day for 30 days on `warp-ubuntu-latest-x64-4x` at 4 minutes per run, one restore and one save operation per run, and a 2.5 GB entry held on the default branch for the whole month. | Line | Monthly quantity | Rate | Cost | | ---------------------------------- | ---------------- | --------------------- | ----- | | Warming runner minutes | 120 minutes | $0.008 per minute | $0.96 | | Cache operations | 60 operations | $0.0001 per operation | $0.01 | | Shared entry on the default branch | 2.5 GB-months | $0.20 per GB-month | $0.50 | | Total | $1.47 | | | Set that against the branch side. At 600 feature branch runs in the month, each skipping 2 minutes of dependency installation on the same runner size, the repository avoids 1,200 runner minutes, which is $9.60 of runner billing at $0.008 per minute. The sensitive assumption is the install time a warm entry actually removes: below about 20 seconds per run, the warming job costs more than it returns, and the shared entry is better left to refresh on merges alone. On BYOC runners, cache storage and cache operations are both free, so only the warming runner minutes remain. Two structural notes belong with any of these numbers. The [persistent caches guide](/guides/persistent-caches-github-actions) covers the cases where whole-machine state beats path-level caching entirely. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can a pull request from a fork restore my repository's cache?](#can-a-pull-request-from-a-fork-restore-my-repositorys-cache)Copy Anchor Link A pull request run can restore entries written on the base branch, including when the pull request comes from a fork. It cannot write into the base branch scope. Entries a fork pull request saves land on its own merge ref and are readable only by later runs on that same pull request, which is the isolation boundary that keeps an untrusted contributor from poisoning the entry every branch reads. The [cache scope definition](/glossary/cache-scope) lists the ref each event type records. ### [Why does my feature branch miss an entry another feature branch just wrote?](#why-does-my-feature-branch-miss-an-entry-another-feature-branch-just-wrote)Copy Anchor Link Two feature branches are two separate scopes. A restore request searches the run's own ref and then the default branch, and stops there, so an entry on `refs/heads/feature-a` is invisible to a run on `feature-b` even when the key matches exactly. Seed the shared entry from the default branch instead, using the warming workflow above. [Can I share a cache between GitHub Actions workflows](/answers/can-i-share-a-cache-between-github-actions-workflows) covers the key and version half of the same match. ### [How often should the default-branch warming job run?](#how-often-should-the-default-branch-warming-job-run)Copy Anchor Link More often than the expiry window. WarpBuild Cache entries expire 7 days after their last use, and a restore counts as a use, so a daily or every-other-day schedule keeps the shared entry alive and current. Scheduled workflows run only on the default branch, which puts the entry in the scope every branch can read. The [persistent caches guide](/guides/persistent-caches-github-actions) walks through the cadence against entry size. ### [What if the branch boundary is not the problem, and the repository boundary is?](#what-if-the-branch-boundary-is-not-the-problem-and-the-repository-boundary-is)Copy Anchor Link Nothing in the cache service crosses repositories. An entry belongs to one repository and a workflow elsewhere is refused whatever key it presents, so a monorepo split into several repositories cannot share one dependency cache through this mechanism. Moving the storage to a remote build cache the build tool talks to directly is the usual answer, and the [guide on sharing a build cache across repositories](/guides/build-cache-sharing-across-repositories) covers the options. --- # Do GitHub Actions Caches Work Across Architectures? | WarpBuild URL: https://www.warpbuild.com/answers/do-github-actions-caches-work-across-architectures Description: No. An x64 cache entry restored into an ARM64 job hands that job binaries it cannot run. Put runner.arch in the key and in every restore-keys prefix. # Do GitHub Actions Caches Work Across Architectures? No. An x64 cache entry restored into an ARM64 job hands that job binaries it cannot run. Put runner.arch in the key and in every restore-keys prefix. No. A cache entry written by an x64 job should not be restored into an ARM64 job, because the compiled objects, native modules, and linked binaries inside it were produced for the architecture that wrote them. Nothing in GitHub Actions blocks that restore, so the guard belongs in the key: add `${{ runner.arch }}` next to `${{ runner.os }}` and carry the same segment into every `restore-keys` prefix ([GitHub contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts)). WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so a matrix that spans architectures is one label per leg: `warp-ubuntu-latest-x64-4x` at $0.008 per minute and `warp-ubuntu-latest-arm64-4x` at $0.006 per minute ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link `runner.os` reports `Linux` on an x86-64 runner and `Linux` on an aarch64 runner. The common key shape `${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}` therefore renders to the same string on both legs of a matrix, and the store has no second signal to tell them apart. A cache version is a hash of the compression tool and the `path` list, and two entries with different versions never match, so two Linux jobs caching the same paths land on the same version and the same entry ([caching documentation](/docs/ci/features/caching)). That version rule is what keeps operating systems apart. A cache saved on `warp-macos-14-arm64-6x` cannot be restored on `warp-ubuntu-latest-x64-4x` because the versions differ, and the `enableCrossOsArchive` input defaults to `false` ([caching documentation](/docs/ci/features/caching)). Inside one operating system there is no equivalent guard, so you write it into the key yourself. Copy Text ``` name: test on: [push, pull_request] jobs: test: strategy: fail-fast: false matrix: runner: - warp-ubuntu-latest-x64-4x - warp-ubuntu-latest-arm64-4x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: WarpBuilds/cache@v1 with: path: | ~/.npm node_modules key: ${{ runner.os }}-${{ runner.arch }}-node22-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-node22- ${{ runner.os }}-${{ runner.arch }}- - run: npm ci - run: npm test ``` `runner.arch` resolves to `X64` on the first leg and `ARM64` on the second, so the two legs address different entries and neither can read the other's tree. Both fallback prefixes keep the architecture segment. A prefix truncated to `${{ runner.os }}-` reopens the hole the primary key just closed, because a miss on the exact key would then fall back to whatever the other architecture wrote. Cache storage is $0.20 per GB-month and each cache write, restore, or list operation is $0.0001 on hosted runners, with both free on BYOC ([caching documentation](/docs/ci/features/caching) and the [pricing page](/pricing), checked on 2026-08-13). ## [Detail](#detail)Copy Anchor Link ### [What travels across architectures and what does not](#what-travels-across-architectures-and-what-does-not)Copy Anchor Link The dividing line is simple: contents that were downloaded stay valid, contents that were built do not. | Cached path | What it holds | Portable across architectures | | ------------------------------------ | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | | \~/.npm/\_cacache | registry tarballs and their metadata | Yes for the tarballs. Optional dependencies still resolve per platform, so the installed tree differs | | node\_modules | the installed tree, including .node addons built by node-gyp | No | | \~/go/pkg/mod | module source zips fetched from the proxy | Yes | | \~/.cache/go-build | compiled package objects | No | | \~/.cargo/registry | crate sources and the registry index | Yes | | target/ | rlibs, object files, and linked binaries | No | | \~/.cache/pip | wheels tagged with the platform, such as manylinux\_2\_28\_x86\_64 | Downloads only. An x64 wheel is skipped on aarch64 and the install rebuilds from source | | \~/.m2/repository, \~/.gradle/caches | jars of bytecode | Yes, apart from artifacts that bundle native libraries | | \~/.cache/sccache, \~/.ccache | compiled object files keyed by compiler input | No | That split argues for two entries instead of one. Keep the download caches under a key carrying the operating system and the lockfile hash, and put the build output under a key that also carries `${{ runner.arch }}`. The x64 and ARM64 legs then share one copy of the tarballs and keep their own compiled trees. The [cache key glossary entry](/glossary/cache-key) covers segment ordering, and [how to write a good cache key](/answers/how-do-i-write-a-good-cache-key-in-github-actions) covers choosing the files to hash. ### [What goes wrong without the architecture segment](#what-goes-wrong-without-the-architecture-segment)Copy Anchor Link **The job fails on the first binary it touches.** An x64 `node_modules` restored into an aarch64 job raises `invalid ELF header` from the first `require` of a native addon, and a restored `target/` directory sends aarch64 rustc into a tree of x86-64 objects that the linker rejects. **The leg that finishes first owns the entry.** Cache entries are immutable, so the second leg's save step finds the key taken and uploads nothing. The architecture that lost the race keeps reading the other architecture's tree on every later run, and the entry survives until it expires after 7 days without use ([caching documentation](/docs/ci/features/caching)). **A green run can still ship the wrong artifact.** Steps that copy a prebuilt binary out of the restored cache into a release archive do not always execute it first, so the mismatch reaches the registry instead of the log. **Fallback prefixes bleed.** A primary key with the architecture and a `restore-keys` prefix without it behaves correctly until the exact key misses, which is the run where the lockfile changed and the diff already looks large. ### [Sizing the duplicate entry](#sizing-the-duplicate-entry)Copy Anchor Link Splitting one entry into two costs storage and buys back build minutes. A 2 GB dependency tree held for both architectures occupies 4 GB, which is $0.80 a month at $0.20 per GB-month. At 1,500 workflow runs a month with one restore and one write on each leg, the 6,000 operations add $0.60 at $0.0001 each, for $1.40 a month. The rebuild it replaces is 4 minutes of `npm ci` and native module compilation on `warp-ubuntu-latest-arm64-4x` at $0.006 per minute: 1,500 x 4 x $0.006 = $36.00 a month on the ARM64 leg alone. Every rate here comes from the [pricing page](/pricing) and the [caching documentation](/docs/ci/features/caching), checked on 2026-08-13. ### [The setup actions already do this](#the-setup-actions-already-do-this)Copy Anchor Link `actions/setup-node` builds its own key rather than using yours. The prefix is `node-cache-${platform}-${arch}-${packageManager}`, where `platform` comes from `RUNNER_OS` and `arch` from `os.arch()` on the runner, and the lockfile hash is appended to it ([actions/setup-node source](https://github.com/actions/setup-node/blob/main/src/cache-restore.ts)). A matrix that caches only through `cache: true` on the official setup actions is separated by architecture already. Hand-written keys are the exposure, together with any extra `path` entries layered on top of a setup action. ### [Layer caches and the platforms that opt out](#layer-caches-and-the-platforms-that-opt-out)Copy Anchor Link Docker layer caches are indexed per platform, so a multi-platform build does not mix `linux/amd64` and `linux/arm64` layers inside one image. The exposure moves to the backend key: two matrix legs writing to the GitHub Actions cache backend under the same scope overwrite each other, so give each platform its own `scope` ([Docker cache backend documentation](https://docs.docker.com/build/cache/backends/gha/)). A remote Docker builder holds its layer cache on the builder virtual machine and runs one session per architecture, so the per-architecture split happens without a cache key at all ([Docker builders documentation](/docs/ci/docker-builders)). Two platforms retire the question. macOS runners are ARM64 across the catalog, so a macOS entry has no second architecture to collide with. The Windows catalog is x86-64 only, and WarpBuild caching is not supported on Windows runners ([caching documentation](/docs/ci/features/caching)). ## [Related Questions](#related-questions)Copy Anchor Link **Does `runner.os` already separate x64 from ARM64?** No. `runner.os` reports `Linux` on both, so a key built from `runner.os` and a lockfile hash collides across a matrix. Add `${{ runner.arch }}`, which resolves to `X64` or `ARM64` ([GitHub contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts)). The [cache key glossary entry](/glossary/cache-key) shows where the segment belongs in the string, and the [Linux ARM64 runner catalog](/runners/linux-arm64) lists every label that resolves to `ARM64`. **Can a cache saved on macOS restore on a Linux runner?** No. Cache versions differ across those runners, and entries with different versions never match, so the two stay apart without any work from you. The `enableCrossOsArchive` input covers the operating system boundary and defaults to `false`, and it has no bearing on x64 against ARM64 inside Linux ([caching documentation](/docs/ci/features/caching)). **Which parts of a cache can both architectures share?** Anything downloaded rather than built: registry tarballs, Go module zips, crate sources, and bytecode jars. Compiled objects, linked binaries, and native node addons belong to the architecture that produced them. Give each class its own entry, as described in [how to write a good cache key](/answers/how-do-i-write-a-good-cache-key-in-github-actions). Pick the label for the ARM64 leg from the [Linux ARM64 runner catalog](/runners/linux-arm64), price the two-entry split against your own job minutes on the [pricing page](/pricing), and wire the drop-in cache action from the [caching reference](/docs/ci/features/caching). --- # Do Caches Work on Windows GitHub Actions Runners? | WarpBuild URL: https://www.warpbuild.com/answers/do-github-actions-caches-work-on-windows-runners Description: No. WarpBuild caching is documented as unsupported on Windows runners. Windows jobs cache through actions/cache@v4 or a build-tool cache in the workspace. # Do Caches Work on Windows GitHub Actions Runners? No. WarpBuild caching is documented as unsupported on Windows runners. Windows jobs cache through actions/cache@v4 or a build-tool cache in the workspace. Last verified: 2026-08-13 No. WarpBuild caching is documented as unsupported for Windows-based runners, so a job on `warp-windows-latest-x64-8x` gets no WarpBuild cache, and the same statement appears on both the [caching documentation](/docs/ci/features/caching) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. Windows jobs still cache well through `actions/cache@v4`, which writes to GitHub's own Actions cache service and needs no extra configuration on a WarpBuild runner, or through a build-tool cache written into the job workspace. ## [Answer](#answer)Copy Anchor Link The support boundary is short enough to state in three lines. * WarpBuild cache is available on Linux runners and is enabled by default there. * The caching documentation's limitations section records one entry: WarpBuild caching is not supported for Windows-based runners. * On a Windows runner, `actions/cache@v4` is the cache action to use, and it works from a WarpBuild Windows runner with no extra setup. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the cache feature does not cover the Windows fleet. That is the accurate answer, and knowing it before you write the workflow saves a debugging session over a cache step that silently does nothing. Here is the shape a Windows job wants. The label is a real Windows runner label from the catalog, and the cache action is the upstream one: Copy Text ``` name: windows-build on: push: branches: [main] pull_request: env: NUGET_PACKAGES: C:\nuget jobs: build: runs-on: warp-windows-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: "9.0" - name: Restore NuGet packages from the GitHub cache service uses: actions/cache@v4 with: path: C:\nuget key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} restore-keys: | ${{ runner.os }}-nuget- - name: Restore run: dotnet restore -p:RestoreLockedMode=true - name: Build run: dotnet build --no-restore -c Release - name: Test run: dotnet test --no-build -c Release ``` Nothing in that file references WarpBuild. The runner label selects the machine, and the cache traffic goes to GitHub. Every Windows label in the catalog behaves the same way on this point. | Windows label | Image | vCPU / RAM | Per minute | WarpBuild cache | Cache action to use | | ------------------------------- | ----------------------------- | ----------- | ---------- | --------------- | ------------------- | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 / 16 GB | $0.016 | Not supported | actions/cache@v4 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 / 32 GB | $0.032 | Not supported | actions/cache@v4 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 / 64 GB | $0.064 | Not supported | actions/cache@v4 | | warp-windows-latest-x64-32x | Windows Server 2022 | 32 / 128 GB | $0.128 | Not supported | actions/cache@v4 | | warp-windows-2025-x64-8x | Windows Server 2025 | 8 / 32 GB | $0.032 | Not supported | actions/cache@v4 | | warp-windows-2025-vs2026-x64-8x | Windows Server 2025 (VS 2026) | 8 / 32 GB | $0.032 | Not supported | actions/cache@v4 | Every rate and shape above comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. All Windows labels carry 256GB SSD storage, and that storage is ephemeral, so it disappears when the runner terminates. Windows 2 vCPU runners were removed on June 8, 2026, which makes 4 vCPU the smallest Windows shape. The per-minute rates for every platform are on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [What "not supported" means once a job is running](#what-not-supported-means-once-a-job-is-running)Copy Anchor Link The WarpBuild cache is a service the runner talks to, reached through the `WarpBuilds/cache` action and its `restore` and `save` variants. On a Linux runner the action finds the runner-side endpoint and the `WARPBUILD_RUNNER_VERIFICATION_TOKEN` environment variable that authenticates it. On a Windows runner that path is outside the supported set, so the honest move is to leave `WarpBuilds/cache` out of Windows jobs rather than add it and hope. The same rule reaches further than the cache action itself. WarpBuild maintains drop-in replacements for the popular `setup-*` actions covering Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise, and those actions route toolchain and dependency caching through WarpBuild Cache underneath. On a Windows job, use the upstream `actions/setup-*` action and cache with `actions/cache@v4`. On a Linux job, the `WarpBuilds/setup-*` variants give you the WarpBuild cache with no workflow changes beyond the action name. Docker layer caching through the WarpBuild cache proxy follows the same boundary, since it depends on the cache service the Windows fleet does not reach. Windows container builds and Windows Docker workloads are a separate topic from cache backends, and the layer cache question only arises where the WarpBuild cache runs. ### [The Windows replacement is GitHub's own Actions cache service](#the-windows-replacement-is-githubs-own-actions-cache-service)Copy Anchor Link `actions/cache@v4` authenticates with the cache credentials GitHub injects into every workflow run, so it works from a WarpBuild Windows runner exactly as it works anywhere else GitHub Actions executes. GitHub has updated its cache service for larger entries, and the size limits and expiration policy for your organization live under the organization Actions settings. The WarpBuild caching documentation calls this the recommended approach for most users, with WarpBuild cache reserved for advanced cases and BYOC on Linux. Three details are worth setting up front on Windows. **Redirect the package cache to a short path.** Windows path length limits bite hardest inside deeply nested package folders. Setting `NUGET_PACKAGES` to `C:\nuget`, as in the snippet above, keeps restored package paths short and gives `actions/cache` one stable directory to archive. **Key on a lock file, not on a branch.** `hashFiles('**/packages.lock.json')` changes only when dependencies change, which is what makes a cache entry worth restoring. Turn on `RestorePackagesWithLockFile` so the lock files exist, then restore with `-p:RestoreLockedMode=true` so a drifted lock file fails the job instead of quietly re-resolving against the feed. The [NuGet caching guide](/guides/nuget-cache-github-actions) covers key design in full. **Include `runner.os` in the key.** It costs nothing and it prevents a Linux entry and a Windows entry from ever competing for the same key. ### [Build-tool caches that live in the workspace](#build-tool-caches-that-live-in-the-workspace)Copy Anchor Link The second Windows path skips the cache service and keeps state where the build tool expects it. MSBuild has a working incremental build system that compares timestamps against `obj` and `bin` output. Every GitHub Actions job starts on a fresh machine, so those directories are empty and every target rebuilds. Inside one job, though, incremental behavior is real: restore once, build once, and run tests against the already-built output with `--no-build` so the test step does not recompile the solution. Analyzer runs are the other lever, since analyzers rerun on projects nobody touched unless you scope them. The [MSBuild caching guide](/guides/msbuild-caching-github-actions) works through both. Anything you want to carry between jobs on Windows goes through `actions/cache@v4` or `actions/upload-artifact@v4`. Runner storage is ephemeral on every WarpBuild platform, so nothing on the disk survives the job that wrote it. Here is the shape most .NET repositories end up with once both legs exist. The Linux leg uses the WarpBuild setup action with caching switched on, which routes through the WarpBuild cache and needs a `packages.lock.json` to key against; the Windows leg uses the upstream action plus `actions/cache@v4`: Copy Text ``` name: build-matrix on: pull_request: jobs: linux: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-dotnet@v4 with: dotnet-version: "9.0" cache: true cache-dependency-path: "**/packages.lock.json" - run: dotnet restore -p:RestoreLockedMode=true - run: dotnet build --no-restore -c Release - run: dotnet test --no-build -c Release windows: runs-on: warp-windows-2025-x64-8x env: NUGET_PACKAGES: C:\nuget steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: "9.0" - uses: actions/cache@v4 with: path: C:\nuget key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} restore-keys: | ${{ runner.os }}-nuget- - run: dotnet restore -p:RestoreLockedMode=true - run: dotnet build --no-restore -c Release - run: dotnet test --no-build -c Release ``` Two cache backends in one workflow is the normal end state for a cross-platform repository, and the split costs four lines of YAML. ### [The cross-OS archive note](#the-cross-os-archive-note)Copy Anchor Link Both `actions/cache@v4` and `WarpBuilds/cache@v1` expose an `enableCrossOsArchive` input. The WarpBuild [caching documentation](/docs/ci/features/caching) describes it as an optional boolean that, when enabled, allows Windows runners to save or restore caches that can be restored or saved on other platforms, with a default of `false`. Teams read that input and assume one entry can serve a Linux leg and a Windows leg. It usually cannot, for a reason that sits below the flag. Cache entries carry a version, and the version is a hash over the compression tool the runner OS uses (gzip, zstd, and so on) together with the set of paths being cached. Entries with different versions are treated as different caches during matching. The documentation gives the concrete example: an entry saved on `warp-macos-14-arm64-6x` cannot be restored on `warp-ubuntu-latest-x64-4x`, because the versions differ. `enableCrossOsArchive` addresses archive format compatibility. It does not collapse the version hash, and it does not rewrite paths. A Windows job caching `C:\nuget` and a Linux job caching `~/.nuget/packages` are asking for different path sets, so they compute different versions and never match each other. On top of that, restored content is often platform-specific anyway: native binaries, symlink handling, and file permissions all differ. The practical guidance is to give each operating system its own key prefix with `runner.os`, accept two entries, and spend the engineering time on key quality instead. Cross-OS restore is worth attempting only for content that is genuinely platform-neutral, such as a directory of downloaded source archives with identical relative paths on both sides. ### [What Windows caching costs](#what-windows-caching-costs)Copy Anchor Link Windows jobs generate no WarpBuild cache line items, because their cache traffic goes to GitHub. That makes the arithmetic for a cross-platform repository easy to lay out. Every number below comes from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. Take a .NET repository that runs both legs on every pull request: 1,000 Windows jobs a month at 8 minutes each, and 1,000 Linux jobs a month at 4 minutes each. * Windows compute: 8,000 minutes on `warp-windows-2025-x64-8x` at $0.032 per minute = **$256.00 per month**. * Windows cache: **$0.00** in WarpBuild charges. The entries live in GitHub's Actions cache service. * Linux compute: 4,000 minutes on `warp-ubuntu-latest-x64-4x` at $0.008 per minute = **$32.00 per month**. * Linux cache storage: 3 GB of live entries at $0.20 per GB-month = **$0.60 per month**. * Linux cache operations: one restore and one save per job, 2,000 operations at $0.0001 each = **$0.20 per month**. Monthly total: $288.80, of which $0.80 is cache. WarpBuild cache entries expire after 7 days of last use and can be deleted from the action or the console, so the storage figure tracks live entries rather than everything ever written. On BYOC, cache storage and cache operations are included at no charge, and the compute is billed by your own cloud account. ### [The rest of the Windows runner surface](#the-rest-of-the-windows-runner-surface)Copy Anchor Link Caching is the one documented gap on Windows. The Windows x86-64 images carry the same tooling as GitHub-hosted runners, which is why moving a Windows job usually reduces to the `runs-on` line. The full label list, including the Windows Server 2025 and Visual Studio 2026 images, is in the [Windows runner catalog](/runners/windows). ## [Related Questions](#related-questions)Copy Anchor Link **Does the WarpBuild cache work on Windows runners?** No. The caching documentation lists WarpBuild caching as not supported for Windows-based runners, and the [cloud runners documentation](/docs/ci/cloud-runners) repeats it. WarpBuild cache is enabled by default on Linux runners only. A Windows job caches through `actions/cache@v4` instead, and the [Windows runner catalog](/runners/windows) lists every Windows label this applies to. **What should a Windows GitHub Actions job use for caching instead?** `actions/cache@v4`, which writes to GitHub's own Actions cache service using the credentials GitHub injects into every workflow run, so it needs no extra configuration on a WarpBuild Windows runner. Pair it with a build-tool cache such as the NuGet global packages folder redirected to a short path, covered in the [NuGet caching guide](/guides/nuget-cache-github-actions). **Can a cache saved on a Windows runner be restored on Linux?** Rarely, and it takes work. The `enableCrossOsArchive` input lets a Windows runner save an archive other platforms can read, but the cache version hash still covers the compression tool and the cached paths, so entries with different versions never match. Key entries per operating system with `runner.os` instead. __Do the WarpBuilds setup- actions cache on Windows runners?_\* No. Those drop-in setup actions use WarpBuild Cache underneath, and WarpBuild Cache is unsupported on Windows. Keep the upstream `actions/setup-*` action plus `actions/cache@v4` on Windows jobs, and use the `WarpBuilds/setup-*` variants on Linux jobs. The [MSBuild caching guide](/guides/msbuild-caching-github-actions) shows the Windows side end to end. Pick a Windows label from the [Windows runner catalog](/runners/windows), confirm the platform fits your build in [can GitHub Actions run Windows builds](/answers/can-github-actions-run-windows-builds), and price the change against your own job minutes on the [pricing page](/pricing). --- # How Much Disk Space Do GitHub Actions Runners Have? | WarpBuild URL: https://www.warpbuild.com/answers/do-github-actions-runners-have-large-disks Description: WarpBuild runners carry 150GB SSD on every Linux x64 and ARM64 label, 256GB SSD on Windows labels, and 120GB or 270GB on macOS. Builder profiles reach 2TB. # How Much Disk Space Do GitHub Actions Runners Have? WarpBuild runners carry 150GB SSD on every Linux x64 and ARM64 label, 256GB SSD on Windows labels, and 120GB or 270GB on macOS. Builder profiles reach 2TB. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link WarpBuild runners carry 150GB SSD on every Linux x64 and Linux ARM64 label, 256GB SSD on every Windows label, and either 120GB or 270GB SSD on macOS depending on the size you pick. Runner storage is ephemeral working space and is deleted when the runner terminates, so anything you need after the job goes to a cache, an artifact, or a registry. Every disk figure below comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Platform | Labels | Disk per runner | Per minute | | -------------- | ------------------------------------------------------------------------------- | --------------- | ---------------- | | Linux x64 | warp-ubuntu-latest-x64-2x through 32x, plus the 2204, 2404, and 2604 label sets | 150GB SSD | $0.004 to $0.064 | | Linux ARM64 | warp-ubuntu-latest-arm64-2x through 32x, plus the 2604 label set | 150GB SSD | $0.003 to $0.048 | | macOS, 6 vCPU | warp-macos-26-arm64-6x, warp-macos-15-arm64-6x, warp-macos-14-arm64-6x | 120GB SSD | $0.08 | | macOS, 12 vCPU | warp-macos-26-arm64-12x, warp-macos-15-arm64-12x | 270GB SSD | $0.16 | | Windows | warp-windows-latest-x64-4x through 32x, plus the 2025 and vs2026 label sets | 256GB SSD | $0.016 to $0.128 | Two things in that table surprise people. Disk does not scale with vCPU on Linux or Windows: the 2 vCPU Ubuntu label and the 32 vCPU Ubuntu label both mount 150GB, and every Windows label mounts 256GB whether it has 4 vCPU or 32\. On macOS the disk does move with the size, so `warp-macos-26-arm64-12x` carries 270GB against 120GB on the 6 vCPU labels. Workloads that need more than the runner volume have two routes. Remote Docker builders run the image build on a dedicated builder virtual machine whose profiles start at 100GB of disk and go up to 2TB, with a persistent layer cache attached to the profile. BYOC runners on instance types that ship local NVMe SSDs get those disks detected, formatted, and mounted to the runner work directory at boot with no configuration, on Linux images ([local SSD documentation](/docs/ci/features/local-ssd)). Per-minute rates for every label above are on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [Disk is working space and it disappears with the runner](#disk-is-working-space-and-it-disappears-with-the-runner)Copy Anchor Link Runner storage is deleted when the runner is terminated ([cloud runners documentation](/docs/ci/cloud-runners)). That is the design of an ephemeral runner: each job gets a clean machine, and nothing a job writes to the filesystem survives to the next one. The practical consequence is that the volume size answers one question only, which is how much a single job can hold at its peak. It says nothing about how much a repository can accumulate over time. Anything with a lifetime longer than the job belongs in one of three places: * Build caches, restored at the start of a job and saved at the end. * Workflow artifacts, uploaded with `actions/upload-artifact` and downloaded by later jobs. * A container registry or object store, for images and large binaries. Snapshot runners are the exception worth knowing about. They capture a runner virtual machine mid-workflow so later jobs boot from that captured state rather than from a bare image, which moves a slow warm-up out of the critical path. Snapshots are deleted after 15 days ([snapshot runners documentation](https://www.warpbuild.com/docs/ci/features/snapshot-runners)). ### [An example disk budget for one job](#an-example-disk-budget-for-one-job)Copy Anchor Link The volume size is the size of the disk, and the runner image already occupies part of it with preinstalled tooling. Budget against measured free space instead of against the headline number. Here is a worked example for one image-building job on a Linux label with 150GB SSD, using example workload sizes you would replace with your own: | What lands on disk | Example size | | ------------------------------------------- | ------------ | | Checkout with full history (fetch-depth: 0) | 12 GB | | Base images pulled during the build | 8 GB | | Built image layers across three tags | 15 GB | | Dependency tree and compiled output | 6 GB | | Test fixtures and database dumps | 4 GB | | Artifacts staged for upload | 5 GB | | Total peak | 50 GB | At 50 GB of peak usage against a 150GB volume, that job has room. The failure mode teams hit is different: a matrix leg that pulls a fresh set of base images per iteration, or a build that keeps every intermediate tag, grows the third row without bound while the other rows stay flat. Docker layer growth is the usual reason a job that passed for months starts failing with a no-space error. ### [Checking free space from inside the workflow](#checking-free-space-from-inside-the-workflow)Copy Anchor Link Measuring beats guessing. This workflow records disk before and after the build, reclaims space between the build and test stages, and runs on a real WarpBuild label: Copy Text ``` name: image-build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-16x steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Record disk before the build run: | df -h / docker system df - name: Build the image run: docker build -t app:${{ github.sha }} . - name: Reclaim space before the test stage run: | docker image prune -af --filter "until=1h" docker builder prune -af df -h / - name: Integration tests run: ./scripts/integration-test.sh - uses: actions/upload-artifact@v4 with: name: build-logs path: logs/ ``` `df -h /` prints the free space on the volume the work directory sits on. `docker system df` breaks the Docker footprint into images, containers, local volumes, and build cache, which tells you which of the four is growing. Both commands cost a second and turn a mystery failure into a number you can act on. ### [When image layers are the problem, move the build off the runner](#when-image-layers-are-the-problem-move-the-build-off-the-runner)Copy Anchor Link Remote Docker builders send the build to a dedicated builder virtual machine with a persistent layer cache, so image layers stop competing with the checkout for runner disk. Builder profiles are billed per session, and multiple concurrent jobs on the same profile share one session ([Docker builders documentation](https://www.warpbuild.com/docs/ci/docker-builders), checked on 2026-08-13). | Builder profile | Disk | Per minute | Architectures | | ------------------- | ----- | ---------- | ------------------- | | 16 vCPU, 32GB RAM | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU, 64GB RAM | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU, 128GB RAM | 200GB | $0.24 | amd64, arm64, multi | | 96 vCPU, 192GB RAM | 600GB | $0.36 | amd64 only | | 96 vCPU, 192GB RAM | 2TB | $0.52 | amd64 only | | 192 vCPU, 384GB RAM | 600GB | $0.72 | amd64 only | | 192 vCPU, 384GB RAM | 2TB | $0.88 | amd64 only | The arm64 and multi-architecture profiles top out at 64 vCPU, so the 2TB profiles are amd64 only. **Worked model: 500 image builds a month.** Take a repository that builds one image per pull request, 500 builds a month, with a 4 minute builder session each. That is 2,000 session minutes, and the profile you pick sets the bill: * 16 vCPU, 100GB disk at $0.06 per minute: 2,000 x $0.06 = **$120.00 per month**. * 32 vCPU, 200GB disk at $0.12 per minute: 2,000 x $0.12 = **$240.00 per month**. * 96 vCPU, 2TB disk at $0.52 per minute: 2,000 x $0.52 = **$1,040.00 per month**. The runner that orchestrates the build bills separately. At 5 minutes per job on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, 500 jobs add 2,500 minutes and $20.00\. Cache storage, if the same team keeps 50 GB of cache entries, adds $10.00 a month at $0.20 per GB-month. All rates from the [pricing page](/pricing), checked on 2026-08-13. Pick the profile by the disk and RAM the build actually needs. A Dockerfile with a large dependency layer and a handful of tags fits the 100GB profile; a monorepo producing dozens of images per run with a deep layer cache is what the 600GB and 2TB profiles exist for. ### [BYOC instances with local NVMe SSDs](#byoc-instances-with-local-nvme-ssds)Copy Anchor Link On BYOC, the disk comes from the instance type you choose in your own cloud account. When that instance type ships local NVMe SSDs, the runner detects every local NVMe device at boot, stripes multiple devices into a RAID-0 array, formats it, and mounts it to the runner work directory before any job starts. No configuration is required ([local SSD documentation](/docs/ci/features/local-ssd)). Three constraints go with it. Auto-mount is available on Linux images only, so Windows and macOS runners use their regular volumes. Local SSDs are tied to the instance lifecycle, so the data is gone when the runner terminates, which matches how ephemeral runners already behave. RAID-0 gives throughput and capacity with no redundancy, which is the correct trade for build workspace. The documentation names instance families rather than capacities, because capacity follows the instance type you select: `c5d`, `m5d`, `r5d`, `i3`, `c6id`, `m6id`, and `r6id` families on AWS, the `-lssd` machine types on GCP such as `c3-standard-88-lssd`, and VM sizes with local NVMe temp storage on Azure such as the `Lsv3` and `Lasv3` families. Check the capacity of the specific instance type in your cloud provider's own documentation before you size the runner set. The [hosted versus BYOC comparison](/guides/hosted-vs-byoc-runners) covers the rest of the trade. ### [What to change when a job runs out of disk](#what-to-change-when-a-job-runs-out-of-disk)Copy Anchor Link In rough order of effort: 1. Add `df -h /` and `docker system df` steps, then read them. Half of these failures are one runaway directory. 2. Prune Docker between stages with `docker image prune -af` and `docker builder prune -af`. 3. Narrow the checkout. `fetch-depth: 1` and sparse checkout cut the largest fixed cost on a big repository, which the [large repository guide](/guides/large-repository-github-actions) covers step by step. 4. Move image builds to a remote Docker builder profile so layers live on the builder rather than the runner volume. 5. On BYOC, choose an instance type with local NVMe SSDs and let the auto-mount handle the rest. The Action Debugger opens a shell on a paused runner so you can inspect its filesystem directly. ## [Related Questions](#related-questions)Copy Anchor Link **How much disk does a large repository need on a GitHub Actions runner?** A full-history checkout plus dependencies and build output usually fits inside the 150GB SSD on a Linux label, but measure it. Run `df -h` at the start of the job and again after the build, then size the workload against the number you see rather than against the volume size. The [large repository guide](/guides/large-repository-github-actions) covers checkout depth, sparse checkout, and cache layout for repositories where this gets tight. **Does the runner disk limit how much build cache I can keep?** No. Cache lives off the runner and is billed separately at $0.20 per GB-month plus $0.0001 per write or restore, so a 50 GB cache costs $10.00 a month ([pricing page](/pricing), checked on 2026-08-13). Only the restored copy of a cache entry occupies runner disk during the job, so a large cache costs money rather than runner space. **What do I do when Docker layers fill the runner disk?** Prune between steps with `docker image prune -af`, or move the build to a remote Docker builder profile. Builder profiles run from 100GB of disk on the 16 vCPU size up to 2TB on the 96 and 192 vCPU sizes, with a persistent layer cache that lives on the builder instead of the runner. The [Docker builder catalog](/runners/docker-builders) lists every profile. **Does the runner keep its disk between jobs?** No. Runner storage is deleted when the runner terminates. Snapshot runners capture a runner virtual machine mid-workflow so later jobs boot from that state, billed at $0.04 per restore and $0.025 per snapshot-hour, and snapshots are deleted after 15 days. Size a job against the real numbers: the [Linux x64 runner catalog](/runners/linux-x64) lists the labels and their 150GB volumes, and the [pricing page](/pricing) prices the runner, the builder profile, and the cache side by side. --- # Do GitHub Actions Runners Keep State Between Jobs? | WarpBuild URL: https://www.warpbuild.com/answers/do-github-actions-runners-keep-state-between-jobs Description: No. Each job gets a fresh ephemeral VM whose storage is destroyed when the runner terminates, unless a snapshot runner boots the job from saved VM state. # Do GitHub Actions Runners Keep State Between Jobs? No. Each job gets a fresh ephemeral VM whose storage is destroyed when the runner terminates, unless a snapshot runner boots the job from saved VM state. Last verified: 2026-08-13 No. Every GitHub Actions job runs on a fresh runner, and on WarpBuild Cloud runners that runner is a newly allocated ephemeral virtual machine whose storage is deleted the moment the runner terminates, so nothing one job writes to disk is present on the machine the next job receives. The exception is a snapshot runner, which boots a WarpBuild Cloud Ubuntu job from a saved image of an earlier run's VM, so the packages, dependency trees, working directories, and pulled container images that image holds are on disk before the first step executes. ## [Answer](#answer)Copy Anchor Link The unit that resets is the job. Steps inside one job all run on the same machine, so a step can rely on what the step before it installed, wrote, exported, or left running. Crossing a job boundary crosses a machine boundary, and everything machine-local goes with it. | Boundary | Same machine | Disk state survives | How to carry state across | | ---------------------------------- | ----------------------- | -------------------- | -------------------------------------------------- | | Step to step inside one job | Yes | Yes | Nothing needed | | Job to job inside one workflow run | No | No | Job outputs, artifacts, cache, or a snapshot alias | | Run to run of the same workflow | No | No | Artifacts, cache, or a snapshot alias | | Snapshot restore | New machine, saved disk | Yes, apart from /tmp | snapshot.key= | This behavior follows from the isolation model rather than from a setting you can flip. All WarpBuild runners run on ephemeral VMs for isolation and security: they are freshly allocated when a job needs them and destroyed when the workflow completes, and runner storage is deleted when the runner terminates. The [cloud runners documentation](/docs/ci/cloud-runners) states both properties for every Linux, macOS, and Windows shape in the catalog. The practical consequence shows up in build times rather than in errors. A five-job workflow on a repository with heavy setup runs the same `apt-get install`, the same `npm ci`, the same `docker pull`, and the same cold compile five times, once per job, because each job starts from an identical base image with an empty local Docker store and no dependency tree. The one supported way to start a job from a previous job's disk is a snapshot runner. You append `snapshot.enabled=true` or `snapshot.key=` to the `runs-on` label, capture the machine mid-workflow with the `WarpBuilds/snapshot-save` action, and later jobs that request the same alias boot from that captured disk. The job stays ephemeral: a fresh VM is still allocated and still destroyed, and what survives is the image. The [snapshot runners hub](/runners/snapshot) covers the rollout patterns and [what are snapshot runners](/answers/what-are-snapshot-runners) covers the definition and the mechanism in full. Platform scope matters before you design around any of this. Snapshot behavior applies to the Cloud Ubuntu part of that catalog only. ## [Detail](#detail)Copy Anchor Link ### [The three mechanisms that move state on purpose](#the-three-mechanisms-that-move-state-on-purpose)Copy Anchor Link Because disks do not survive the job boundary, GitHub Actions gives you three explicit ways to move something across it, and each carries a different payload. Job outputs carry strings. A step writes to `$GITHUB_OUTPUT`, the job republishes the value under its `outputs` block, and a downstream job with a `needs` edge reads it as `needs..outputs.`. The `needs` edge serializes the two jobs, so an output costs you parallelism. Artifacts carry files and outlive the run. `actions/upload-artifact` in the producer and `actions/download-artifact` in the consumer is the mechanism that guarantees delivery, and it is the right answer whenever the consumer cannot proceed without the payload. Caches carry directories a later job would otherwise rebuild, on a best-effort basis. A cache entry is scoped to its key, its version, and its branch, and a restore that misses leaves the job to rebuild from scratch. The [sharing data between GitHub Actions jobs guide](/guides/share-data-between-github-actions-jobs) walks through picking between the three by payload size and required lifetime. All three restore named payloads into a clean machine. That distinction is the whole reason snapshot runners exist. Restoring 3 GB of `node_modules` still leaves apt packages uninstalled, the local Docker image store empty, the Rust `target` directory partial, and every file outside the cached paths missing. A snapshot restores the disk, so whatever a previous run left anywhere on it is present at step one. ### [Which runners honor snapshot labels](#which-runners-honor-snapshot-labels)Copy Anchor Link Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. Everywhere else the labels parse and then do nothing. Rates below come from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Runner family | Example label | Per-minute list price | Snapshot labels | | ------------------------ | ------------------------------------------ | --------------------- | ---------------- | | Cloud Ubuntu x64 | warp-ubuntu-latest-x64-8x | $0.016 | Honored | | Cloud Ubuntu ARM64 | warp-ubuntu-latest-arm64-8x | $0.012 | Honored | | Cloud macOS | warp-macos-latest-arm64-6x | $0.08 | Silently ignored | | Cloud Windows | warp-windows-latest-x64-8x | $0.032 | Silently ignored | | BYOC Linux in your cloud | BYOC label, $0.002 plus your instance cost | $0.002 | Silently ignored | The word to weigh there is silently. A snapshot label on an unsupported runner type produces no error and no warning in the job log, so a typo in the runner label looks identical to a working configuration. The check that tells you the truth is the `WARPBUILD_SNAPSHOT_KEY` environment variable, which a runner created from a snapshot carries and a runner booted from the base image does not. ### [The two labels, and what a snapshot actually holds](#the-two-labels-and-what-a-snapshot-actually-holds)Copy Anchor Link Both snapshot labels turn the feature on, and they differ in where the job boots from. * `snapshot.enabled=true` always boots from the base image. It is the label for the job that produces a clean snapshot. * `snapshot.key=` boots from the saved state for that alias when one exists, and falls back to the base image when none does. Two behaviors need planning around once state does survive. `/tmp` does not persist, because the directory is cleaned on reboot and a snapshot boot is a reboot, so any tooling that stages files in `/tmp` before the save needs a different path. Credentials on disk do persist, because they are part of the disk: an SSH key, an AWS profile, a registry token, or a `.npmrc` written by a login step all travel inside the image. On public repositories any contributor can read your workflow file and name your alias in a pull request, and on private repositories WarpBuild provisions runners at the organization level, so a snapshot carrying secrets can reach other jobs in the organization. Both cases have the same fix: delete credentials in a step immediately before the save. Snapshots are also temporary and are deleted after 15 days, so an alias nobody refreshes quietly stops resolving and the next job runs at its cold duration. Full behavior, action inputs, and cleanup guidance live in the [snapshot runners documentation](/docs/ci/features/snapshot-runners). ### [A workflow where state survives between runs](#a-workflow-where-state-survives-between-runs)Copy Anchor Link This shape keeps the image honest. The default branch always boots clean and republishes the alias, and pull requests boot from the published image. Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=api-main' }} steps: - uses: actions/checkout@v5 - name: Report boot source run: | if [ -z "$WARPBUILD_SNAPSHOT_KEY" ]; then echo "booted from the base image" else echo "booted from snapshot $WARPBUILD_SNAPSHOT_KEY" fi - name: Install system packages if: env.WARPBUILD_SNAPSHOT_KEY == '' run: | sudo apt-get update sudo apt-get install -y libpq-dev protobuf-compiler - name: Install dependencies run: npm ci - name: Build and test run: | npm run build npm test - name: Remove credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws $HOME/.npmrc git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "api-main" fail-on-error: false wait-timeout-minutes: 45 ``` The `npm ci` step stays unguarded on purpose. It reconciles the lockfile against whatever `node_modules` tree came out of the image, which is quick when the tree already matches and correct when the lockfile moved on. Guard the steps that are expensive and idempotent, and leave the steps that enforce correctness running every time. `fail-on-error: false` keeps a failed capture from turning a green pipeline red. ### [What keeping state costs](#what-keeping-state-costs)Copy Anchor Link Persisting state has two line items on top of runner minutes, both from the [pricing page](/pricing), checked on 2026-08-13: snapshot restore at $0.04 per job and snapshot storage at $0.025 per hour per snapshot. The restore fee is fixed per job, so a snapshot pays for itself only when it removes more than one boot minute plus the fee. On `warp-ubuntu-latest-x64-8x` at $0.016 per minute, that threshold is 3.5 minutes of skipped setup. Here is a month with the assumptions stated so you can substitute your own: 1,320 workflow runs (60 per weekday across 22 weekdays), a job that takes 9 minutes on a clean boot of which 5 minutes rebuilds environment state, one alias held live for the full 720 hours, and a snapshot boot that adds a minute. The GitHub column prices the same 9-minute job on the 8-core Linux larger runner at $0.022 per minute, from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. GitHub-hosted runners have no snapshot equivalent, so that column carries no snapshot line items. | Line | GitHub 8-core Linux larger runner | warp-ubuntu-latest-x64-8x | warp-ubuntu-latest-x64-8x with snapshot | | ------------------------ | --------------------------------- | ------------------------- | --------------------------------------- | | Rate per minute | $0.022 | $0.016 | $0.016 | | Minutes per job | 9 | 9 | 5 | | Runner minutes per month | 11,880 | 11,880 | 6,600 | | Runner cost | $261.36 | $190.08 | $105.60 | | Snapshot restore fees | none | $0 | $52.80 | | Snapshot storage | none | $0 | $18.00 | | Total per month | $261.36 | $190.08 | $176.40 | Two readings come out of that table. The same-shape list price does most of the work: `warp-ubuntu-latest-x64-8x` at 8 vCPU and 32 GB costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner at the same shape, which is 27 percent lower list price before any state is reused. Snapshots add $13.68 on these assumptions, and that margin is sensitive: at 3 minutes of setup instead of 5 the snapshot column loses to the plain runner column, exactly as the 3.5-minute threshold predicts. Storage scales with aliases, so ten per-branch aliases held the same way is $180.00 per month. ## [Related Questions](#related-questions)Copy Anchor Link ### [Do steps in the same GitHub Actions job share state?](#do-steps-in-the-same-github-actions-job-share-state)Copy Anchor Link Yes. Every step in one job runs on the same machine, so the workspace directory, installed packages, environment files, the local Docker image store, and background processes are all shared between steps. The job is the boundary that resets state; a step boundary resets nothing. [Ephemeral runner](/glossary/ephemeral-runner) defines the allocation model that produces this behavior. ### [How do I carry files from one job to another?](#how-do-i-carry-files-from-one-job-to-another)Copy Anchor Link Use the mechanism that matches the payload. Job outputs carry strings through the `needs` graph, artifacts carry files that outlive the run, and a cache carries directories a later job would otherwise rebuild. All three restore named payloads into a clean machine rather than restoring the machine. The [sharing data between GitHub Actions jobs guide](/guides/share-data-between-github-actions-jobs) compares them, and the [persistent caches guide](/guides/persistent-caches-github-actions) covers cache keys, scopes, and the [cache size limit](/guides/github-actions-cache-size-limit) you hit on large dependency trees. ### [Which WarpBuild runners can boot from saved state?](#which-warpbuild-runners-can-boot-from-saved-state)Copy Anchor Link WarpBuild Cloud Ubuntu runners on x64 and ARM64\. BYOC runners, Windows runners, and macOS runners are not supported, and a snapshot label on those runner types is silently ignored, so the job runs normally with no snapshot behavior and no error in the log. The [snapshot runners hub](/runners/snapshot) lists the Ubuntu labels that accept the feature and their per-minute rates. ### [How long does saved runner state last?](#how-long-does-saved-runner-state-last)Copy Anchor Link Snapshots are deleted after 15 days. An alias that no workflow refreshes stops resolving after that window, and the next job requesting it boots from the base image and runs at its cold duration. A workflow that saves on every merge to the default branch never reaches the expiry. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) is the reference for the lifetime and the save action inputs. --- # Do GitHub Actions Runners Store My Source Code? | WarpBuild URL: https://www.warpbuild.com/answers/do-github-actions-runners-store-my-source-code Description: No. A WarpBuild runner holds your checkout on an encrypted volume destroyed after the build. Cache entries, BYOC bucket objects, and snapshots are what persist. # Do GitHub Actions Runners Store My Source Code? No. A WarpBuild runner holds your checkout on an encrypted volume destroyed after the build. Cache entries, BYOC bucket objects, and snapshots are what persist. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link No. A WarpBuild GitHub Actions runner holds your source code only while the job runs, because each runner gets its own virtual machine and its own encrypted storage volume, both created on demand and destroyed after the build. Everything that outlives the job is a named surface with its own retention setting: cache entries a step saved, objects in a bucket inside your own cloud account on BYOC, and snapshot images if you turned snapshot runners on. That distinction is the one a security reviewer is actually asking about. The question sounds like it is about the runner, and the answer lives in four separate storage surfaces with four different lifetimes and four different delete buttons. | Storage surface | What it can contain | How long it lives | Who removes it | | ----------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------- | | Runner VM disk (150GB SSD on the Ubuntu labels) | The checkout, dependency trees, compiled output, test artifacts, anything a step writes | One job | Destroyed with the virtual machine after each build | | WarpBuild cache entry, hosted runners | Only the paths your cache step names in path | 7 days from last use | Expiry, a delete-cache run, or the WarpBuild console | | BYOC object storage bucket | Artifact cache, container image layer caches, runner telemetry logs | Until your own lifecycle policy removes it | You, in your own cloud account | | Snapshot image, hosted Ubuntu runners | The whole runner disk as it stood when the save action ran | 15 days | Deleted automatically after 15 days | Two boundaries hold across all four rows. GitHub keeps the repository, the workflow definitions, and the secrets, since a runner is a machine that clones from GitHub and streams results back to it. WarpBuild does not access or store any build secrets; they stay in your source code repository and reach only the runner environment for the duration of the job. Both facts are documented in the [WarpBuild runner security documentation](/docs/ci/security), which is the page to hand a reviewer first. The catalog scope is worth stating alongside the answer, because a reviewer will ask which runners the model covers. Every label in that catalog resolves to a per-job virtual machine under row one of the table. ## [Detail](#detail)Copy Anchor Link ### [The job disk and why its lifetime is the whole answer](#the-job-disk-and-why-its-lifetime-is-the-whole-answer)Copy Anchor Link Each runner runs in its own virtual machine. The virtual machines are created on demand and destroyed after each build, and they are never handed to a second job. Each runner also gets its own encrypted storage volume, created and destroyed on the same schedule. Your source code lands on that volume the moment `actions/checkout` runs, along with whatever the build produces afterward. When the job ends, the volume goes with the machine. There is no warm pool holding a working tree from an earlier run, and no long-lived host that ran your build this morning and someone else's build this afternoon. For a vendor questionnaire, that turns into three recorded facts: * **Isolation unit.** One virtual machine per job, with no shared tenancy between builds. * **Storage lifetime.** One encrypted volume per job, created on demand and destroyed after each build. * **Encryption at rest.** The per-runner volume is encrypted, and when caching is enabled the cache is encrypted and stored where only your runner can reach it. The attestation behind those statements is SOC 2 Type 2, with three Trust Services Criteria: Security, Availability, and Confidentiality. Request the report and the supporting security documentation at [trust.warpbuild.com](https://trust.warpbuild.com/). The [security review checklist](/enterprise/ci-security-review) collects the five questions reviewers ask in the order they ask them. ### [What persists on hosted runners, and how to bound it](#what-persists-on-hosted-runners-and-how-to-bound-it)Copy Anchor Link Cache entries are the surface teams forget. A cache step writes the paths you name into WarpBuild cache storage, and those bytes survive the job by design, because that is the entire point of a cache. Three properties bound the exposure: * **Scope.** A cache entry is scoped to the key, the cache version, and the branch. A cache saved on `warp-macos-latest-arm64-6x` cannot be restored on `warp-ubuntu-latest-x64-4x`, because the version hash differs across compression tool and cached paths. * **Expiry.** Cache is set to expire after 7 days of last use, per the [WarpBuild caching documentation](/docs/ci/features/caching). * **Manual deletion.** An entry can be deleted at any time, either from the console or with the `delete-cache` input on the cache action. The practical control is choosing what goes into `path`. Caching package manager download directories keeps registry tarballs in cache storage and keeps your working tree out of it. Caching the repository directory itself does the opposite. Copy Text ``` name: build on: pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Restore dependency cache id: deps uses: WarpBuilds/cache@v1 with: path: | ~/.npm ~/.cargo/registry key: deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json', '**/Cargo.lock') }} restore-keys: | deps-${{ runner.os }}- - name: Install dependencies if: steps.deps.outputs.cache-hit != 'true' run: npm ci - name: Build run: npm run build - name: Test run: npm test ``` The two paths named there are download caches owned by the package managers. No file from the working tree enters cache storage, so the only bytes that outlive the job are third-party archives your build would have downloaded again anyway. When an entry does need to go, a maintenance workflow removes it by key: Copy Text ``` name: purge-cache on: workflow_dispatch: inputs: cache_key: description: Exact cache key to delete required: true jobs: purge: runs-on: warp-ubuntu-latest-x64-2x steps: - name: Delete the cache entry uses: WarpBuilds/cache@v1 with: path: | ~/.npm ~/.cargo/registry key: ${{ inputs.cache_key }} delete-cache: true ``` ### [What persists on BYOC, and the lifecycle policy to set](#what-persists-on-byoc-and-the-lifecycle-policy-to-set)Copy Anchor Link BYOC moves every persisted byte into your account. A WarpBuild Stack creates one object storage bucket in the region you selected, and that bucket is used for artifact cache, container image layer caches, and telemetry. The [AWS BYOC configuration documentation](/docs/ci/byoc/aws/config) publishes the exact prefixes, which is what an auditor wants when writing a bucket policy: * Cache: `//artifact_cache/////` * Telemetry: `/runner/logs/all/.` Those objects sit under your own IAM, your own encryption settings, your own access logging, and your own bill. They also sit there until you remove them, so the retention decision is yours to make explicitly. WarpBuild documents the recommendation directly: set an S3 lifecycle policy for the cache and telemetry data according to your organization's policy, and 7 days of retention is recommended. That figure lines up with the 7 day cache expiry on hosted runners, so a team running both placements can hold one retention number across the estate. Two more BYOC facts belong in the same review note. The bucket region and the stack region are fixed at creation and cannot be changed afterward. US and EU are the regions WarpBuild names publicly for its own infrastructure, and the [EU data residency page](/enterprise/data-residency-eu) covers what a written residency clause needs. The [AWS BYOC page](/byoc/aws) walks the account connection and the stack setup. ### [What retention costs, at published rates](#what-retention-costs-at-published-rates)Copy Anchor Link Retention is a cost decision as well as a policy decision, and the arithmetic is short. Hosted cache storage bills at $0.20 per GB-month and every cache write, restore, or list operation bills at $0.0001, both from the [pricing page](/pricing), checked on 2026-08-13. Take a monorepo running 40 workflow runs per weekday across 22 weekdays, which is 880 runs per month. Caching the working tree alongside the download directories means six cache operations per run; caching the download directories alone means four. | Line item | Cache the working tree too | Cache download directories only | | --------------------------------------- | -------------------------- | ------------------------------- | | Live cache footprint | 40 GB | 15 GB | | Storage per month at $0.20 per GB-month | $8.00 | $3.00 | | Cache operations per month | 5,280 | 3,520 | | Operation charges at $0.0001 each | $0.53 | $0.35 | | Total per month | $8.53 | $3.35 | The difference is $5.18 per month on this workload, which is small money and a large reduction in what sits outside the job boundary for 7 days. Substitute your own footprint: the storage line scales linearly with gigabytes held, and the operations line scales with runs multiplied by cache steps. On BYOC, both of those line items are included, and the storage charge you pay is your cloud provider's own bucket pricing under the lifecycle policy you set. Pricing is purely usage based on either placement. ### [The one surface that persists on purpose](#the-one-surface-that-persists-on-purpose)Copy Anchor Link Snapshot runners are the deliberate exception, and describing them as ephemeral would be wrong. A snapshot captures the whole runner disk at the instant the save action runs, and the resulting image is deleted after 15 days rather than at the end of the job. Whatever sat on disk at that moment, including the working tree and any credential a login step wrote, is inside the image. Two mitigations belong in every workflow that saves a snapshot. Delete credential paths before the save step, and run `git clean -ffdx` so untracked and ignored files leave the working tree. On public repositories the alias is readable by anyone who can open your workflow file, so cloud credentials, registry tokens, and signing keys have no place in a snapshot at all. Snapshot storage bills at $0.025 per snapshot-hour and each restore at $0.04 per job, from the [pricing page](/pricing), checked on 2026-08-13\. A single alias held for a 720 hour month is $18.00, so the count of live aliases is worth tracking as both a cost line and a retention line. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does WarpBuild keep a copy of my source code after a job finishes?](#does-warpbuild-keep-a-copy-of-my-source-code-after-a-job-finishes)Copy Anchor Link No. Each runner runs in its own virtual machine with its own encrypted storage volume, both created on demand and destroyed after each build. The checkout, the dependency tree, and the build output go away with that volume, and WarpBuild does not access or store any build secrets. The [security review checklist](/enterprise/ci-security-review) lists the documentation page behind each of those statements, and [are GitHub Actions runners secure](/answers/are-github-actions-runners-secure) covers the isolation model in more depth. ### [What about snapshot runners? Do those keep my checkout?](#what-about-snapshot-runners-do-those-keep-my-checkout)Copy Anchor Link Yes, by design. A snapshot is a saved image of the entire runner disk at the moment the save action ran, so the working tree and anything else on disk is in that image, and snapshots are deleted after 15 days rather than at the end of the job. Remove credentials and run `git clean -ffdx` before the save step. The [WarpBuild runner security documentation](/docs/ci/security) covers the per-job model that snapshots deliberately depart from. ### [On BYOC, where do the persisted bytes land?](#on-byoc-where-do-the-persisted-bytes-land)Copy Anchor Link In an object storage bucket inside your own cloud account, in the region you pinned when you created the stack. The same bucket holds the artifact cache, container image layer caches, and runner telemetry logs, and the [AWS BYOC configuration documentation](/docs/ci/byoc/aws/config) recommends a 7 day lifecycle policy on it. The [AWS BYOC page](/byoc/aws) covers the account connection and the stack region choice. ### [Can I keep every persisted byte in one region?](#can-i-keep-every-persisted-byte-in-one-region)Copy Anchor Link US and EU are the regions WarpBuild names publicly for hosted runners, and enterprise customers can pin hosted runners to one of them. On BYOC the region is your stack region, fixed at stack creation, so the placement decision happens before any runner exists. The [EU data residency page](/enterprise/data-residency-eu) covers the hosted side, and the [pricing page](/pricing) carries the per-minute rate for every runner label. --- # Do GitHub Actions Runners Support macOS? | WarpBuild URL: https://www.warpbuild.com/answers/do-github-actions-runners-support-macos Description: Yes. GitHub Actions runs macOS jobs on ARM64 runners such as warp-macos-15-arm64-6x, which carries 6 vCPU and 14GB of memory at $0.08 per minute. # Do GitHub Actions Runners Support macOS? Yes. GitHub Actions runs macOS jobs on ARM64 runners such as warp-macos-15-arm64-6x, which carries 6 vCPU and 14GB of memory at $0.08 per minute. Last verified: 2026-08-13 Yes. GitHub Actions runs macOS jobs on ARM64 macOS runners, and moving a job onto one is a single `runs-on` change to a label such as `warp-macos-15-arm64-6x`, which provisions 6 vCPU with 14GB of memory and a 120GB SSD at $0.08 per minute. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the macOS line is ARM64 only ([WarpBuild cloud runners](/docs/ci/cloud-runners), checked on 2026-08-13). This page covers what macOS support actually means on a GitHub Actions runner: which images and labels exist, what ships on each image, the two limits that catch teams out on their first macOS run, and what a macOS minute costs against GitHub's published list price. ## [Answer](#answer)Copy Anchor Link macOS runners exist in three shapes under GitHub Actions, and all three run real macOS on Apple hardware. **GitHub-hosted macOS runners.** GitHub publishes `macos-latest`, versioned labels such as `macos-15`, and larger macOS runners on paid plans. Rates are on the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing). **Self-hosted Macs.** The runner agent ships a macOS ARM64 release on [github.com/actions/runner](https://github.com/actions/runner), so a Mac you own can register against a repository or organization and advertise labels. You own the hardware, the image, the Xcode installs, and the cleanup between jobs. **Managed macOS runners.** A provider registers ephemeral macOS virtual machines against your GitHub organization and you select them by label. WarpBuild runs this shape, and the catalog carries multiple sizes and configurations per chip. Five WarpBuild labels cover three macOS images. Every row comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Runner label | OS | vCPU | Memory | Storage | Per minute | Alias | | ----------------------- | -------- | ---- | ------ | --------- | ---------- | --------------------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | none | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | none | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | none | Two facts about this table change how you pin. macOS 13 runners were removed on June 8, 2026, so any workflow still naming `warp-macos-13-arm64-6x` fails to find a runner and has to move to macOS 14, macOS 15, or macOS 26\. The `latest` aliases resolve to the macOS 15 image, in sync with GitHub's own `macos-latest` tag, so a job that must stay on one image should name the versioned label instead of the alias. A workflow that already runs on `macos-latest` needs one line changed: Copy Text ``` name: ios-ci on: pull_request: push: branches: [main] jobs: test: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v4 - name: Record the toolchain run: | sw_vers uname -m xcodebuild -version - name: Resolve Swift package dependencies run: xcodebuild -resolvePackageDependencies -project App.xcodeproj - name: Build and test run: | xcodebuild test \ -project App.xcodeproj \ -scheme App \ -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \ -resultBundlePath TestResults.xcresult - uses: actions/upload-artifact@v4 if: always() with: name: xcresult path: TestResults.xcresult ``` `uname -m` prints `arm64` on that runner and `sw_vers` prints the image version, which is worth keeping in the log while you are moving labels around. ## [Detail](#detail)Copy Anchor Link ### [What ships on each macOS image](#what-ships-on-each-macos-image)Copy Anchor Link The tooling on a WarpBuild macOS runner is the same set that the matching GitHub-hosted image carries, so the images work as drop-in targets for an existing Xcode workflow. The package lists are published per image by GitHub: [macOS 14 ARM64](https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md), [macOS 15 ARM64](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-arm64-Readme.md), and [macOS 26 ARM64](https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md). Read the list for the image you are pinning before you assume a Ruby, Node, or Xcode version is present, because the three images do not carry identical sets. The macOS 26 labels carry one addition. `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` ship Xcode 27.0 (build 27A266a) on top of the Xcode versions already in the upstream GitHub macOS 26 image, along with the iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 simulator runtimes ([cloud runners documentation](/docs/ci/cloud-runners)). That addition exists while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. Plan for a re-pin of your `DEVELOPER_DIR` path at that switch. The per-image Xcode and simulator matrix lives on the [Xcode image reference](/runners/macos/xcode-images). Tailscale is preinstalled on every runner image, including macOS, with the daemon started on demand when networking is configured for the runner ([preinstalled software documentation](/docs/ci/preinstalled-software)). ### [The two limits that catch teams out](#the-two-limits-that-catch-teams-out)Copy Anchor Link **No Docker and no nested virtualization.** macOS runners do not support nested virtualization and cannot run Docker ([cloud runners documentation](/docs/ci/cloud-runners)). A workflow that builds an iOS app and then packages a container image needs two jobs on two platforms. Move the container half of the pipeline to a Linux runner or a remote Docker builder while the macOS job keeps the Xcode, Swift, and simulator work. The [Docker on macOS runners answer](/answers/can-i-use-docker-on-macos-runners) covers the workarounds that people try first and why they fail. **Concurrency on macOS is quota governed.** Since July 27, 2026, concurrency on the macOS fleet is governed by per-organization quotas, and jobs beyond the quota wait for capacity to free up ([July 2026 changelog](/docs/ci/changelog/2026-july)). Teams that need a wide macOS fan-out should size the matrix against the quota their organization holds and contact support to raise it, rather than assuming the Linux and Windows behavior carries over. Runner storage is ephemeral in all cases, so derived data, simulator logs, and build products go to an artifact or a cache before the job ends. ### [What a macOS minute costs](#what-a-macos-minute-costs)Copy Anchor Link Both sides of this comparison are list prices. WarpBuild rates come from the [cloud runners documentation](/docs/ci/cloud-runners). GitHub rates come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing), checked on 2026-08-13. | WarpBuild label | Shape | WarpBuild per minute | GitHub-hosted comparison | GitHub per minute | Difference | | ----------------------- | ------------- | -------------------- | ---------------------------------------------------------- | ----------------- | ------------------------------------- | | warp-macos-15-arm64-6x | 6 vCPU, 14GB | $0.08 | Largest GitHub-hosted macOS ARM64 runner, 5 vCPU with 14GB | $0.102 | 22 percent lower list price | | warp-macos-15-arm64-12x | 12 vCPU, 28GB | $0.16 | Shape-matched comparison unavailable at 12 vCPU | not compared | see the per vCPU-minute figures below | Per vCPU-minute the 6 vCPU row works out to $0.0133 against $0.0204 for the GitHub ARM64 larger runner, and the runner carries one more vCPU and the same memory at the lower rate. The 12 vCPU size sits at the same $0.0133 per vCPU-minute, so doubling the machine doubles the bill and nothing else. **Worked model: one iOS repository.** The repository merges enough pull requests to queue 900 macOS jobs a month, each running 14 minutes on a 6 vCPU runner. That is 12,600 macOS minutes. * `warp-macos-15-arm64-6x` at $0.08 per minute: 12,600 x $0.08 = **$1,008.00 per month**. * Largest GitHub-hosted macOS ARM64 runner at $0.102 per minute: 12,600 x $0.102 = **$1,285.20 per month**. * Difference: $277.20 per month, $3,326.40 over twelve months, which is 22 percent lower list price (GitHub pricing, checked on 2026-08-13). * The $10 in signup credits covers the first $10 of that bill, so month one lands at $998.00 out of pocket at this volume. Run the same arithmetic with your own job count and minute length on the [macOS runner cost guide](/guides/github-actions-macos-runner-cost) before you commit to a label. ## [Related Questions](#related-questions)Copy Anchor Link **Which macOS versions run on WarpBuild GitHub Actions runners?** Three images across five labels: macOS 26, macOS 15, and macOS 14\. macOS 13 runners were removed on June 8, 2026, so a job still pinned to `warp-macos-13-arm64-6x` has to move to one of the three remaining images. The full label list with sizes and rates is on the [macOS runner catalog](/runners/macos). **Can a macOS GitHub Actions runner run Docker?** No. macOS runners do not support nested virtualization and cannot run Docker. Send container builds to Linux runners or to remote Docker builders, and keep the macOS job for Xcode, Swift, and simulator work. The [Docker on macOS runners answer](/answers/can-i-use-docker-on-macos-runners) has the job split that works. **Which Xcode versions ship on the macOS runner images?** Each image carries the Xcode versions of the upstream GitHub image it is built from. The macOS 26 labels additionally ship Xcode 27.0 (build 27A266a) with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes, while GitHub's upstream macOS 27 runner image is in beta. The per-image matrix is on the [Xcode image reference](/runners/macos/xcode-images). **What do macOS GitHub Actions runners cost per minute?** The 6 vCPU size is $0.08 per minute and the 12 vCPU size is $0.16 per minute. GitHub lists its largest macOS ARM64 runner, 5 vCPU with 14GB of memory, at $0.102 per minute (GitHub Actions billing reference, checked on 2026-08-13). The [macOS runner cost guide](/guides/github-actions-macos-runner-cost) works the monthly model, and every rate is on the [pricing page](/pricing). Pick a label from the [macOS runner catalog](/runners/macos), check the Xcode set for that image on the [Xcode image reference](/runners/macos/xcode-images), and change one `runs-on` line to move the job. --- # Do I Need Buildx in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/do-i-need-buildx-in-github-actions Description: Yes for multi-platform images and cross-run cache backends, no for a single-platform build. What buildx adds, and what a remote builder profile removes. # Do I Need Buildx in GitHub Actions? Yes for multi-platform images and cross-run cache backends, no for a single-platform build. What buildx adds, and what a remote builder profile removes. Last verified: 2026-08-13 You need buildx for multi-platform images and for cache backends that outlive the job, and you do not need it for a single-platform image built and pushed inside one job. When a remote builder profile is in play, the drop-in build actions configure buildx for you, so the separate `docker/setup-buildx-action` step and the `cache-to` and `cache-from` flags come out of the workflow file. ## [Answer](#answer)Copy Anchor Link On a current Docker Engine, `docker build` already runs through BuildKit, so the Dockerfile features people associate with BuildKit work without any extra setup: cache mounts, secret mounts, heredocs, and parallel stage execution. That is why the question comes up at all. The step that adds buildx is often copied from an older workflow and left there. What `docker buildx` adds is the builder layer above the engine. It creates named builder instances, selects a driver that decides where the build actually executes, exposes cache import and export backends, and assembles manifest lists that cover more than one platform. The [buildx repository](https://github.com/docker/buildx) documents the driver list, including the `remote` driver that a WarpBuild builder profile uses. | What the job does | Buildx needed | Reason | | ---------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | Build and push one image for the runner's own architecture | No | The default builder covers it. Layers stay in the local store and disappear with the ephemeral runner. | | Build linux/amd64 and linux/arm64 under one tag | Yes | Only a buildx builder with both platforms available can produce the manifest list. | | Reuse layers across runs through cache-to and cache-from | Yes | The default docker driver exports inline cache only. Registry, gha, s3, and local backends need a builder created by buildx. | | Build several targets from a bake file | Yes | docker buildx bake is a buildx subcommand. | | Build on a remote builder VM | Yes, and the action sets it up | The remote driver is a buildx driver, configured by the WarpBuild build actions. | Everything below the first row points at the same conclusion. Once a workflow needs layer reuse or a second architecture, buildx is in the picture, and the only open question is who configures it. The Docker build job usually sits on a small Linux runner while the image build itself executes on a separate builder VM. The [Docker builders documentation](/docs/ci/docker-builders) is the reference for the behavior described on this page. ## [Detail](#detail)Copy Anchor Link ### [What the drop-in actions remove from the workflow file](#what-the-drop-in-actions-remove-from-the-workflow-file)Copy Anchor Link Each builder profile corresponds to one dedicated Docker builder virtual machine with a persistent layer cache. `Warpbuilds/build-push-action` and `Warpbuilds/bake-action` are drop-in replacements for the upstream Docker actions, and they configure buildx against that builder automatically. The documentation recommends removing the `docker/setup-buildx-action` step when it exists only to set up builders. The cache flags go too. When using Docker Builders, the `cache-to` and `cache-from` options are not required, because a cached Docker builder caches the layers and reuses them for subsequent builds without being told to. Copy Text ``` - - name: Set up Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push - uses: docker/build-push-action@v6 + - name: Build and push + uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: user/app:latest - cache-from: type=gha - cache-to: type=gha,mode=max + profile-name: "super-fast-builder" ``` Here is the whole workflow after the change, building both architectures under one tag: Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} platforms: linux/amd64,linux/arm64 profile-name: "super-fast-builder" timeout: 600000 ``` Two details decide whether this workflow runs. The builder profile must have both architectures enabled in the WarpBuild UI, otherwise an arm64 build fails with `exec format error`. And `api-key: ${{ secrets.WARPBUILD_API_KEY }}` is required when the job runs on a runner that is not a WarpBuild runner; on WarpBuild runners the key is unnecessary. The builders work from GitHub Actions, other pipelines, and a developer laptop through the same API. ### [Driving buildx yourself](#driving-buildx-yourself)Copy Anchor Link Teams that already own their build steps do not have to hand them over. `Warpbuilds/docker-configure@v1` provisions the builder inside the runner VM and emits its details as action outputs, and every step after it runs against that builder. Place the configure step immediately before the build step, because builders have a built-in idle timeout that exists so nobody pays for a builder that is waiting around. Outside GitHub Actions the sequence is the documented remote driver setup, and it is worth reading even if you use the actions, because it shows exactly what they automate: 1. Request a builder assignment from the profile with a POST to `/api/v1/builders/assign`, passing an `external_unique_id` for idempotency. 2. Poll `/api/v1/builders/{id}/details` until the status is `ready`, then read the host and the TLS material from the response metadata. 3. Write `ca.pem`, `cert.pem`, and `key.pem` to a certificate directory. 4. Create the buildx instance against the remote node. 5. Build with `--builder`, then complete the session request and remove the buildx instance. Copy Text ``` docker buildx create --name "$BUILDER_NAME" \ --node "$BUILDER_ID" \ --driver remote \ --driver-opt "cacert=$CERT_DIR/ca.pem" \ --driver-opt "cert=$CERT_DIR/cert.pem" \ --driver-opt "key=$CERT_DIR/key.pem" \ --use \ tcp://$HOST docker buildx build --builder "$BUILDER_NAME" -t myimage:latest . ``` Step five carries the money. Billing runs for the entire duration until the assigned builders are terminated, so a script that exits without calling `/api/v1/builder-session-requests/complete` keeps paying. The actions handle termination in their post-job steps. A hand-rolled script needs a trap. Appending a second node to the same buildx instance is supported through the same flow, which is how a build distributes across more than one builder. ### [Builder sizes and what a session costs](#builder-sizes-and-what-a-session-costs)Copy Anchor Link Docker builders are billed per session, measured from when the builder action starts until the job completes. Multiple concurrent jobs on the same builder profile share one session, billed from the first job's start to the last job's completion. A multi-arch build runs one session per architecture, so two architectures produce two parallel sessions on the same profile. | Builder profile | Disk | Price per minute | Architectures | | -------------------- | ----- | ---------------- | ------------------- | | 16 vCPU, 32 GB RAM | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU, 64 GB RAM | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU, 128 GB RAM | 200GB | $0.24 | amd64, arm64, multi | | 96 vCPU, 192 GB RAM | 600GB | $0.36 | amd64 | | 96 vCPU, 192 GB RAM | 2TB | $0.52 | amd64 | | 192 vCPU, 384 GB RAM | 600GB | $0.72 | amd64 | | 192 vCPU, 384 GB RAM | 2TB | $0.88 | amd64 | Rates from the [pricing page](/pricing) and the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13\. The arm64 and multi-arch profiles cap at 64 vCPU; the 96 vCPU and 192 vCPU sizes are amd64 only. The builder cache has a TTL of 10 days, so a profile that goes unused for longer resets and the next build runs cold. ### [The cost model](#the-cost-model)Copy Anchor Link The runner and the builder are two separate, independent resources and you are charged for both, so the comparison has to add them. Take the workflow above: a `warp-ubuntu-latest-x64-4x` runner at $0.008 per minute driving a builder profile, against the same image built inline on the 8-core Linux larger runner at $0.022 per minute, from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. Assume the inline build takes 12 minutes, which puts it at $0.264 per build. The builder path costs the combined per-minute rate for as long as the session is open, so the break-even is the point where the session ends. | Builder profile | Combined rate per minute | Break-even against $0.264 | Share of the 12 minutes | | --------------- | ------------------------ | ------------------------- | ----------------------- | | 16 vCPU | $0.068 | 3.9 minutes | 32 percent | | 32 vCPU | $0.128 | 2.1 minutes | 17 percent | | 64 vCPU | $0.248 | 1.1 minutes | 9 percent | Read the table as a threshold rather than a promise. A 16 vCPU builder pays for itself once the build finishes inside 3.9 minutes, and a 64 vCPU builder has to land inside 1.1 minutes to beat the same baseline. Larger profiles buy parallelism for builds that can use it, and they demand a proportionally larger reduction in wall-clock time to come out ahead on price. Substitute your own inline build duration and rerun the division; the arithmetic is one step. What moves the wall-clock number is the persistent layer cache on the builder, since a warm builder skips the layers it has already produced. The first build after a profile reset is a cold one, which is the case the 10-day TTL creates. For cache behavior outside Docker layers, the [caching documentation](/docs/ci/features/caching) covers the drop-in cache action and the cache-enabled setup actions. ## [Related Questions](#related-questions)Copy Anchor Link ### [Do I need buildx to build multi-arch images in GitHub Actions?](#do-i-need-buildx-to-build-multi-arch-images-in-github-actions)Copy Anchor Link Yes. A plain build produces one image for one platform. Emitting a manifest list that covers `linux/amd64` and `linux/arm64` requires a buildx builder with both platforms available. On WarpBuild, arm64 and multi-arch builder profiles cap at 64 vCPU, and a multi-arch build runs one session per architecture, so two architectures bill as two parallel sessions. The [multi-arch answer](/answers/can-github-actions-build-multi-arch-images) walks through the emulation alternative and why it is slow. ### [Do I still need cache-to and cache-from with a remote Docker builder?](#do-i-still-need-cache-to-and-cache-from-with-a-remote-docker-builder)Copy Anchor Link No. When using WarpBuild Docker Builders, the `cache-to` and `cache-from` options are not required. A cached Docker builder automatically caches the layers and reuses them for subsequent builds, and the builder profile cache resets automatically after 10 unused days. Concurrent builds share the cache with eventual consistency, so a layer produced by one in-flight build may not reach a parallel build until synchronization completes. The [buildx cache backends guide](/guides/buildx-cache-backends-github-actions) compares the backends you would otherwise configure by hand. ### [When is plain docker build enough?](#when-is-plain-docker-build-enough)Copy Anchor Link When the job builds one image for the runner's own architecture, pushes it in the same job, and does not need layer reuse across runs. The default builder handles that, and the layers stay in the local store on an ephemeral runner, so the next run starts cold. That is acceptable for a small image with cheap layers and expensive for anything that compiles. [What is a remote Docker builder](/answers/what-is-a-remote-docker-builder) covers the point where the local store stops being enough. ### [Do I pay for both the runner and the Docker builder?](#do-i-pay-for-both-the-runner-and-the-docker-builder)Copy Anchor Link Yes. These are two separate, independent resources and you are charged for both. Builder sessions are billed per minute from the first job's start to the last job's completion, and the size of the builder is set by the profile rather than by the runner it is driven from. The [remote Docker builders page](/runners/docker-builders) lists the profiles, and the [pricing page](/pricing) carries every per-minute rate. --- # Do I Need to Rewrite My Workflow for Faster Runners? | WarpBuild URL: https://www.warpbuild.com/answers/do-i-need-to-change-my-workflow-to-use-faster-runners Description: No. The runs-on label is the only required change, because WarpBuild runner images carry the same tooling as GitHub-hosted runners and steps run as written. # Do I Need to Rewrite My Workflow for Faster Runners? No. The runs-on label is the only required change, because WarpBuild runner images carry the same tooling as GitHub-hosted runners and steps run as written. Last verified: 2026-08-13 No. The `runs-on` label is the only line you are required to change, because the runner images carry the same tooling as GitHub-hosted runners and every step, action reference, expression, and secret in the file is evaluated the same way. Three documented differences deserve a check before a repository-wide rollout: the Ubuntu 24.04 ARM64 work directory, cache actions on Windows, and Docker or nested virtualization availability per platform ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link A GitHub Actions job picks its machine with one line. Everything above and below that line belongs to the workflow parser and the runner agent: `steps`, `uses` references, `with` inputs, `if` conditions, `${{ }}` expressions, secrets, OIDC token requests, service containers, artifacts, `strategy.matrix`, `concurrency`, `permissions`, reusable workflow calls, and composite actions. None of that is aware of who supplies the hardware. So the migration is a label swap, plus an optional second swap if you want dependency caching served by WarpBuild Cache. Copy Text ``` name: build-and-test on: push: branches: [main] pull_request: jobs: test: - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci - run: npm test - uses: actions/upload-artifact@v4 with: name: coverage path: coverage/ ``` Two lines moved. The first is the whole migration. The second is optional: `actions/setup-node@v4` runs fine on a WarpBuild runner, and the `WarpBuilds/setup-node@v6` fork accepts the same inputs and produces the same outputs while routing the npm cache to WarpBuild Cache ([setup actions documentation](/docs/ci/features/caching/setup-actions)). ### [Which label replaces which](#which-label-replaces-which)Copy Anchor Link Every WarpBuild rate below comes from the [cloud runners documentation](/docs/ci/cloud-runners). Every GitHub rate comes from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | GitHub-hosted label | WarpBuild label | Shape | GitHub per minute | WarpBuild per minute | List price difference | | ---------------------------- | --------------------------- | --------------- | ----------------- | -------------------- | --------------------------- | | ubuntu-latest | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.006 | $0.004 | 33 percent lower list price | | 4-core Linux larger runner | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.012 | $0.008 | 33 percent lower list price | | 8-core Linux larger runner | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.022 | $0.016 | 27 percent lower list price | | 16-core Linux larger runner | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.042 | $0.032 | 24 percent lower list price | | 32-core Linux larger runner | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.082 | $0.064 | 22 percent lower list price | | ubuntu-24.04-arm | warp-ubuntu-latest-arm64-2x | 2 vCPU, 8 GB | $0.005 | $0.003 | 40 percent lower list price | | 4-core Windows larger runner | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.022 | $0.016 | 27 percent lower list price | The `ubuntu-latest` and `ubuntu-24.04-arm` rows quote GitHub's private-repository rates, which are the rates paying teams run against. macOS is the one row where the shapes differ: `warp-macos-latest-arm64-6x` is 6 vCPU and 14 GB at $0.08 per minute, against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14 GB, so that comparison carries one more vCPU and the same memory at the lower rate. The table holds job minutes constant, so it measures list price alone. Per-size arithmetic for every label lives on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [What carries over with no edits](#what-carries-over-with-no-edits)Copy Anchor Link The Linux x86-64 and Windows x86-64 runner images have the same tooling installed as GitHub-hosted runners ([cloud runners documentation](/docs/ci/cloud-runners)). Language toolchains, package managers, Docker, the AWS and Azure CLIs, and the rest of the preinstalled set are present under the same paths, so a step that shells out to a binary finds it. The Ubuntu 24.04 ARM64 images are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the published package list is at [actions/partner-runner-images](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md). Read that list before you assume a specific language version, because ARM64 images and x86-64 images do not always ship the same set. Runner storage is ephemeral and is deleted when the runner terminates, which matches GitHub-hosted behavior. Anything a job needs to outlive itself goes to an artifact, a cache, or a registry. ### [The three things worth checking](#the-three-things-worth-checking)Copy Anchor Link **The Ubuntu 24.04 ARM64 work directory.** The arm64 images for Ubuntu 24.04 set the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/` for the same instance ([cloud runners documentation](/docs/ci/cloud-runners)). Any step with a literal `/home/runner/work/...` path breaks on that platform. Use `${{ github.workspace }}` in YAML and `$GITHUB_WORKSPACE` in shell steps, and audit Docker `-v` mounts, coverage upload paths, and cache path globs for hardcoded prefixes. x86-64 Linux, macOS, and Windows are unaffected. **Cache actions on Windows.** WarpBuild caching is not supported on Windows runners ([caching documentation](/docs/ci/features/caching)). Windows jobs keep `actions/cache@v4` and the upstream `setup-*` actions. On Linux and macOS you may switch to `WarpBuilds/cache@v1`, which is compatible with `actions/cache@v4` and accepts the same `key`, `path`, and `restore-keys` inputs. A mixed-platform matrix can therefore carry two cache steps gated by `runner.os`, or simply leave every leg on the GitHub cache until the Linux legs are proven. Two cache behaviors change the mental model slightly. Entries are scoped to the key, the version, and the branch, and the version hash covers the compression tool plus the cached paths, so a cache saved on `warp-macos-14-arm64-6x` cannot restore on `warp-ubuntu-latest-x64-4x`. Entries expire after 7 days of last use. Cache storage bills at $0.20 per GB-month and $0.0001 per operation on hosted runners, and is free on BYOC. **Docker and nested virtualization per platform.** Linux x86-64 runners run Docker and can request `/dev/kvm` through the `nested-virtualization.enabled=true` dynamic label, which is what Android emulator jobs need. ARM64 Linux runners do not support nested virtualization, so those jobs stay on an x86-64 label. macOS runners do not support nested virtualization and cannot run Docker at all, so a macOS job that shells out to `docker build` needs to move to a Linux runner or to a remote Docker builder. All three facts are in the [cloud runners documentation](/docs/ci/cloud-runners). ### [The optional cache swap, in full](#the-optional-cache-swap-in-full)Copy Anchor Link Ten forks cover the common toolchains, and each one is a one line change with the same inputs as its upstream ([setup actions documentation](/docs/ci/features/caching/setup-actions)). | Upstream action | WarpBuild fork | What it caches | | ----------------------- | ----------------------------------------- | ---------------------------------------- | | actions/setup-node | WarpBuilds/setup-node@v6 | npm, yarn, and pnpm dependencies | | actions/setup-python | WarpBuilds/setup-python@v6 | pip, pipenv, and poetry dependencies | | actions/setup-go | WarpBuilds/setup-go@v6 | Go modules and build outputs | | actions/setup-java | WarpBuilds/setup-java@v5 | Maven, Gradle, and sbt dependencies | | actions/setup-dotnet | WarpBuilds/setup-dotnet@v4 | NuGet global packages | | ruby/setup-ruby | WarpBuilds/setup-ruby@v1.295.0 | Gems installed by bundle install | | Swatinem/rust-cache | WarpBuilds/rust-cache@v2 | Cargo registry, git deps, and target dir | | gradle/actions | WarpBuilds/gradle-actions/setup-gradle@v5 | Gradle User Home | | jdx/mise-action | WarpBuilds/mise-action@v2 | Tools installed by mise | | goto-bus-stop/setup-zig | WarpBuilds/setup-zig@v2 | Downloaded Zig compilers | For a hand-rolled cache step the change is the same shape: Copy Text ``` - - uses: actions/cache@v4 + - uses: WarpBuilds/cache@v1 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} restore-keys: | ${{ runner.os }}-pip- ``` These forks pick up WarpBuild Cache automatically when they run on a WarpBuild runner, with no extra configuration. On a GitHub-hosted runner they fall back to the GitHub cache, so a mixed matrix does not need branching logic. ### [What the swap is worth on one repository](#what-the-swap-is-worth-on-one-repository)Copy Anchor Link A service repository merges enough pull requests to queue 2,000 jobs a month at 5 minutes each on a 4 vCPU Linux runner. That is 10,000 billable minutes. * `warp-ubuntu-latest-x64-4x` at $0.008 per minute: 10,000 x $0.008 = **$80.00 per month**. * GitHub 4-core Linux larger runner at $0.012 per minute: 10,000 x $0.012 = **$120.00 per month**. * Difference: $40.00 per month, $480.00 over twelve months, which is 33 percent lower list price ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). * The $10 in signup credits covers the first $10 of that bill, so month one lands at $70.00 out of pocket at this volume. The arithmetic assumes identical job minutes on both sides, so it sets a floor on the difference. ### [Rolling out one job at a time](#rolling-out-one-job-at-a-time)Copy Anchor Link The label lives in a matrix like any other value, so a single workflow can run both sides and let you read the difference from the Actions tab. Copy Text ``` name: runner-comparison on: pull_request: jobs: test: strategy: fail-fast: false matrix: runner: - ubuntu-latest - warp-ubuntu-latest-x64-4x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - run: pip install -r requirements.txt - run: pytest -q ``` `fail-fast: false` keeps the GitHub-hosted leg running when the new leg fails, which is what you want while you are proving the swap. Once the numbers look right, delete the matrix and keep one label. For a repository where the label should be switchable without a commit, put it in a repository variable and reference it as `runs-on: ${{ vars.DEFAULT_RUNNER }}`. Rollback becomes a settings change instead of a pull request, which is useful during a first week on new hardware. Optional features can be added later as a step, label modifier, or profile. None is a prerequisite for the runner-label swap described above. ## [Related Questions](#related-questions)Copy Anchor Link **How do I roll back if a job fails on the faster runner?** Change the `runs-on` line back to the GitHub-hosted label and re-run. No other line in the workflow file depends on the runner, so the revert is a one line commit, or a repository variable you flip without touching the file. The [runner label reference](/answers/how-do-i-change-the-runner-in-a-github-actions-workflow) covers where the label can live in a workflow. **Can one matrix run some legs on GitHub-hosted runners and some on WarpBuild runners?** Yes. Put both labels in the matrix and read them with `runs-on: ${{ matrix.runner }}`, as in the comparison workflow above. Both legs execute the same steps, which is the cleanest way to compare wall clock and per-minute cost on your own repository. Mixing runner types inside one workflow is a supported pattern, covered in [mixing GitHub-hosted and self-hosted runners](/answers/can-i-mix-github-hosted-and-self-hosted-runners). **Do I have to change my caching setup?** No. `actions/cache@v4` keeps working on WarpBuild runners. `WarpBuilds/cache@v1` is a drop-in replacement for it, and the `WarpBuilds` setup forks use WarpBuild Cache automatically on WarpBuild runners. WarpBuild caching is not supported on Windows runners, so Windows jobs stay on the GitHub cache. The [setup actions documentation](/docs/ci/features/caching/setup-actions) lists every fork and its cache inputs. **What does it cost to try the swap on one workflow?** Price your own workload against the [pricing page](/pricing) before you widen the rollout. Start with one job on a label from the [Linux x64 runner catalog](/runners/linux-x64), keep the GitHub-hosted leg in the matrix for a week, and work through the rest of the pipeline with the [guide to speeding up GitHub Actions workflows](/guides/speed-up-github-actions). --- # Do I Pay a Seat Fee for GitHub Actions Runners? | WarpBuild URL: https://www.warpbuild.com/answers/do-i-pay-a-seat-fee-for-github-actions-runners Description: No. WarpBuild GitHub Actions runners bill purely by usage, with no base subscription fee, no platform fee, and no seat fee at any engineering headcount. # Do I Pay a Seat Fee for GitHub Actions Runners? No. WarpBuild GitHub Actions runners bill purely by usage, with no base subscription fee, no platform fee, and no seat fee at any engineering headcount. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link No. WarpBuild's GitHub Actions runners bill purely by usage, with no base subscription fee, no platform fee, and no seat fee. Adding a developer to your organization adds no line to the invoice and changes no rate on the lines already there. The billing unit is the runner minute. Every rate below comes from the [WarpBuild pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Line item | How it is billed | Rate | | ----------------------------- | --------------------------- | ----------------------------------------------------------- | | Linux x64 runner minutes | Per minute of job execution | $0.004/min at 2 vCPU and 8 GB (warp-ubuntu-latest-x64-2x) | | Linux ARM64 runner minutes | Per minute of job execution | $0.003/min at 2 vCPU and 8 GB (warp-ubuntu-latest-arm64-2x) | | macOS runner minutes | Per minute of job execution | $0.08/min at 6 vCPU and 14 GB (warp-macos-latest-arm64-6x) | | Windows runner minutes | Per minute of job execution | $0.016/min at 4 vCPU and 16 GB (warp-windows-latest-x64-4x) | | Cache storage | Per GB-month | $0.20 | | Cache write, restore, or list | Per operation | $0.0001 | | Snapshot restore | Per job | $0.04 | | Seats or users | Never billed | $0.00 | | Base subscription | Never billed | $0.00 | | Platform fee | Never billed | $0.00 | The seat count moves none of those four rates. Signup includes $10 free credits, which covers 2,500 minutes on `warp-ubuntu-latest-x64-2x` at $0.004 per minute before any charge appears; the [free credits answer](/answers/what-happens-when-my-free-credits-run-out) covers what happens after that. ## [Detail](#detail)Copy Anchor Link ### [The one fee that stays fixed](#the-one-fee-that-stays-fixed)Copy Anchor Link One line on the price list holds still while usage moves. SSO is available for a flat $250 per month, whatever the user count, and the [SSO documentation](/docs/sso) covers the SAML 2.0 and OIDC setup it pays for. Ten engineers and a thousand engineers pay the same $250, which is the opposite shape of a seat fee: the charge is attached to the capability, and the user count never enters the arithmetic. The [SSO for GitHub Actions runner access page](/enterprise/sso-github-actions) walks through provisioning and what changes for workflows during a rollout, which is nothing. Everything else on the invoice is metered. Runner minutes bill per minute, cache storage bills per GB-month, cache operations bill per operation, and snapshot restores bill per job. ### [Doubling headcount at constant build volume](#doubling-headcount-at-constant-build-volume)Copy Anchor Link Take a team of 25 engineers that grows to 50 while the workflows keep the same monthly minutes: 60,000 Linux x64 minutes on the 2 vCPU label, 2,000 macOS minutes, 1,000 Windows minutes, and 40 GB of cache storage. | Line item | Volume | Rate | 25 engineers | 50 engineers | | ---------------------------------- | ----------- | ------------------ | ------------ | ------------ | | warp-ubuntu-latest-x64-2x minutes | 60,000 | $0.004/min | $240.00 | $240.00 | | warp-macos-latest-arm64-6x minutes | 2,000 | $0.08/min | $160.00 | $160.00 | | warp-windows-latest-x64-4x minutes | 1,000 | $0.016/min | $16.00 | $16.00 | | Cache storage | 40 GB-month | $0.20 per GB-month | $8.00 | $8.00 | | Seats | 25 then 50 | none | $0.00 | $0.00 | | Base subscription | 1 account | none | $0.00 | $0.00 | | Platform fee | 1 account | none | $0.00 | $0.00 | | Total | $424.00 | $424.00 | | | The totals match because nothing in the arithmetic reads headcount. Add SSO and both columns become $674.00, since the flat $250 is the same number on either side. In practice, 25 more engineers usually do push more commits, and that is where the bill moves. The 26th engineer costs $0.00 until their first pull request queues a job. A 6 minute job on `warp-ubuntu-latest-x64-4x`, priced at $0.008 per minute on the [WarpBuild pricing page](/pricing), costs $0.048, so 200 such runs in their first month add $9.60\. Forecasting reduces to expected minutes per platform multiplied by the rate of the labels those jobs use, which is a number your billing reports already carry. ### [How this sits next to your GitHub plan](#how-this-sits-next-to-your-github-plan)Copy Anchor Link GitHub bills its own plans per user, and that billing belongs to GitHub rather than to the runner your jobs land on (see [github.com/pricing](https://github.com/pricing), checked on 2026-08-13). Moving a job from a GitHub-hosted label to a `warp-` label changes the runner line on the WarpBuild invoice and leaves your GitHub seat count exactly where it was. That split matters when you compare quotes. A per-minute rate and a per-seat rate answer different questions, and a fleet quote is only comparable once both sides state the fixed component. On WarpBuild the fixed component is $0.00 unless you buy SSO, in which case it is $250 per month at any user count. The full rate list is on the [WarpBuild pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does adding an engineer increase my GitHub Actions runner bill?](#does-adding-an-engineer-increase-my-github-actions-runner-bill)Copy Anchor Link Only through the jobs that engineer queues. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so a new hire adds cost when their pull requests consume runner minutes and not before. Per-minute rates for every label are on the [WarpBuild pricing page](/pricing). ### [Which WarpBuild charges are fixed rather than usage based?](#which-warpbuild-charges-are-fixed-rather-than-usage-based)Copy Anchor Link SSO is available for a flat $250 per month, whatever the user count, as described in the [SSO documentation](/docs/sso) and the [SSO for GitHub Actions runner access page](/enterprise/sso-github-actions). Every other line is metered: runner minutes per minute, cache storage per GB-month, cache operations per operation, and snapshot restores per job. ### [Is there a minimum spend or base subscription fee for GitHub Actions runners?](#is-there-a-minimum-spend-or-base-subscription-fee-for-github-actions-runners)Copy Anchor Link No. There is no minimum spend and no base subscription fee. Signup includes $10 free credits, which covers 2,500 minutes on a 2 vCPU Linux x64 runner at $0.004 per minute, and usage bills from there at the rates listed on the [WarpBuild pricing page](/pricing). The [free credits answer](/answers/what-happens-when-my-free-credits-run-out) covers the transition from credits to billed usage. --- # Do I Pay for Both the Runner and the Builder? | WarpBuild URL: https://www.warpbuild.com/answers/do-i-pay-for-both-the-runner-and-the-docker-builder Description: Yes. The GitHub Actions runner bills per minute and the remote Docker builder bills per session, on two separate lines. Both rate tables and a worked job. # Do I Pay for Both the Runner and the Builder? Yes. The GitHub Actions runner bills per minute and the remote Docker builder bills per session, on two separate lines. Both rate tables and a worked job. Last verified: 2026-08-13 Yes. A GitHub Actions job that uses a remote Docker builder holds two machines, and each one bills on its own line: the runner bills per minute for as long as the job runs, and the builder bills per session at the rate of its profile size. The [Docker builders documentation](/docs/ci/docker-builders) states the builder half directly, and the runner half is the ordinary per-minute rate on the [pricing page](/pricing). ## [Answer](#answer)Copy Anchor Link The two lines exist because the two resources are separate. The runner checks out the repository, runs tests, runs post-job steps, and issues the build request. The builder is a different virtual machine with its own disk and its own layer cache, and it does the image work. Neither one substitutes for the other, so both meters run. The documentation is explicit about how each meter reads. Docker builders "are billed per session. A session is measured from when the builder action starts until the job completes", and concurrent jobs on one profile share a single session billed from the first job start to the last job completion ([Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13). Runners bill per minute at the rate published for their label on the [pricing page](/pricing). This is the runner line for the common sizes. | Runner label | Shape | Price per minute | | --------------------------- | -------------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32GB | $0.016 | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128GB | $0.064 | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16GB | $0.006 | | warp-macos-latest-arm64-6x | 6 vCPU, 14GB | $0.08 | | warp-windows-latest-x64-4x | 4 vCPU, 16GB | $0.016 | This is the builder line, for every profile size in the catalog. | Builder profile | vCPU | Memory | Disk | Price per minute | Architectures | | -------------------- | ---- | ------ | ----- | ---------------- | ------------------- | | 16 vCPU | 16 | 32GB | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU | 32 | 64GB | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU | 64 | 128GB | 200GB | $0.24 | amd64, arm64, multi | | 96 vCPU | 96 | 192GB | 600GB | $0.36 | amd64 | | 96 vCPU, large disk | 96 | 192GB | 2TB | $0.52 | amd64 | | 192 vCPU | 192 | 384GB | 600GB | $0.72 | amd64 | | 192 vCPU, large disk | 192 | 384GB | 2TB | $0.88 | amd64 | Both tables come from the [pricing page](/pricing) and the runner and builder documentation, checked on 2026-08-13\. The [remote Docker builder catalog](/runners/docker-builders) carries the same builder rows with the sizing notes. ## [Detail](#detail)Copy Anchor Link ### [The two meters start at different moments](#the-two-meters-start-at-different-moments)Copy Anchor Link The runner meter starts when the job picks up a machine and stops when the job finishes. The builder meter starts when the builder action runs inside that job and stops when the job completes. The builder window therefore sits inside the runner window, and the overlapping stretch is billed on both lines at the same time. That overlap is the part worth designing around. Minutes spent on checkout, dependency install, tests, and post-job steps are runner-only minutes. Minutes spent on the image build are runner minutes and builder minutes together. ### [One build job, both lines](#one-build-job-both-lines)Copy Anchor Link Stated assumptions, so you can substitute your own measurements: * One image build job on `warp-ubuntu-latest-x64-4x` at $0.008 per minute. * The job runs 7 minutes end to end: 1 minute of checkout and setup, 5 minutes on the build step, 1 minute of post-job steps. * The builder is a 32 vCPU profile with 64GB memory and a 200GB disk at $0.12 per minute. * The builder action starts at minute 1 and the session ends when the job completes at minute 7, so the session is 6 minutes. | Line | Rate | Billed quantity | Cost | | --------------------------------- | ----------------- | ----------------- | ------ | | Runner, warp-ubuntu-latest-x64-4x | $0.008 per minute | 7 runner minutes | $0.056 | | Builder session, 32 vCPU profile | $0.12 per minute | 6 session minutes | $0.720 | | One build, both lines | $0.776 | | | The builder is 93 percent of that total, which is the practical answer to "which line should I tune". Scale it to 400 builds a month arriving one at a time: 2,800 runner minutes at $22.40, plus 2,400 session minutes at $288.00, for $310.40. Now change the arrival pattern and nothing else. If the same 400 builds arrive as 100 fan-outs of 4 jobs on one profile, and each fan-out holds the builder for 10 minutes, the builder line is 1,000 session minutes at $120.00 while the runner line stays at $22.40, for $142.40\. Same builds, same profile, $168.00 of difference from scheduling, because concurrent jobs on one profile share a session and staggered jobs each open their own. ### [When the builder line pays for itself](#when-the-builder-line-pays-for-itself)Copy Anchor Link The builder rate is higher per minute than a runner rate at the same shape: $0.12 per minute for a 32 vCPU builder profile against $0.064 per minute for `warp-ubuntu-latest-x64-32x` at 32 vCPU. The builder line earns that rate by removing minutes, and it removes minutes only while the layer cache on the profile disk stays warm across runs. Here is the comparison on the same repository, with the build running on the runner in one case and on a builder in the other. | Shape | Runner line | Builder line | Total per build | | ------------------------------------------------------------------------------------------------ | ----------- | ------------ | --------------- | | Build on warp-ubuntu-latest-x64-32x, cold layer store every run, 21 minutes | $1.344 | none | $1.344 | | Build on a 32 vCPU builder profile driven by warp-ubuntu-latest-x64-4x, warm cache, 7 minute job | $0.056 | $0.720 | $0.776 | The $0.568 difference comes from the 14 minutes the warm cache removes rather than from a lower per-minute rate. Two conditions break that arithmetic. A profile that goes unused for more than 10 days has its cache reset automatically, so a repository that builds images weekly starts cold on most runs. A base image tag that gets republished on every build invalidates every layer below it, which produces a cold build while still opening a billable session. Remote Docker builders are one part of the product surface, alongside snapshot runners, CI observability, an MCP server, and the Action Debugger, and each of those has its own line or its own included behavior on the bill. ### [Reading both lines in your own account](#reading-both-lines-in-your-own-account)Copy Anchor Link The [reports documentation](/docs/ci/features/reports) describes where the split shows up. The Billing section has separate CI, Docker Builder, and Cache tabs. The CI tab gives one row per job execution with repository, job name, runner label, execution time, billed time, and the runner and snapshot cost breakdown. The Docker Builder tab gives one row per session with profile, architecture, duration, and cost. Both tables filter by date range and export to CSV, which is the fastest way to check the session count against the job count and find the staggered builds. Nothing else attaches to these two lines. Every rate is on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does the runner keep billing while it waits on the builder?](#does-the-runner-keep-billing-while-it-waits-on-the-builder)Copy Anchor Link Yes. The runner holds a machine for the whole job, including the stretch it spends waiting for the build to come back from the builder, so the two lines overlap in time. That is why a small runner paired with a large builder profile is usually the cheaper shape: the runner rate applies to every minute of the job, and the builder rate applies only to the session. [How a remote Docker builder is billed](/answers/how-is-a-remote-docker-builder-billed) works through the session boundaries in more detail. ### [Do I still pay a builder line on a GitHub-hosted runner?](#do-i-still-pay-a-builder-line-on-a-github-hosted-runner)Copy Anchor Link Yes. Remote Docker builders work with runners WarpBuild does not operate, and the builder session is billed the same way whatever drives it. The runner minutes move to GitHub's bill instead of WarpBuild's, and the workflow passes an API key through the `api-key` input so the action can request a builder assignment ([Docker builders documentation](/docs/ci/docker-builders)). ### [Does a multi-arch image produce two builder lines?](#does-a-multi-arch-image-produce-two-builder-lines)Copy Anchor Link Yes. Each architecture runs on a separate builder instance, so a multi-arch build opens one session per architecture on the same profile and both are billed independently until the post-action steps finish. The runner line stays single, because one job drives both builds. Note also that arm64 and multi-arch profiles cap at 64 vCPU, so the 96 vCPU and 192 vCPU rows in the [builder catalog](/runners/docker-builders) are available for amd64-only profiles. --- # Do I Pay for Queue Time on GitHub Actions Runners? | WarpBuild URL: https://www.warpbuild.com/answers/do-i-pay-for-queue-time-on-github-actions-runners Description: No. Billing starts when a job begins running on a machine, so GitHub Actions queue time never reaches the invoice and shows up as engineer wait time. # Do I Pay for Queue Time on GitHub Actions Runners? No. Billing starts when a job begins running on a machine, so GitHub Actions queue time never reaches the invoice and shows up as engineer wait time. ## [Answer](#answer)Copy Anchor Link No, billing starts when the job runs on a machine, so waiting in the queue is a delivery problem rather than a line on the bill. GitHub bills Actions minutes for job execution, rounded up to the nearest minute per job, per the [GitHub Actions billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-actions) (checked on 2026-08-13), and WarpBuild bills runner minutes from the moment the machine starts the job at the rates on the [pricing page](/pricing). The timestamps make the split checkable on any run you already have. Every job object in the [workflow jobs REST API](https://docs.github.com/en/rest/actions/workflow-jobs) carries `created_at`, `started_at`, and `completed_at`. Subtract `created_at` from `started_at` and you have the queue wait. Subtract `started_at` from `completed_at` and you have the interval that turns into money. | Interval | Timestamps | Where it is reported | On the invoice | | ---------- | -------------------------------- | ----------------------------------------------------------------------- | -------------- | | Queue wait | created\_at to started\_at | Queue Timings report, and the queue time percentiles in the Jobs report | No | | Execution | started\_at to completed\_at | CI billing table, execution time column | Yes | | Rounding | Billed time minus execution time | CI billing table, billed time column | Yes | That third row is the only place where a queue changes the bill at all: a workflow split into many short jobs pays the rounding on each one, so job count matters to the invoice while wait time does not. ## [Detail](#detail)Copy Anchor Link ### [What the queue timings report separates](#what-the-queue-timings-report-separates)Copy Anchor Link WarpBuild keeps wait time and run time in different tables, which is what makes the answer above auditable rather than theoretical. Per the [reports documentation](/docs/ci/features/reports): * The Queue Timings section has one row per runner label and stack, with run count, queue time P75, and queue time P90, plus a daily bar chart of average queue time and job count. * The Jobs section has one row per repository, workflow, and job name, with duration P75 and P90 next to queue time P75 and P90, so a job that got slower and a job that started later look different. * The CI billing table has one row per job execution, carrying execution time, billed time, and the split between runner cost and snapshot cost. Queue wait appears in none of its columns. Every one of those tables exports to CSV with the current filters applied, so the before and after comparison is a spreadsheet rather than a screenshot. ### [Why queue time still costs money](#why-queue-time-still-costs-money)Copy Anchor Link Queue minutes are unbilled, which is exactly why they survive for years. They are paid in engineer hours. Here is one team converted into numbers, with the assumptions stated so you can substitute your own from the Queue Timings report. Assumptions: * 30 engineers, each opening 4 pull requests per week that gate on a required check: 120 gated runs per week. * Queue time P75 of 5 minutes on the busiest runner label, read off the Queue Timings table. * Half of those runs (60) have a person actively waiting on the result before they can review, merge, or ship. * A loaded engineer cost of $120 per hour. The arithmetic: * 120 runs x 5 minutes = 600 queue minutes per week across the team. * The watched half: 60 runs x 5 minutes = 300 minutes, or 5 engineer hours per week. * At $120 per hour that is $600 per week, or $31,200 across 52 weeks. * Each minute shaved off P75 returns 1 engineer hour per week (60 runs x 1 minute), worth $120 per week or $6,240 per year. Now the billed side of the same 120 runs, at rates from the [cloud runners catalog](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13: | Runner label | Shape | Price per minute | Cost of an 8 minute job | | -------------------------- | -------------- | ---------------- | ----------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | $0.032 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | $0.064 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $0.128 | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | $0.256 | Assume each of the 120 runs holds `warp-ubuntu-latest-x64-8x` for 8 minutes of execution. That is 960 runner minutes, or $15.36 per week, the third row above at 120 runs. So this team's invoice for those runs is $15.36 a week and its wait is $600 a week of engineer time. The number that never appears on a bill is the larger of the two by a wide margin, which is the practical reason to treat queue time as a budget item even though no one charges for it. ### [Where to see wait time per runner label](#where-to-see-wait-time-per-runner-label)Copy Anchor Link Open the Queue Timings report and sort by queue time P90, then filter to one runner label at a time. Two shapes show up. A label with a high P90 and a normal P75 is bursting past its capacity at specific hours. A label where P75 and P90 are both high has a steady shortage, or a `runs-on` value that no online runner advertises, in which case jobs sit forever with an empty log. The [queue times guide](/guides/github-actions-queue-times) works through the five causes and the fix that matches each one, and the [queue time definition](/glossary/queue-time) covers the term itself. The daily series on that report splits total queue time into the GitHub side and the WarpBuild side of the handoff, which tells you whether to look at your account and runner group configuration or at capacity. ### [What the wait looks like after the pool goes away](#what-the-wait-looks-like-after-the-pool-goes-away)Copy Anchor Link Creates a fresh ephemeral machine for each job when that job queues, so a 16-way matrix gets 16 machines instead of waiting in waves for a fixed pool. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That covers generally available features on Linux and Windows runners; for high concurrency on macOS runners, contact [support@warpbuild.com](mailto:support@warpbuild.com). Label strings are in the [cloud runners catalog](/docs/ci/cloud-runners), and [how fast runners start after a job is queued](/answers/how-fast-do-runners-start-after-a-job-is-queued) covers what replaces the wait. ### [What the bill actually contains](#what-the-bill-actually-contains)Copy Anchor Link The full rate list is on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does GitHub Actions bill the minutes a job spends queued?](#does-github-actions-bill-the-minutes-a-job-spends-queued)Copy Anchor Link No. GitHub bills job execution time, rounded up to the nearest minute per job ([GitHub Actions billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13), and WarpBuild bills runner minutes from the moment the machine starts the job. The wait before that lands in the Queue Timings report rather than on the invoice, and the CI billing row proves it by carrying execution time and billed time with no queue column. ### [Where do I see queue time per runner label?](#where-do-i-see-queue-time-per-runner-label)Copy Anchor Link The Queue Timings report groups every job by runner label and stack and reports run count, queue time P75, and queue time P90, alongside a daily average chart and CSV export. The Jobs report adds the same percentiles per repository, workflow, and job name. Both are documented in the [reports documentation](/docs/ci/features/reports). ### [If queue time is free, why fix it?](#if-queue-time-is-free-why-fix-it)Copy Anchor Link Because it is paid in engineer hours instead of dollars. On the model above, 60 watched runs a week waiting 5 minutes each is 5 engineer hours a week, or $600 at a $120 loaded hourly cost, against $15.36 of billed runner minutes for the same runs at $0.016 per minute (rates checked on 2026-08-13 on the [pricing page](/pricing)). Every minute removed from P75 returns about $6,240 a year on those assumptions. ### [Do WarpBuild runners have concurrency limits?](#do-warpbuild-runners-have-concurrency-limits)Copy Anchor Link Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That covers generally available features on Linux and Windows runners; for high concurrency on macOS runners, contact [support@warpbuild.com](mailto:support@warpbuild.com). The [queue times guide](/guides/github-actions-queue-times) explains why account ceilings and fixed pools are the usual source of a long wait. --- # Is Xcode Preinstalled on macOS Runners? | WarpBuild URL: https://www.warpbuild.com/answers/do-macos-github-actions-runners-have-xcode-preinstalled Description: Yes. Xcode ships inside the macOS runner image, so a job compiles with no download step. What else is preinstalled, and what still installs per job. # Is Xcode Preinstalled on macOS Runners? Yes. Xcode ships inside the macOS runner image, so a job compiles with no download step. What else is preinstalled, and what still installs per job. Yes. Xcode is baked into the macOS runner image, so a job that calls `xcodebuild` in its first step compiles right away with no toolchain download at job start. WarpBuild macOS images are built from the matching upstream GitHub macOS arm64 images and carry the same tooling ([preinstalled software documentation](/docs/ci/preinstalled-software), checked on 2026-08-13), and the macOS 26 labels additionally ship Xcode 27.0 (build 27A266a) with its simulator runtimes ([macOS 26 tooling](/docs/ci/cloud-runners#macos-26-tooling)). ## [Answer](#answer)Copy Anchor Link The macOS image decides which Xcode bundles exist on the machine, so the `runs-on` label is the thing that picks your toolchain set. Every WarpBuild macOS image tracks its upstream GitHub equivalent, and GitHub publishes a per-build inventory readme for each one. | macOS image | WarpBuild labels | Preinstalled Xcode tooling | Published inventory | | ----------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | macOS 26 | warp-macos-26-arm64-6x, warp-macos-26-arm64-12x | The Xcode bundles in the upstream GitHub macOS 26 arm64 image, plus Xcode 27.0 (build 27A266a) installed by WarpBuild, with the iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 simulator runtimes | [macos-26-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md) and [macOS 26 tooling](/docs/ci/cloud-runners#macos-26-tooling) | | macOS 15 | warp-macos-15-arm64-6x, warp-macos-15-arm64-12x, plus the aliases warp-macos-latest-arm64-6x and warp-macos-latest-arm64-12x | The Xcode bundles in the upstream GitHub macOS 15 arm64 image, with the simulator runtimes each bundle carries | [macos-15-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-arm64-Readme.md) | | macOS 14 | warp-macos-14-arm64-6x | The Xcode 15 line, with 15.4 as the image default | [macos-14-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md) | Each Xcode bundle brings the command line tools with it, so `xcodebuild`, `xcrun`, `xcode-select`, `swift`, `simctl`, and `notarytool` resolve on PATH from the first step of the job. The platform SDKs live inside the bundle, which is why an SDK version and an Xcode version are the same decision on a runner. Two of those labels are aliases. `warp-macos-latest-arm64-6x` and `warp-macos-latest-arm64-12x` resolve to the macOS 15 labels, in sync with GitHub's `macos-latest` tag ([cloud runners documentation](/docs/ci/cloud-runners)), so a job on an alias moves to a different image and a different preinstalled set the day the alias moves. Pin the versioned label when the toolchain matters. macOS 13 runners were removed on June 8, 2026, so a workflow still naming a macOS 13 label queues without a match. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The full macOS lineup with sizes and rates is on the [macOS runner hub](/runners/macos), and the readme link for every image sits in one place on the [preinstalled software page](/runners/preinstalled-software). ## [Detail](#detail)Copy Anchor Link ### [What "preinstalled" covers, and what it does not](#what-preinstalled-covers-and-what-it-does-not)Copy Anchor Link Preinstalled means the bytes are already on the boot volume when the job starts. On a macOS image that covers the Xcode bundles under `/Applications`, the command line tools inside each bundle, the platform SDKs those bundles carry, and the simulator runtimes bundled with them. It does not cover anything specific to your repository or your Apple Developer account. Four things routinely have to happen inside the job: **Signing certificates and provisioning profiles.** These are account secrets, so no shared image carries them. A release job imports them from repository secrets into a temporary keychain on each run. Runner storage is ephemeral and is deleted when the runner terminates, so the keychain never survives to the next job. **Simulator runtimes the image does not carry.** A destination string naming a runtime that is absent fails the job, and the fix inside the workflow is `sudo xcodebuild -downloadPlatform iOS`. Runtimes are multi-gigabyte downloads, so this adds transfer minutes to every run and competes for the 120GB SSD on the 6 vCPU shape. **Repository dependencies.** Swift Package Manager checkouts, CocoaPods pods, and the gems pinned by your `Gemfile.lock` are resolved per job. The image ships the language runtimes listed in its readme; your dependency graph is fetched fresh unless you cache it. **Anything outside the readme.** Extra Homebrew formulas, a linter your team standardized on last quarter, or an Xcode version that image never carried. Read the readme for the image before assuming a tool is present, because the inventory changes with each image release. ### [What a per-job install step costs](#what-a-per-job-install-step-costs)Copy Anchor Link Every added minute in a job is a billed minute at the macOS rate for that label. Rates come from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The monthly column below assumes 600 macOS jobs a month, which is a team merging around 30 pull requests a weekday with one macOS job per push. | Runner label | Shape | Rate per minute | Cost of one added minute per job | Cost of one added minute across 600 jobs | | ---------------------------------------------------------------------- | -------------- | --------------- | -------------------------------- | ---------------------------------------- | | warp-macos-26-arm64-6x, warp-macos-15-arm64-6x, warp-macos-14-arm64-6x | 6 vCPU, 14 GB | $0.080 | $0.08 | $48.00 | | warp-macos-26-arm64-12x, warp-macos-15-arm64-12x | 12 vCPU, 28 GB | $0.160 | $0.16 | $96.00 | Scale the row by the length of the step. A three-minute toolchain or runtime install on the 6 vCPU shape is 1,800 added minutes a month, which is $144.00, and the same step on the 12 vCPU shape is $288.00\. That arithmetic is the reason to pick a label whose image already carries what the job needs rather than reinstalling it 600 times. ### [A guard step that records the image at job start](#a-guard-step-that-records-the-image-at-job-start)Copy Anchor Link The cheapest protection against an image update you did not read about is three lines of shell at the top of the job. They cost seconds and turn a toolchain change into a diff in the job log instead of a compile error nobody can explain. Copy Text ``` name: ios-pr on: pull_request: branches: [main] jobs: build-and-test: runs-on: warp-macos-26-arm64-6x env: DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer steps: - uses: actions/checkout@v4 - name: Record the preinstalled toolchain run: | ls -1 /Applications | grep Xcode xcode-select -p xcodebuild -version xcrun simctl list runtimes - name: Fail fast when the expected toolchain is missing run: | xcodebuild -version | grep -q "Xcode 27.0" || { echo "Expected Xcode 27.0 on this label" exit 1 } - name: Build and test run: | xcodebuild test \ -scheme AppScheme \ -destination "platform=iOS Simulator,name=iPhone 16 Pro,OS=27.0" ``` `xcodebuild -version` prints the version and build number of the selected toolchain, `xcode-select -p` prints the developer directory in force, and `xcrun simctl list runtimes` prints every runtime the image carries with its build number and availability. The `ls` line names the bundles on disk, which is the fastest way to confirm a `DEVELOPER_DIR` pin. Keep the guard on the release job at minimum, since that is the job whose artifact ships. Xcode 27.0 on macOS 26 has a support window worth a comment in the workflow. It is a WarpBuild addition on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. Runner image changes land in the [WarpBuild changelog](/docs/ci/changelog). ## [Related Questions](#related-questions)Copy Anchor Link **Does a GitHub Actions job need to install Xcode before it can build?** No. Xcode is part of the macOS runner image, so `xcodebuild`, `xcrun`, `swift`, and `simctl` are on PATH from the first step. A job only installs a toolchain when it needs an Xcode version or a simulator runtime the image does not carry, and the [Xcode image coverage page](/runners/macos/xcode-images) lists which image holds what. **Where do the Xcode bundles live on the runner?** Under `/Applications`, with version-suffixed bundle names set by the image, such as `Xcode_27.0.app` on the macOS 26 labels. Run `ls -1 /Applications | grep Xcode` once on the label you plan to use, then pin `DEVELOPER_DIR` to the `Contents/Developer` path inside that bundle. The [Xcode selection answer](/answers/how-do-i-select-an-xcode-version-in-github-actions) covers the selection mechanisms and their failure modes. **Is the preinstalled list the same as on GitHub-hosted macOS runners?** Yes. Each WarpBuild macOS image is built from the matching upstream GitHub macOS arm64 image and carries the same tooling, with one addition: the macOS 26 labels also ship Xcode 27.0 (build 27A266a) and its 27.0 simulator runtimes. The per-image readme links are on the [preinstalled software page](/runners/preinstalled-software) and in the [preinstalled software documentation](/docs/ci/preinstalled-software). Pick a label on the [macOS runner hub](/runners/macos), confirm the toolchain with the guard step above, and price your own macOS minutes on the [pricing page](/pricing). --- # Do Public Repositories Pay for Runners? | WarpBuild URL: https://www.warpbuild.com/answers/do-public-repositories-pay-for-runners Description: Public repositories pay nothing for standard GitHub-hosted minutes and pay a per-minute rate for managed runners, once an owner allows public repos. # Do Public Repositories Pay for Runners? Public repositories pay nothing for standard GitHub-hosted minutes and pay a per-minute rate for managed runners, once an owner allows public repos. ## [Answer](#answer)Copy Anchor Link A public repository pays nothing for minutes on standard GitHub-hosted runners, and it pays the machine supplier for everything else. WarpBuild runners can be enabled for public repositories with the documented setup, and the security considerations for pull requests from forks are documented alongside it on the same page. The enablement step comes first, because until it is done a job pointed at a `warp-` label in a public repository sits queued with no runner assigned. WarpBuild registers itself as a self-hosted runner in the `Default` runner group, id `1`, for your GitHub organization, and GitHub disables self-hosted runners in public repositories by default. Per the [public repositories documentation](/docs/ci/public-repos): 1. Open `https://github.com/organizations/[YOUR_ORG]/settings/actions/runner-groups/1`. 2. Check the box for `Allow public repositories`. GitHub Enterprise supports creation of multiple runner groups, and WarpBuild runners are added to the `Default` group, id `1`, there as well. So on Enterprise the box to check is on that group rather than on a group created for another fleet. Group membership and repository access are covered in [GitHub's runner group access documentation](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access). Once the box is checked, the repository is billed per minute for the jobs that land on `warp-` labels. The rates below come from the [pricing page](/pricing) and the [cloud runners catalog](/docs/ci/cloud-runners), checked on 2026-08-13. | Label | Shape | Per minute | A 10 minute job | 200 of those jobs a month | | --------------------------- | -------------- | ---------- | --------------- | ------------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | $0.04 | $8.00 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | $0.08 | $16.00 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $0.16 | $32.00 | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | $0.32 | $64.00 | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | $0.06 | $12.00 | | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | $0.012 | $0.12 | $24.00 | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016 | $0.16 | $32.00 | | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08 | $0.80 | $160.00 | Read the pipeline off that table one job at a time. The free side stays free: minutes and storage on standard GitHub-hosted runners cost a public repository nothing, per [GitHub's Actions billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13\. The [open source billing answer](/answers/are-github-actions-free-for-open-source) covers where that free allowance ends. ## [Detail](#detail)Copy Anchor Link ### [What a pull request from a fork can and cannot reach](#what-a-pull-request-from-a-fork-can-and-cannot-reach)Copy Anchor Link This is the question that decides whether a maintainer turns managed runners on at all, so it is worth stating in both directions. What the fork's code reaches: the runner machine it executes on, whatever that machine can reach on the network, and anything an earlier job left on its disk. A pull request is a proposal to run arbitrary code, and the runner runs it. What it does not reach on the default `pull_request` trigger: repository secrets are not passed to workflows triggered by a pull request from a fork, and the `GITHUB_TOKEN` for that run is read-only. Runs from first-time contributors also wait for maintainer approval before any job starts. All three behaviors are described in [GitHub's events reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13. The exception worth writing down: `pull_request_target` runs the workflow file from the base branch in the base repository's context, with secrets and a writable token available. A workflow that uses that trigger and then checks out the fork's head commit hands the fork exactly what the default trigger withholds. Public repositories should keep build and test work on `pull_request`. ### [Why the machine matters more than the trigger](#why-the-machine-matters-more-than-the-trigger)Copy Anchor Link [GitHub's runner group access documentation](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access) recommends only using self-hosted runners with private repositories, and the reason is the machine rather than the trigger. A persistent runner holds cloud credentials in its instance profile, caches on local disk, and network routes into a private environment. Contributor code that executes there inherits all of it. The problem sharpens on container fleets such as [actions-runner-controller](https://github.com/actions/actions-runner-controller), where workflows share a kernel with the host and node-level credentials sit close to the workload. WarpBuild answers this with the runner lifecycle, documented on the [security page](/docs/ci/security). Each runner runs in its own virtual machine, created on demand and destroyed after the build. The VMs are ephemeral and never reused. Each runner also gets its own encrypted storage volume, created and destroyed on the same cycle, and WarpBuild does not access or store build secrets. One contributor's job cannot read what another contributor's job left behind, because there is nothing left behind. The runners carry the same tools and versions as GitHub-hosted runners, so a public repository's workflow file stays portable between the two labels. ### [What a public project actually spends in a month](#what-a-public-project-actually-spends-in-a-month)Copy Anchor Link Here is a mid-sized open source project priced job by job, with the assumptions written out so you can substitute your own numbers. Assumptions: * 500 workflow runs in the month. * 350 of them come from fork pull requests and stay on `ubuntu-latest`. * 150 come from maintainer branches and merges to `main`, each running one 9 minute Linux job on `warp-ubuntu-latest-x64-8x`. * 20 release runs add a 12 minute macOS leg on `warp-macos-latest-arm64-6x`. The arithmetic, at the rates in the table above: * Fork pull requests: 350 runs on standard GitHub-hosted runners, **$0.00**. * Linux: 150 x 9 = 1,350 minutes at $0.016 = **$21.60**. * macOS: 20 x 12 = 240 minutes at $0.08 = **$19.20**. * Month total: **$40.80**. Two structural points sit underneath that total. A quiet month bills less by the same arithmetic. There is no minimum to hit and no allowance to forfeit, so a project that ships nothing in August pays for the jobs it ran in August. ### [Public repositories already running warp- labels](#public-repositories-already-running-warp--labels)Copy Anchor Link Public OSS repositories running `warp-` labels are citable evidence, and the workflow files are open to read. The [bitcoin/bitcoin GitHub Actions workflow](https://github.com/bitcoin/bitcoin/blob/master/.github/workflows/ci.yml) routes its Linux jobs across `warp-` labels sized per job, from `warp-ubuntu-latest-x64-2x` for lint up to `warp-ubuntu-latest-x64-16x` for the fuzz and MSan matrices, with compiler caches restored and saved between runs (checked on 2026-08-13). That per-job sizing is the pattern the table above is meant to support: cheap labels for lint and format, large labels for the matrices that actually saturate cores. The [open source projects guide](/guides/github-actions-for-open-source-projects) works through the routing side, including how to keep fork pull requests on the free pool while maintainer pushes take the faster labels. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does a public repository pay for GitHub Actions runners?](#does-a-public-repository-pay-for-github-actions-runners)Copy Anchor Link Not on standard GitHub-hosted runners, where minutes and storage are free for public repositories ([GitHub Actions billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13). Anything that runs on a larger GitHub-hosted runner or on a managed runner is billed. ### [How do I turn on managed runners for a public repository?](#how-do-i-turn-on-managed-runners-for-a-public-repository)Copy Anchor Link An organization owner opens the Default runner group, id `1`, at `github.com/organizations/[YOUR_ORG]/settings/actions/runner-groups/1` and checks `Allow public repositories`. GitHub Enterprise supports creation of multiple runner groups, and WarpBuild runners are added to the `Default` group there too, so that is still the group to change. Until the box is checked, a job pointed at a `warp-` label stays queued with no runner. The [public repositories documentation](/docs/ci/public-repos) has the screenshot. ### [What can a pull request from a fork reach on the runner?](#what-can-a-pull-request-from-a-fork-reach-on-the-runner)Copy Anchor Link The code in the pull request executes on the runner machine, so it reaches that machine, whatever the machine can reach on the network, and anything left on its disk. On the default `pull_request` trigger it does not receive repository secrets and its `GITHUB_TOKEN` is read-only ([GitHub events reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13). WarpBuild runs each job in its own virtual machine with its own encrypted volume, both destroyed when the job finishes, per the [security documentation](/docs/ci/security). Enable the Default runner group with the steps in the [public repositories documentation](/docs/ci/public-repos), price your own job mix against the table above on the [pricing page](/pricing), and start with $10 in free credits. --- # Do Remote Docker Builders Support Multi Platform Builds? | WarpBuild URL: https://www.warpbuild.com/answers/do-remote-docker-builders-support-multi-platform-builds Description: Yes. Each platform gets its own builder instance joined into one Buildx instance, so amd64 and arm64 compile natively and each bills as a separate session. # Do Remote Docker Builders Support Multi Platform Builds? Yes. Each platform gets its own builder instance joined into one Buildx instance, so amd64 and arm64 compile natively and each bills as a separate session. Yes. The documented shape is one builder instance per platform, joined into a single Buildx instance, so the amd64 layers compile on an amd64 machine and the arm64 layers compile on an arm64 machine with no instruction translation anywhere in the build ([Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13). A multi architecture build therefore opens one session per architecture against the same builder profile, and each session bills independently at that profile's per minute rate ([pricing page](/pricing), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link Two conditions have to hold. The builder profile needs both architectures enabled in the WarpBuild dashboard, and the build step needs `platforms: linux/amd64,linux/arm64`. With both in place the action assigns one builder instance per architecture and hands the buildx invocation a builder that carries both nodes ([Docker builders documentation](/docs/ci/docker-builders)). In GitHub Actions that is one step: Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push both platforms uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} platforms: linux/amd64,linux/arm64 profile-name: "multi-arch-builder" timeout: 600000 ``` `Warpbuilds/build-push-action@v6` is a drop-in replacement for `docker/build-push-action@v6` and sets up the remote builders itself, so the `docker/setup-buildx-action` step comes out of the workflow when it was only there to create a builder. The `api-key` input is needed only when the job runs on a runner that is not a WarpBuild runner; the job above uses `warp-ubuntu-latest-x64-4x`, so the action authenticates from the runner. `timeout` is the wait in milliseconds for the builders to become ready and defaults to 10 minutes. `Warpbuilds/bake-action@v6` takes the same `profile-name` input for workflows driven by a bake file ([Docker builders documentation](/docs/ci/docker-builders)). Profile sizes and the architectures each one can serve, from the same documentation, checked on 2026-08-13: | Builder profile | Per minute per session | Architectures | | -------------------------------- | ---------------------- | ------------------- | | 16 vCPU, 32 GB RAM, 100GB disk | $0.06 | amd64, arm64, multi | | 32 vCPU, 64 GB RAM, 200GB disk | $0.12 | amd64, arm64, multi | | 64 vCPU, 128 GB RAM, 200GB disk | $0.24 | amd64, arm64, multi | | 96 vCPU, 192 GB RAM, 600GB disk | $0.36 | amd64 only | | 96 vCPU, 192 GB RAM, 2TB disk | $0.52 | amd64 only | | 192 vCPU, 384 GB RAM, 600GB disk | $0.72 | amd64 only | | 192 vCPU, 384 GB RAM, 2TB disk | $0.88 | amd64 only | The last four rows are the constraint most teams meet first: arm64 and multi architecture profiles cap at 64 vCPU, so the 96 vCPU and 192 vCPU sizes are reachable only by an amd64-only profile. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface, and the runners underneath them cover Linux x64, Linux ARM64, macOS, and Windows ([cloud runners documentation](/docs/ci/cloud-runners)). ## [Detail](#detail)Copy Anchor Link ### [Joining the second builder yourself](#joining-the-second-builder-yourself)Copy Anchor Link Outside GitHub Actions, or inside a workflow that needs custom steps between assignment and build, the same shape is assembled by hand. A multi architecture profile returns more than one entry in `builder_instances` from the assign call, and each entry has its own host and its own TLS material. The first instance creates the buildx instance, the second is added to it with `--append` ([Docker builders documentation](/docs/ci/docker-builders)): Copy Text ``` docker buildx create --name "$BUILDER_NAME" \ --node "$BUILDER_ID" \ --driver remote \ --driver-opt "cacert=$CERT_DIR/ca.pem" \ --driver-opt "cert=$CERT_DIR/cert.pem" \ --driver-opt "key=$CERT_DIR/key.pem" \ --use \ tcp://$HOST docker buildx create --name "$BUILDER_NAME" \ --append \ --node "$BUILDER_ID_2" \ --driver remote \ --driver-opt "cacert=$CERT_DIR_2/ca.pem" \ --driver-opt "cert=$CERT_DIR_2/cert.pem" \ --driver-opt "key=$CERT_DIR_2/key.pem" \ --use \ tcp://$HOST_2 docker buildx build --builder "$BUILDER_NAME" \ --platform linux/amd64,linux/arm64 \ -t myimage:latest --push . ``` `$BUILDER_ID_2` and its request id come from `.builder_instances[1]` of the assign response, the same way the first instance comes from `.builder_instances[0]`. Two operational details travel with this route. Poll each instance for `status: ready` before appending it, because the certificates and host are only present once the instance is up. And complete every request id through the builder session endpoint when the build finishes: billing runs until the assigned builders are terminated, so a script that exits without completing its sessions keeps paying for both nodes. ### [Pricing a two platform build](#pricing-a-two-platform-build)Copy Anchor Link Billing is per session, measured from when the builder action starts until the job completes, and a multi architecture build opens one session per architecture, alive until the post-action steps are done. The arithmetic below holds the build stage at 6 minutes of wall clock and the GitHub Actions job at 7 minutes on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, so the only variable is the profile size. Rates from the [pricing page](/pricing) and the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13. | Builder profile | Sessions | Builder cost per build | Runner cost per build | Total per build | 300 builds per month | | --------------- | -------- | ---------------------- | --------------------- | --------------- | -------------------- | | 16 vCPU, 32 GB | 2 | 2 x 6 x $0.06 = $0.72 | $0.056 | $0.776 | $232.80 | | 32 vCPU, 64 GB | 2 | 2 x 6 x $0.12 = $1.44 | $0.056 | $1.496 | $448.80 | | 64 vCPU, 128 GB | 2 | 2 x 6 x $0.24 = $2.88 | $0.056 | $2.936 | $880.80 | Three things about that table are worth saying out loud. The runner and the builder are separate resources and both are billed, which is why the runner column is there at all. The session count is 2 rather than 1 because each architecture holds its own instance, so a second platform doubles the builder line while leaving the runner line alone. And the 6 minute assumption is yours to replace: time your own Dockerfile on one profile size, then multiply. Concurrency changes the shape of this in your favor. Jobs that overlap on the same profile share a session, billed from the first job's start to the last job's completion, and the documented guidance is roughly 8 vCPU and 16 GB of memory per concurrent build job. A 32 vCPU multi architecture profile therefore carries about four concurrent builds per architecture at the same $0.12 per minute per session that one build pays. ### [The failure when a platform has no native instance](#the-failure-when-a-platform-has-no-native-instance)Copy Anchor Link The common error is `exec format error`, and it means the builder profile does not have the correct architectures selected ([Docker builders documentation](/docs/ci/docker-builders)). No aarch64 instance was assigned, so the arm64 layers have no machine that can execute them, and BuildKit fails at the first `RUN` step of that platform's stage. In the job log it looks like this: Copy Text ``` > [linux/arm64 4/9] RUN apt-get update && apt-get install -y build-essential: 0.213 exec /bin/sh: exec format error ------ failed to solve: process "/bin/sh -c apt-get update && apt-get install -y build-essential" did not complete successfully: exit code: 255 ``` The tell is the platform prefix on the failing step. `[linux/amd64 ...]` steps complete normally in the same log while every `[linux/arm64 ...]` step dies at its first command, which points at the builder profile rather than at the Dockerfile. The fix is to enable both architectures on the profile and rerun; no workflow change is needed if `platforms` was already set. The workaround people reach for instead is `docker/setup-qemu-action`, which registers binfmt handlers so a single amd64 node executes the aarch64 stage under translation. That produces an image, and it moves the compile-heavy steps of the arm64 stage onto an interpreter, which Docker's own documentation names as the slowest of the three multi platform strategies it lists ([Docker multi-platform builds](https://docs.docker.com/build/building/multi-platform/), checked on 2026-08-13). It also introduces failures that read like Dockerfile bugs, most often `Illegal instruction` from libraries probing for CPU features the interpreter does not implement. Enabling arm64 on the profile removes the interpreter from the build entirely. ### [Cache behavior across the two nodes](#cache-behavior-across-the-two-nodes)Copy Anchor Link Each builder profile keeps a persistent layer cache, so `cache-to` and `cache-from` are not required and can come out of the workflow. Two facts govern how that cache behaves on a multi platform build. The cache is shared but eventually consistent, so layers written by one concurrent build may not be visible to another build running at the same time, and they are available to later builds after synchronization. And the cache has a TTL of 10 days: a profile that goes unused for longer than that is reset automatically, and the next build after the reset is a cold one ([Docker builders documentation](/docs/ci/docker-builders)). For the full picture of when a single multi platform builder beats splitting the build across native runners, see [multi platform Docker builds on native runners](/guides/multi-platform-docker-builds) and the [Docker builds on GitHub Actions hub](/solutions/docker-builds-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does one builder profile handle both amd64 and arm64?](#does-one-builder-profile-handle-both-amd64-and-arm64)Copy Anchor Link Yes, when both architectures are enabled on the profile. Each architecture then runs on its own builder instance, so a two platform build opens one session per architecture on that profile and each session bills independently until the post-action steps finish ([Docker builders documentation](/docs/ci/docker-builders)). Profile sizes and rates are on the [multi platform Docker builders page](/runners/docker-builders/multi-platform). ### [What is the largest multi platform builder profile?](#what-is-the-largest-multi-platform-builder-profile)Copy Anchor Link 64 vCPU, 128 GB RAM, 200GB disk at $0.24 per minute per session. The 96 vCPU and 192 vCPU profiles are amd64 only, so arm64 and multi architecture profiles cap at 64 vCPU ([Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13). The [pricing page](/pricing) carries the same rates next to the per-minute rates for runners. ### [Why does the arm64 half fail with exec format error?](#why-does-the-arm64-half-fail-with-exec-format-error)Copy Anchor Link The builder profile does not have arm64 enabled, so no aarch64 instance is assigned and the arm64 layers have nowhere native to execute. Enable both architectures on the profile, then set `platforms: linux/amd64,linux/arm64` on the build step. If you would rather build each architecture on a runner of that architecture and merge the results, [how to push a manifest list from GitHub Actions](/answers/how-do-i-push-a-manifest-list-from-github-actions) covers that shape end to end. --- # Do Runner Groups Limit Concurrency? | WarpBuild URL: https://www.warpbuild.com/answers/do-runner-groups-limit-concurrency Description: No. A runner group decides which repositories may claim a set of runners, so a bad group leaves jobs unclaimed with an empty log rather than merely slow. # Do Runner Groups Limit Concurrency? No. A runner group decides which repositories may claim a set of runners, so a bad group leaves jobs unclaimed with an empty log rather than merely slow. ## [Answer](#answer)Copy Anchor Link No. A runner group is an access boundary around a set of self-hosted runners: it decides which repositories may use them and, on GitHub Enterprise, which workflow paths, branches, tags, and commit SHAs may claim them, and no field in that access list holds a parallel job count, so adding or removing a group changes who may run while leaving the number of simultaneous jobs alone. This access model is the one documented in the runner group checks of the [common issues guide](/docs/ci/common-issues). The question keeps coming up because both problems land on the same screen. A misconfigured group shows up as jobs that never get claimed: the run sits queued, the log view stays empty, and it stays empty for as long as you let it sit, because no runner ever accepts the job and nothing is there to write. A capacity problem shows up as jobs that start late and then run normally, and it drains as other jobs finish. Duration is the tell. A group block survives an idle weekend, since the passage of time changes nothing about whether the repository appears on the group's allowed list. On WarpBuild runners the capacity half of that pair is rarely the answer. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and the [cloud runners documentation](/docs/ci/cloud-runners) carries the exact scope: features that are Generally Available support unlimited concurrency on Linux and Windows runners, features in beta may carry limits, and macOS concurrency runs on a per-organization quota that [support@warpbuild.com](mailto:support@warpbuild.com) raises on request. A fresh virtual machine is provisioned per job, so there is no fixed pool to exhaust. The three mechanisms that do cap parallel jobs are covered in the guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits): the `concurrency` key you wrote in workflow YAML, the concurrent job ceiling attached to your GitHub plan, and the capacity of the runner pool behind your `runs-on` labels. A runner group sits in none of those three. ## [Detail](#detail)Copy Anchor Link ### [The two documented group checks](#the-two-documented-group-checks)Copy Anchor Link The [common issues guide](/docs/ci/common-issues) splits the checks by which group your runners registered into. Work them in this order. | Check | Where it lives | What the run log shows when it fails | | ------------------------------------------------ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | | Bot has access to the repository | app.warpbuild.com/settings/account, Configure Runners on the GitHub connection | Every job in that repository stays queued with an empty log | | Public repositories enabled on the default group | GitHub org settings, Actions, Runner groups, group id 1, Allow public repositories | Every job in every public repository stays queued, private repositories are unaffected | | Repository access on a non-default group | GitHub org settings, Actions, Runner groups, the group selected in the WarpBuild dashboard | Jobs in repositories missing from the access list stay queued with an empty log | | Workflow restriction on a non-default group | The same runner group settings page | One workflow file, branch, or tag stays queued while the rest of the repository runs normally | | runs-on label typo | The workflow file itself | The job stays queued and no group setting explains it | WarpBuild registers as a self-hosted runner in the Default runner group (id `1`) unless the organization selects another group. Confirm which group is in play at `https://app.warpbuild.com/ci` under Runners, then open the matching group in GitHub organization settings. Workflow restrictions are written as a full path with a ref, for example `monalisa/octocat/.github/workflows/cd.yaml@refs/heads/main`, which admits `cd.yaml` on `main` and queues every other workflow. Each entry is an allow-list rule, so the first entry you add excludes everything you did not list. The [runner group glossary entry](/glossary/runner-group) holds the plain definition if someone joining the thread needs it. ### [The repository access model, step by step](#the-repository-access-model-step-by-step)Copy Anchor Link 1. Add the repository to the GitHub connection in the WarpBuild dashboard. A repository added after onboarding is the most common miss. 2. If the repository is public, check `Allow public repositories` on the runner group. GitHub disables self-hosted runners in public repositories by default, including managed ones. 3. If your organization runs a non-default group, open that group in GitHub organization settings and confirm the repository appears on its access list. A group set to selected repositories blocks every repository that was never added to it. 4. Read the workflow restriction list on the same page. An empty list admits every workflow. A list with one entry admits only that entry. 5. Copy the `runs-on` label from the [pricing page](/pricing) rather than typing it, so a spelling slip does not imitate a group block. Steps 2 through 4 are the ones that change per repository over time, which is why [restricting which repositories use your runners](/answers/can-i-restrict-which-repositories-use-my-runners) belongs in an onboarding checklist rather than in incident response. The full walkthrough with screenshots lives in the guide to [using runner groups with GitHub Actions](/guides/github-actions-runner-groups). ### [Reading the difference in Queue Timings](#reading-the-difference-in-queue-timings)Copy Anchor Link The Queue Timings report breaks queue wait down per runner label and stack, with Run Count, Queue Time P75, and Queue Time P90 in the metrics table, a daily average chart, filters on runner label and stack, and CSV export. Every row needs a job that started, which is what makes the report a clean separator. | What the report shows | Reading | | ----------------------------------------------------------------- | ----------------------------------------------------------------------- | | Run Count flat on a label while the Actions tab lists queued jobs | Group access or label mismatch, since no job on that label ever started | | Run Count climbing and P90 climbing with it | Capacity for that label | | P90 spikes during working hours and settles overnight | Contention for a shared ceiling, usually the GitHub plan ceiling | | P90 near zero on every label while pull requests still feel slow | Queue is healthy and job duration is the problem | Two rows separate the group question from everything else. A group block cannot raise a percentile, because the jobs it blocks never produce a measurement. Anything that moves P90 is capacity or contention. ### [The probe, and what the minutes cost](#the-probe-and-what-the-minutes-cost)Copy Anchor Link Put a cheap identity step first in the job. When it never appears in the log, the job was never claimed, and the cause sits in the group rather than inside the workflow. Copy Text ``` name: ci on: [pull_request] jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - name: Print runner identity run: echo "name=$RUNNER_NAME os=$RUNNER_OS arch=$RUNNER_ARCH" - uses: actions/checkout@v4 - run: npm ci - run: npm test ``` A group restriction applies to every label inside the group at once, so splitting the workflow across platforms routes around nothing. Shapes and rates below come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Runner label | OS | vCPU | RAM | Rate per minute | | --------------------------- | ------------------- | ---- | ----- | --------------- | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16 GB | $0.006 | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14 GB | $0.08 | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16 GB | $0.016 | Blocked jobs bill nothing, which is why a group problem hides for weeks. Once access is granted, `warp-ubuntu-latest-x64-4x` at 4 vCPU and 16 GB costs $0.008 per minute against $0.012 per minute for GitHub's 4-core Linux larger runner at the same shape, which is 33 percent lower list price ([GitHub billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). ## [Related Questions](#related-questions)Copy Anchor Link ### [Can a runner group cap the number of parallel jobs?](#can-a-runner-group-cap-the-number-of-parallel-jobs)Copy Anchor Link No. A runner group carries a repository access list and, on GitHub Enterprise, workflow path, branch, tag, and sha restrictions. None of those settings holds a parallel job count. The number of jobs that run at once comes from the `concurrency` key in your YAML, the concurrent job ceiling on your GitHub plan, and the capacity behind the `runs-on` label, all three of which are covered in the guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits). ### [What does a runner group block look like in the run log?](#what-does-a-runner-group-block-look-like-in-the-run-log)Copy Anchor Link The job stays queued and the log view stays empty for as long as you let it sit, because no runner ever claims the job and there is nothing to write. A capacity wait produces the opposite shape: the log fills in once a slot frees, usually within minutes, and the wait shows up as the gap between `created_at` and `started_at` on the job object. The [common issues guide](/docs/ci/common-issues) lists the group settings to check when the log stays empty. ### [How do I tell a group block from a capacity problem in Queue Timings?](#how-do-i-tell-a-group-block-from-a-capacity-problem-in-queue-timings)Copy Anchor Link Queue Timings reports Run Count, Queue Time P75, and Queue Time P90 for each runner label and stack combination. A blocked group never records a start, so the row for that label shows a flat Run Count while the Actions tab shows queued jobs. A capacity wait shows a Run Count that keeps climbing with P90 climbing alongside it. The [runner group glossary entry](/glossary/runner-group) and the guide to [using runner groups with GitHub Actions](/guides/github-actions-runner-groups) cover the settings behind each reading. Check per-minute rates for the labels above on the [pricing page](/pricing), then confirm your group change with the identity probe in a single pull request. --- # Do Snapshot Runners Replace the Cache? | WarpBuild URL: https://www.warpbuild.com/answers/do-snapshot-runners-replace-actions-cache Description: No. A cache action moves selected directories over the network and a snapshot boots a prepared machine. When each one fits, what each costs, how to run both. # Do Snapshot Runners Replace the Cache? No. A cache action moves selected directories over the network and a snapshot boots a prepared machine. When each one fits, what each costs, how to run both. No. A cache action and a snapshot runner solve different problems: a cache moves selected directories in and out of a clean machine over the network, while a [snapshot runner](/runners/snapshot) boots the job from a disk image an earlier run left behind. Many GitHub Actions pipelines run both, with the snapshot carrying the toolchain and the cache carrying the dependencies derived from a lockfile. ## [Answer](#answer)Copy Anchor Link The two mechanisms differ in what they address and in what they carry. A cache action names paths. You give it a `path` list and a `key`, it uploads those paths at the end of a run and downloads them at the start of the next one, and everything outside the list stays as the base image left it. The [WarpBuild caching documentation](/docs/ci/features/caching) covers the action inputs, and GitHub's [dependency caching reference](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows) covers how keys and `restore-keys` resolve. A snapshot names a machine. You append `snapshot.key=` to the `runs-on` label, the job boots from the disk saved under that alias, and the apt packages, container images in the local Docker store, compiler state, and generated outputs a previous run left anywhere on that disk are present at step one. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) is the reference for the labels, the save action, and the 15-day snapshot lifetime. That difference produces a workload split. | Workload | Cache action | Snapshot runner | | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | Many small dependency sets keyed on a lockfile hash | Fits. Key changes with the lockfile, so a hit is exact | Poor fit. The alias updates when the saving workflow runs, so it lags the lockfile | | One large prepared environment: apt packages, toolchains, pulled images, warmed compiler state | Partial. Only paths you list are restored | Fits. The whole disk comes back | | State that changes on most pull requests | Fits | Poor fit. The alias would need a save on every run | | State that changes only when the toolchain changes | Works, with a hand-maintained path list | Fits. Save on merges to the default branch | | Windows, macOS, or BYOC jobs | Mixed. Runs on macOS and BYOC. Not supported on WarpBuild Windows runners | Unavailable. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners | Platform scope narrows both mechanisms in that last row, though not by the same amount. Snapshot labels applied to a runner type that does not support them are silently ignored, so the job runs with no snapshot behavior and no error. The cache action's restriction is narrower: the [WarpBuild caching documentation](/docs/ci/features/caching) lists WarpBuild Windows runners as the one unsupported platform. ## [Detail](#detail)Copy Anchor Link ### [Restore time: archive transfer against machine boot](#restore-time-archive-transfer-against-machine-boot)Copy Anchor Link A cache restore is a network transfer followed by a decompress into the named paths. Its duration tracks the size of the archive, so a pull request that changes one file still pays the full restore for a multi-gigabyte dependency tree, and the save on the way out pays a compress and an upload. A snapshot boot replaces the base image the machine starts from. Boot times for snapshot runners can be slower than the default runners and take 45 to 60 seconds, per the [snapshot runners documentation](/docs/ci/features/snapshot-runners). That figure does not move with the amount of state on the disk, which is the whole shape of the comparison: transfer cost grows with the payload, boot cost is flat. The more setup work a machine holds, the better the flat number looks. The corollary matters for small payloads. A 200 MB package tree restores quickly and reconciles cleanly against a lockfile, and paying a machine boot to carry it is the wrong trade. [Warm cache](/glossary/warm-cache) covers what keeps a cache resolving in the first place, and the [persistent caches guide](/guides/persistent-caches-github-actions) walks the full decision. ### [What each mechanism costs](#what-each-mechanism-costs)Copy Anchor Link Both mechanisms bill on top of the per-minute rate of the runner you selected. Rates below are from the [pricing page](/pricing), checked on 2026-08-13. | Line item | Rate | | ---------------------- | ------------------------ | | Cache storage | $0.20 per GB-month | | Cache write or restore | $0.0001 per operation | | Snapshot restore | $0.04 per job | | Snapshot storage | $0.025 per snapshot-hour | Put those on one repository month to see the scale. Assume 880 workflow runs in the month, an 8 GB cache entry held for the month, and one snapshot alias alive for the full 720 hours. | Route | Operations or restores | Storage | Total | | --------------- | ---------------------------------------------- | ---------------------------- | ------ | | Cache action | 880 restores plus 880 saves at $0.0001 = $0.18 | 8 GB at $0.20 = $1.60 | $1.78 | | Snapshot runner | 880 restores at $0.04 = $35.20 | 720 hours at $0.025 = $18.00 | $53.20 | The snapshot fees are the larger line, so the question is whether the snapshot removes more runner minutes than it costs. Divide the $0.04 restore fee by the runner rate to see what it buys: 10 minutes on `warp-ubuntu-latest-x64-2x` at $0.004 per minute, 5 minutes on `warp-ubuntu-latest-x64-4x` at $0.008, 2.5 minutes on `warp-ubuntu-latest-x64-8x` at $0.016, and 1.25 minutes on `warp-ubuntu-latest-x64-16x` at $0.032\. A snapshot that removes 20 minutes of apt and toolchain installation clears that bar on every size. A snapshot that removes 90 seconds clears it only on the larger sizes. ### [A workflow that runs both](#a-workflow-that-runs-both)Copy Anchor Link The split below gives each mechanism the state it handles well. The snapshot carries system packages and the toolchain, refreshed on merges to the default branch. The cache carries `node_modules`, keyed on the lockfile hash so it tracks dependency changes per commit. Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-4x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-4x;snapshot.key=toolchain-main' }} steps: - uses: actions/checkout@v5 - name: Install system toolchain run: | if [ -z "$WARPBUILD_SNAPSHOT_KEY" ]; then sudo apt-get update sudo apt-get install -y libvips-dev protobuf-compiler fi - name: Restore dependencies uses: WarpBuilds/cache@v1 with: path: node_modules key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} restore-keys: ${{ runner.os }}-node- - name: Install dependencies run: npm ci - name: Build and test run: | npm run build npm test - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "toolchain-main" fail-on-error: false ``` Three details make the combination work. The `WARPBUILD_SNAPSHOT_KEY` environment variable is set on a machine that booted from a snapshot, so the guard skips the apt step on restored runners and runs it on the clean default-branch build that produces the image. The cleanup step removes credentials before the save, which the documentation recommends because a snapshot disk carries whatever files were on it. `git clean -ffdx` also removes gitignored paths such as `node_modules`, which suits this layout: those files belong to the cache, and leaving them out keeps the snapshot to the toolchain. `fail-on-error: false` keeps a failed capture from failing the merge build. The next run boots from the previous image or from the base image, and the pipeline stays green. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can a snapshot runner replace my cache action entirely?](#can-a-snapshot-runner-replace-my-cache-action-entirely)Copy Anchor Link Only when every piece of state you carry forward lives on the runner disk and changes at the same rate as the snapshot alias. Dependency trees that move with a lockfile on most pull requests belong in a cache action keyed on the lockfile hash, because a cache key changes per commit while a snapshot alias changes when the workflow that saves it runs. The [persistent caches guide](/guides/persistent-caches-github-actions) covers the boundary case by case. ### [Which restores faster, a cache entry or a snapshot?](#which-restores-faster-a-cache-entry-or-a-snapshot)Copy Anchor Link A cache restore is a network transfer plus a decompress, so its duration scales with the size of the archive. A snapshot boot is documented at 45 to 60 seconds regardless of how much the disk holds, so the larger the prepared environment, the better the snapshot compares. The [snapshot runners hub](/runners/snapshot) lists the Ubuntu labels that accept the feature. ### [What do a cache and a snapshot each cost on WarpBuild?](#what-do-a-cache-and-a-snapshot-each-cost-on-warpbuild)Copy Anchor Link WarpBuild cache storage is $0.20 per GB-month and each cache write or restore is $0.0001\. Snapshot restore is $0.04 per job and snapshot storage is $0.025 per snapshot-hour. Both sit on top of the per-minute rate of the runner you selected, from the [pricing page](/pricing), checked on 2026-08-13\. [Warm cache](/glossary/warm-cache) defines the term the first line item is billed against. --- # Do Snapshot Runners Work on macOS? | WarpBuild URL: https://www.warpbuild.com/answers/do-snapshots-work-on-macos-runners Description: No. Snapshot runners are documented for Linux x64 and Linux ARM64 only, so a macOS job keeps warm state through cached DerivedData and package directories. # Do Snapshot Runners Work on macOS? No. Snapshot runners are documented for Linux x64 and Linux ARM64 only, so a macOS job keeps warm state through cached DerivedData and package directories. No. Snapshot runners are documented for Linux x64 and Linux ARM64 runners, and macOS runners are listed as an unsupported platform, so an Xcode job cannot boot from a saved machine image. A macOS job keeps warm state the other way, by caching the directories that hold compiled output and resolved packages. ## [Answer](#answer)Copy Anchor Link A snapshot runner boots a GitHub Actions job from a saved image of an earlier run's virtual machine, so packages, dependency trees, and build output an earlier run left on disk are present at step one. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) states the platform boundary in its prerequisites: snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, and BYOC runners, Windows runners, and macOS runners are not supported. The feature entry in the runner catalog records the same two platforms, `linux-x64` and `linux-arm64`. The failure mode matters more than the boundary. Snapshot labels on an unsupported runner type are silently ignored, so a macOS job with `snapshot.key=ios-main` in its `runs-on` value starts, runs, and passes with no snapshot behavior and no message saying why. A workflow that was written expecting warm state gets cold-boot durations and no error to explain them. Labels, sizes, and rates for the macOS fleet are on the [macOS runners page](/runners/macos), and the [snapshot runners hub](/runners/snapshot) covers the Ubuntu labels that accept the feature. ## [Detail](#detail)Copy Anchor Link ### [Platform support row from the feature catalog](#platform-support-row-from-the-feature-catalog)Copy Anchor Link | Runner platform | Example label | Snapshot runners | | ------------------------- | --------------------------- | -------------------------- | | Linux x64 | warp-ubuntu-latest-x64-4x | Supported | | Linux ARM64 | warp-ubuntu-latest-arm64-4x | Supported | | macOS | warp-macos-latest-arm64-6x | Unsupported, label ignored | | Windows | warp-windows-latest-x64-4x | Unsupported, label ignored | | BYOC runners on any cloud | customer-defined | Unsupported, label ignored | The two supported rows come from the snapshot feature entry in the runner catalog, and the unsupported rows come from the prerequisites section of the [snapshot runners documentation](/docs/ci/features/snapshot-runners). Labels for every platform are listed in the [cloud runners documentation](/docs/ci/cloud-runners). ### [What a macOS job uses instead](#what-a-macos-job-uses-instead)Copy Anchor Link The mechanism that carries state onto a macOS runner is the cache. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` that takes the same `path`, `key`, and `restore-keys` inputs and returns the same `cache-hit` output, and the [caching documentation](/docs/ci/features/caching) records that the cache is available on all WarpBuild runners. Four paths carry most of the warm state in an Xcode job: * The DerivedData tree, redirected with `xcodebuild -derivedDataPath` to a location inside `${{ github.workspace }}` so the absolute path is identical on the run that saves and the run that restores. Restore `Build/Products` and `Build/Intermediates.noindex`, and leave out `ModuleCache.noindex`, `Index.noindex`, and `Logs`. * The SwiftPM checkout directory, pinned with `xcodebuild -clonedSourcePackagesDirPath` rather than left at its default under `~/Library/Developer/Xcode/DerivedData/*/SourcePackages`. * The shared SwiftPM directory at `~/Library/Caches/org.swift.swiftpm`, which holds repository clones and parsed manifests reused across packages on the machine. * For a CocoaPods project, the `Pods` directory in the checkout plus `~/Library/Caches/CocoaPods` and `~/.cocoapods/repos`. Two constraints apply to any of those entries. A cache entry carries a version derived from the runner operating system and the cached paths, so an entry written on `warp-macos-14-arm64-6x` does not restore on `warp-ubuntu-latest-x64-4x`. And the Xcode version belongs in the key, because module output does not survive a compiler change and a restore that the compiler later rejects costs more than a miss. The [Xcode DerivedData cache guide](/guides/xcode-derived-data-cache-github-actions) works through the key design and the save-from-main pattern in full. ### [An iOS pipeline that also has Linux jobs](#an-ios-pipeline-that-also-has-linux-jobs)Copy Anchor Link Most iOS repositories run more than the app build: an API, a set of integration tests, container images. Those jobs sit on Linux runners, where snapshot runners do apply, so one workflow ends up running both mechanisms side by side. Snapshot labels live in the `runs-on` value of each individual job, so the split is per job and needs no separate workflow file. Copy Text ``` name: mobile on: push: branches: [main] pull_request: jobs: ios: runs-on: warp-macos-latest-arm64-6x steps: - uses: actions/checkout@v5 - name: Record toolchain version id: toolchain run: echo "xcode=$(xcodebuild -version | tr '\n' '-')" >> "$GITHUB_OUTPUT" - name: Restore DerivedData and SwiftPM checkouts uses: WarpBuilds/cache/restore@v1 with: path: | ${{ github.workspace }}/.derived-data/Build ${{ github.workspace }}/.spm-checkouts ~/Library/Caches/org.swift.swiftpm key: ios-${{ steps.toolchain.outputs.xcode }}-${{ hashFiles('**/Package.resolved') }}-${{ github.sha }} restore-keys: | ios-${{ steps.toolchain.outputs.xcode }}-${{ hashFiles('**/Package.resolved') }}- - name: Build and test run: | xcodebuild test \ -scheme App \ -destination "platform=iOS Simulator,name=iPhone 16" \ -derivedDataPath "${{ github.workspace }}/.derived-data" \ -clonedSourcePackagesDirPath "${{ github.workspace }}/.spm-checkouts" - name: Save from main only if: github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: | ${{ github.workspace }}/.derived-data/Build ${{ github.workspace }}/.spm-checkouts ~/Library/Caches/org.swift.swiftpm key: ios-${{ steps.toolchain.outputs.xcode }}-${{ hashFiles('**/Package.resolved') }}-${{ github.sha }} api: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-4x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-4x;snapshot.key=api-main' }} steps: - uses: actions/checkout@v5 - name: Install system packages run: | if [ -z "$WARPBUILD_SNAPSHOT_KEY" ]; then sudo apt-get update sudo apt-get install -y protobuf-compiler fi - name: Test run: ./scripts/test.sh - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - uses: WarpBuilds/snapshot-save@v1 if: github.ref == 'refs/heads/main' with: alias: "api-main" fail-on-error: false ``` Three details in that file are worth calling out. `WARPBUILD_SNAPSHOT_KEY` is set only on a machine that booted from a snapshot, so the guard around `apt-get` is what turns a warm boot into saved minutes. The credential cleanup step runs before every save, because a snapshot carries the whole disk and any token left in a home directory travels with it. And the macOS job has no equivalent guard, because a cache restore reports its result through `cache-hit` rather than through an environment variable. The expiry windows differ too. Snapshots are deleted after 15 days, so an alias that no workflow refreshes stops resolving and the next job boots from the base image and runs normally. Cache entries follow the repository eviction rules in [GitHub's caching documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/cache-dependencies), which is why the save step above is guarded on the default branch: pull request jobs that each write a DerivedData entry evict the entry everything else restores from. ### [What each mechanism costs](#what-each-mechanism-costs)Copy Anchor Link | Mechanism | Line item | Rate | | ---------------------------------- | --------------------------- | ------------------------ | | Snapshot runner, Linux only | Snapshot restore | $0.04 per job | | Snapshot runner, Linux only | Snapshot storage | $0.025 per snapshot-hour | | Cache, all runners including macOS | Cache storage | $0.20 per GB-month | | Cache, all runners including macOS | Cache write or restore | $0.0001 per operation | | macOS runner, 6 vCPU | warp-macos-latest-arm64-6x | $0.08 per minute | | macOS runner, 12 vCPU | warp-macos-latest-arm64-12x | $0.16 per minute | Rates come from the [pricing page](/pricing), checked on 2026-08-13, and both cache lines are free on BYOC. On the macOS side the arithmetic is easy to run for a repository with 880 monthly workflow runs, 40 per weekday across 22 weekdays: a 4 GB DerivedData entry held for a month is $0.80 in storage, and 880 runs that each restore and save it is $0.176 in operations, against $0.08 per minute of runner time. One minute of compilation saved per run on the 6 vCPU label is $70.40 per month, so the cache pays for itself long before the storage line becomes visible. ## [Related Questions](#related-questions)Copy Anchor Link ### [What happens if I put a snapshot label on a macOS runner?](#what-happens-if-i-put-a-snapshot-label-on-a-macos-runner)Copy Anchor Link Nothing visible. The `snapshot.enabled=true` and `snapshot.key` labels are silently ignored on unsupported runner types, so the job runs normally with no snapshot behavior, no warning, and no error in the log. The only way to tell a working snapshot job from an ignored label is to check whether `WARPBUILD_SNAPSHOT_KEY` is set, as the [snapshot runners documentation](/docs/ci/features/snapshot-runners) describes. ### [What keeps an Xcode job warm on macOS instead?](#what-keeps-an-xcode-job-warm-on-macos-instead)Copy Anchor Link A cache entry. Point `xcodebuild` at an explicit `-derivedDataPath` and `-clonedSourcePackagesDirPath` inside the workspace, then restore and save those paths plus `~/Library/Caches/org.swift.swiftpm` with `WarpBuilds/cache@v1`, which is a drop-in replacement for `actions/cache@v4` and is available on all WarpBuild runners. The [Xcode DerivedData cache guide](/guides/xcode-derived-data-cache-github-actions) covers which subdirectories are safe to restore. ### [Can one pipeline use snapshot runners for its Linux jobs and caching for its macOS jobs?](#can-one-pipeline-use-snapshot-runners-for-its-linux-jobs-and-caching-for-its-macos-jobs)Copy Anchor Link Yes. Snapshot labels live in the `runs-on` value of each job, so a Linux job can carry `snapshot.key` while the macOS job in the same workflow uses a plain macOS label and a cache step. The two mechanisms share nothing and are billed separately, as the rate table above shows. The [snapshot runners hub](/runners/snapshot) lists the Ubuntu labels that accept the feature, and the [macOS runners page](/runners/macos) lists the five macOS labels and their sizes. --- # Do Windows Runners Support Nested Virtualization? | WarpBuild URL: https://www.warpbuild.com/answers/do-windows-github-actions-runners-support-nested-virtualization Description: No. WarpBuild Windows runners have no /dev/kvm. Nested virtualization is documented for cloud Linux x86-64 runners through a dynamic runs-on label. # Do Windows Runners Support Nested Virtualization? No. WarpBuild Windows runners have no /dev/kvm. Nested virtualization is documented for cloud Linux x86-64 runners through a dynamic runs-on label. No. Nested virtualization is documented for cloud Linux x86-64 runners through the `nested-virtualization.enabled=true` dynamic label, Windows runners are recorded as unsupported in the support matrix ([nested virtualization documentation](/docs/ci/features/nested-virtualization), checked on 2026-08-13), and that matrix is what to read before you design a workflow around `/dev/kvm`, because an unsupported label is accepted and ignored rather than rejected, so the job starts and the KVM-dependent step is what breaks. ## [Answer](#answer)Copy Anchor Link The support matrix covers every runner class by platform and by hosted or BYOC placement. Every row below comes from the [nested virtualization documentation](/docs/ci/features/nested-virtualization), checked on 2026-08-13. | Runner class | Nested virtualization | How it is turned on | | --------------------------- | --------------------- | ---------------------------------------------------------------- | | Cloud: Linux x86-64 | Yes | nested-virtualization.enabled=true appended to the runs-on label | | Cloud: Linux ARM64 | No | Not available | | Cloud: Windows | No | Not available | | Cloud: macOS | No | Not available | | BYOC on AWS: Linux x86-64 | Yes | Automatic when every instance type in the runner set supports it | | BYOC on GCP: Linux x86-64 | Yes | Automatic when every instance type in the runner set supports it | | BYOC on Azure: Linux x86-64 | Yes | Automatic on supported VM sizes | | BYOC: ARM64 runner sets | No | Not available | | BYOC: Windows runner sets | No | Not available | Two rows carry the practical answer for a Windows pipeline. Cloud Windows runners have no `/dev/kvm`, and BYOC Windows runner sets do not change that, so a KVM-dependent step has to run on a Linux x86-64 machine wherever the rest of the workflow lives. The expectation usually comes from GitHub's own changelog, which announced hardware-accelerated Android virtualization on GitHub-hosted Windows and Linux larger runners ([GitHub changelog, February 23, 2023](https://github.blog/changelog/2023-02-23-hardware-accelerated-android-virtualization-on-actions-windows-and-linux-larger-hosted-runners/)). WarpBuild exposes that capability on the Linux x86-64 line. Here is the Linux job that needs `/dev/kvm`. The dynamic label goes after a semicolon on the same `runs-on` value, and the permissions step runs before the emulator action: Copy Text ``` name: instrumentation-tests on: pull_request: jobs: android-tests: runs-on: warp-ubuntu-latest-x64-8x;nested-virtualization.enabled=true steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 arch: x86_64 script: ./gradlew connectedDebugAndroidTest ``` The permissions step is required on GitHub-hosted runners too, because the default device permissions on `/dev/kvm` are `crw-rw---- root:kvm` and the `runner` user is outside that group ([nested virtualization documentation](/docs/ci/features/nested-virtualization)). Skip it and [reactivecircus/android-emulator-runner](https://github.com/ReactiveCircus/android-emulator-runner#running-hardware-accelerated-emulators-on-linux-runners) logs `ProbeKVM: This user doesn't have permissions to use KVM (/dev/kvm).`, then launches with `-accel off` and runs the suite in software emulation. ## [Detail](#detail)Copy Anchor Link ### [When the workload cannot move to Linux](#when-the-workload-cannot-move-to-linux)Copy Anchor Link Split the workflow by what each step actually needs. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so the Windows compile and the KVM leg sit in one workflow file under one account. A .NET service with an Android client is the common shape: the server build and unit tests stay on a `warp-windows-` label, and the instrumentation suite moves to a Linux x86-64 job with the dynamic label. Artifacts travel between the two jobs through `actions/upload-artifact` and `actions/download-artifact`, and `needs:` orders them. Two Windows workloads look like nested virtualization and are something else. Hyper-V isolation for Windows containers does need it, and the fix is matching the base image tag to the host release rather than isolating the container ([Microsoft container version compatibility reference](https://learn.microsoft.com/en-us/virtualization/windowscontainers/deploy-containers/version-compatibility), checked on 2026-08-13). WSL2 also needs it, so a build script that shells into WSL belongs on a Linux runner directly. Where the workload genuinely cannot leave Windows, such as a driver test that needs a Windows guest, no runner class in the catalog provides it and the test stays on hardware you operate. ### [What the split costs](#what-the-split-costs)Copy Anchor Link Take a repository that merges 500 pull requests a month, with a 10 minute Windows build and test leg and a 12 minute Android instrumentation leg. Rates come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. * Windows leg on `warp-windows-latest-x64-8x` at $0.032 per minute: 500 x 10 x $0.032 = **$160.00 per month**. * Instrumentation leg on `warp-ubuntu-latest-x64-8x` at $0.016 per minute: 500 x 12 x $0.016 = **$96.00 per month**. * Total: **$256.00 per month**, with the two legs running in parallel so wall clock is set by the longer one. Per-minute rates for every label are on the [pricing page](/pricing). When the emulator leg fails and the logs do not explain why, the Action Debugger opens a shell on the runner while the job is held, which is where `ls -l /dev/kvm` settles whether the device is present. ## [Related Questions](#related-questions)Copy Anchor Link **Can I run Android emulator tests on a Windows GitHub Actions runner?** No. Send that job to a Linux x86-64 label with `nested-virtualization.enabled=true` and the KVM permissions step, as shown above. The [runners that expose /dev/kvm](/runners/nested-virtualization) page lists the sizes available for it. **What happens if I add `nested-virtualization.enabled=true` to a Windows label?** The label is silently ignored and the job runs normally without nested virtualization ([nested virtualization documentation](/docs/ci/features/nested-virtualization)). No error is raised at queue time, so treat a passing job as no evidence that KVM was present. **Do BYOC Windows runner sets get nested virtualization?** No. On BYOC it is automatic on Linux x86-64 runner sets on AWS, GCP, and Azure when every selected instance type supports it, and a mixed instance list disables it to keep behavior consistent across jobs. Windows runner sets stay outside that support. **Can I use Hyper-V isolation for Windows containers on a WarpBuild Windows runner?** No. Match the base image tag to the host release instead: `ltsc2022` images on the `warp-windows-latest-x64` labels and `ltsc2025` images on the `warp-windows-2025-x64` labels. The full label list with sizes and rates is on the [Windows runner page](/runners/windows). Check the platform rows on the [Windows runner page](/runners/windows), read the term itself in the [nested virtualization glossary entry](/glossary/nested-virtualization), and price the Linux side of the split against your own job minutes on the [pricing page](/pricing). --- # Does BYOC Support Custom VM Images? | WarpBuild URL: https://www.warpbuild.com/answers/does-byoc-support-custom-vm-images Description: Yes. WarpBuild BYOC runs your own VM images on AWS, GCP, and Azure once they meet the documented requirements, and a custom runner selects the image. # Does BYOC Support Custom VM Images? Yes. WarpBuild BYOC runs your own VM images on AWS, GCP, and Azure once they meet the documented requirements, and a custom runner selects the image. ## [Answer](#answer)Copy Anchor Link Yes. WarpBuild BYOC accepts VM images you build in your own cloud account, provided the image meets the requirements in the [custom VM images documentation](/docs/ci/byoc/custom-vm-images), and you register the image with WarpBuild so a custom runner definition can select it. The image then runs on instances inside your account, and workflows reach it through the Runner ID of that custom runner, which is the runner name prefixed with `warp-custom-`. Two things narrow the answer. The images in question are VM images, which the docs call out as distinct from custom container image support. And the capability belongs to BYOC, so it does not apply to WarpBuild hosted capacity, where you select from the maintained catalog instead. BYOC runs on AWS, GCP, and Azure, and the [feature matrix](/docs/ci/feature-matrix) marks custom VM images as supported on all three, checked on 2026-08-13. | Where the runner runs | Custom VM images | | ------------------------- | ---------------- | | WarpBuild hosted capacity | Not applicable | | BYOC on AWS | Supported | | BYOC on GCP | Supported | | BYOC on Azure | Supported | One caveat sits under that row. The documented requirement list is written for AMIs, Linux and Windows, so read the [custom VM images documentation](/docs/ci/byoc/custom-vm-images) against your own cloud before assuming the same package list transfers to a GCP or Azure image. There is no additional cost for using custom VM images. ## [Detail](#detail)Copy Anchor Link ### [What the image has to carry](#what-the-image-has-to-carry)Copy Anchor Link The Linux distro underneath the image has to use `systemd`, because WarpBuild relies on it to run the [WarpBuild agent](https://github.com/WarpBuilds/warpbuild-agent/). Ubuntu and Amazon Linux 2023 both qualify. On top of that, five packages must be present. | Package | Why the runner needs it | | ------- | ---------------------------------------------------- | | curl | Agent and artifact downloads | | wget | Agent and artifact downloads | | bash | Setup and hook script execution | | jq | Runner orchestration | | libicu | .NET runtime dependency of the GitHub Actions runner | Five more come with every supported distro and must survive your hardening pass: `tar`, `gzip`, `coreutils` (which provides `id`, `chpasswd`, `chown`, `tr`, and `sed`), `shadow-utils` (which provides `useradd` and `usermod`), and `systemd`. The package name for `libicu` moves between distro versions, so resolve it against your own base rather than copying a name across images. Windows images add three conditions. `aria2` has to be installed and reachable through the system `PATH`, because WarpBuild uses it in place of the slower default Windows download path. On AWS, the EC2 instance has to be [sysprepped](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ami-create-win-sysprep.html#sysprep-gui-procedure-ec2launchv2) before you turn it into an image, either through the EC2Launch settings dialog or through the Packer sysprep commands. And jobs run under the `runneradmin` user, the same account GitHub's Windows runners use, so user-scoped environment variables set under a different login are invisible to the job and belong at machine level instead. If your image needs to run something around each job, use the `WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_STARTED` and `WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_COMPLETED` variables. WarpBuild already consumes [GitHub's own hook variables](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/run-scripts) to orchestrate runners for your jobs, so the unprefixed names have no effect. ### [Build the image, then register it](#build-the-image-then-register-it)Copy Anchor Link A Packer template keeps the image reviewable and reproducible. The shape below starts from an Ubuntu 24.04 base, creates the `runner` user, and prepares the tool cache directories. Copy Text ``` locals { version = "1.0.0" } source "amazon-ebs" "ci-base" { region = "us-east-1" instance_type = "t3.micro" ami_name = "ci-base-v${local.version}" source_ami_filter { filters = { name = "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.*-amd64-server-*" root-device-type = "ebs" } owners = ["099720109477"] most_recent = true } ssh_username = "ubuntu" } build { sources = ["source.amazon-ebs.ci-base"] provisioner "shell" { inline = [ "sudo groupadd runner || true", "sudo useradd -m -g runner -s /bin/bash runner || true", "sudo apt-get update", "sudo apt-get install -y curl wget jq unzip git libicu-dev", "sudo mkdir -p /opt/hostedtoolcache", "sudo chown -R runner:runner /opt/hostedtoolcache", ] } } ``` Registration is four steps, documented on the [custom VM images page](/docs/ci/byoc/custom-vm-images). 1. Set up a WarpBuild Stack, which fixes the region, network, and object storage the runners use ([BYOC documentation](/docs/ci/byoc)). 2. Open the custom images page in the dashboard and press **Add Image**. Every image in the region your stack lives in is listed, so an image built in another region will not appear. 3. Create a custom runner that uses the image, along with the instance types, disk settings, and IP configuration for that runner. 4. Reference the Runner ID in `runs-on`. .github/workflows/ci.yml Copy Text ``` jobs: build: runs-on: warp-custom-ci-base-4x steps: - uses: actions/checkout@v4 - run: make build - run: make test ``` The install steps the image now covers come out of the workflow in the same pull request. The AWS click path, including the stack and connection work that has to exist first, is on the [AWS BYOC page](/byoc/aws), and the AMI specifics are in [custom AMIs for BYOC runners on AWS](/byoc/aws/custom-amis). ### [When baking beats installing at job time](#when-baking-beats-installing-at-job-time)Copy Anchor Link The fee side of this decision is small, so measure it in per-job seconds and then weigh the maintenance. At 3,000 workflow jobs per month, every second of install time removed from a job returns 50 minutes of runner time per month. The table applies that at four levels, using the BYOC Linux fee of $0.002 per minute and the hosted `warp-ubuntu-latest-x64-4x` rate of $0.008 per minute ([pricing page](/pricing), checked on 2026-08-13). | Seconds removed per job | Runner minutes per month | Fee returned at $0.002 | Fee returned at $0.008 | Build wait returned | | ----------------------- | ------------------------ | ---------------------- | ---------------------- | ------------------- | | 15 | 750 | $1.50 | $6.00 | 12.5 hours | | 45 | 2,250 | $4.50 | $18.00 | 37.5 hours | | 90 | 4,500 | $9.00 | $36.00 | 75 hours | | 240 | 12,000 | $24.00 | $96.00 | 200 hours | The image pipeline costs almost nothing to run. A 12 minute image build on `warp-ubuntu-latest-x64-4x` at $0.008 per minute is $0.096 per rebuild, about $0.42 per month at a weekly rebuild. Against the 15 second row, the fee math already clears by a wide margin. What decides the question is upkeep. The pipeline needs an owner, a version scheme, a rebuild trigger when the base image picks up security updates, and a rollback path when a bad image reaches production runners. Because of that, teams usually set the bar above the fee break-even: bake when the installs you would remove run longer than about 90 seconds per job, or when the install step is a recurring source of red builds whatever its length. Two situations argue the other way. If the tooling already ships in the maintained runner images, changing the label is the cheaper move, since. With tooling kept current for you. And if the slow part is machine start rather than tool install, a fatter image makes it worse; standby disks are the lever there. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can I use a custom VM image on WarpBuild hosted runners?](#can-i-use-a-custom-vm-image-on-warpbuild-hosted-runners)Copy Anchor Link No. Custom VM images are a BYOC capability, so the image runs on instances in your own cloud account under a custom runner you configure. On hosted capacity you pick from the maintained catalog, which covers Linux x64, Linux ARM64, macOS, and Windows runners. The [runner image definition](/glossary/runner-image) covers the difference between a maintained image and one you bake yourself, and [BYOC runners on AWS](/byoc/aws) covers the account setup a custom image needs. ### [Does a custom VM image cost extra?](#does-a-custom-vm-image-cost-extra)Copy Anchor Link No. There is no additional cost for using custom VM images ([custom VM images documentation](/docs/ci/byoc/custom-vm-images)). You pay the BYOC runner fee of $0.002 per minute and your own cloud account bills the instance, disk, and data transfer at your rates. ### [What happens if the image is missing a required package?](#what-happens-if-the-image-is-missing-a-required-package)Copy Anchor Link The runner starts and then fails on the first job it takes. WarpBuild relies on `systemd` to run its agent, and the GitHub Actions runner needs `libicu` as a .NET runtime dependency, so a hardened base that strips either one produces a machine that looks healthy until a job lands on it. Validate the package list during the image build rather than on the first workflow run. [Building custom runner images for GitHub Actions](/guides/custom-runner-images-github-actions) walks through the full requirement list and the hook variables that go with it. --- # Does BYOC on GCP Support Local SSD? | WarpBuild URL: https://www.warpbuild.com/answers/does-byoc-support-gcp-local-ssd Description: Yes. GCP BYOC runners detect and mount bundled Local SSDs at boot on Linux images, so the Compute Engine machine type you pick decides whether a job gets one. # Does BYOC on GCP Support Local SSD? Yes. GCP BYOC runners detect and mount bundled Local SSDs at boot on Linux images, so the Compute Engine machine type you pick decides whether a job gets one. ## [Answer](#answer)Copy Anchor Link Yes. GCP BYOC runners support Local SSD, and the mount is automatic: when the Compute Engine machine types on a runner configuration ship bundled Local SSDs, such as the `-lssd` machine types, WarpBuild detects the NVMe devices during boot, formats them, and mounts them to the runner work directory on Linux images ([GCP BYOC configuration documentation](/docs/ci/byoc/gcp/config), checked on 2026-08-13). No field in the runner form switches it on, so the machine type you select is the decision that produces the storage. That splits the storage settings on a GCP runner configuration into two independent things. The disk type and size fields control the persistent disk attached to the instance, and the GCP configuration documentation lists five disk types with a 100GB minimum size and a 150GB recommended starting point. Local SSD arrives with the machine type instead, so it appears in the machine type list rather than in the disk fields. | Setting | What it controls | Where you set it | | --------------- | -------------------------------------------------------------------------------- | --------------------------------------- | | Disk type | pd-balanced, pd-ssd, pd-standard, hyperdisk-balanced, or hyperdisk-extreme | Runner creation form | | Disk size | Persistent disk capacity, 100GB minimum and 150GB recommended | Runner creation form | | Machine types | Whether the instance carries bundled Local SSD, since only some machine types do | Runner creation form, in priority order | | Local SSD mount | Detection, RAID-0 assembly, format, and mount at the work directory | Nothing to set, handled at boot | BYOC runs on AWS, GCP, and Azure, and the automatic mount behaves the same way on all three for Linux images ([local SSD documentation](/docs/ci/features/local-ssd), checked on 2026-08-13). WarpBuild bills BYOC Linux runners at $0.002 per minute and Google bills the instance, which on an `-lssd` machine type already includes the Local SSD capacity in the machine type rate. ## [Detail](#detail)Copy Anchor Link ### [The machine type has to be one that carries the storage](#the-machine-type-has-to-be-one-that-carries-the-storage)Copy Anchor Link Google publishes Local SSD as a property of the machine type: each Local SSD disk provides 375 GiB, and the `-lssd` machine types come with a predetermined number of disks that you cannot customize ([Google Local SSD documentation](https://cloud.google.com/compute/docs/disks/local-ssd), checked on 2026-08-13). The capacity a runner gets is therefore the disk count of the machine type multiplied by 375 GiB. | Machine type | Local SSD disks | Local SSD capacity | | ---------------------------------- | --------------- | -------------------------------------- | | c4-standard-16-lssd | 2 | 750 GiB | | c4-standard-48-lssd | 8 | 3 TiB | | Machine type with no \-lssd suffix | 0 | None, the job uses the persistent disk | The WarpBuild configuration documentation names the `c3-standard-88-lssd` and `c4a-standard-4-lssd` families as examples of what to select. Disk counts differ per machine type, so check the exact count for the type you plan to run in Google's table before you size the work directory against it. One selection detail catches teams out. A runner configuration takes one or more machine types in priority order, and WarpBuild picks by availability, so a shortfall on the first type falls through to the second instead of leaving the job queued. If the first type is an `-lssd` type and the fallback is not, the same workflow gets Local SSD on some runs and the persistent disk on others, and job duration moves with it. Keep every machine type inside one runner configuration on the same storage shape, and give the mixed shapes their own runner configuration and their own `warp-custom-` label. ### [What the runner does at boot](#what-the-runner-does-at-boot)Copy Anchor Link The sequence runs during cloud-init and finishes before any job starts: detect every local NVMe device, create a RAID-0 array if there is more than one, format the device or the array with ext4, and mount it at the runner work directory such as `/home/runner/work`. For BYOC custom images, `mdadm` is installed automatically when it is missing, which covers Ubuntu, Amazon Linux, and other common distributions. | Runner | Local SSD auto-mount | | -------------------- | -------------------- | | BYOC on AWS, Linux | Supported | | BYOC on GCP, Linux | Supported | | BYOC on Azure, Linux | Supported | | Windows | Not supported | | macOS | Not supported | The Linux restriction matters on GCP for a second reason: GCP BYOC runners are Linux Compute Engine instances, and snapshot runners are not available on BYOC GCP ([GCP BYOC configuration documentation](/docs/ci/byoc/gcp/config)). Windows and macOS jobs stay on hosted `warp-` labels in the same workflow file and get their regular volumes there. ### [Local SSD is ephemeral on both sides](#local-ssd-is-ephemeral-on-both-sides)Copy Anchor Link Google states that all data on a Local SSD may be lost if the instance stops or is terminated for any reason. WarpBuild runners are ephemeral anyway, so the runner terminating after the job takes the disk with it. Nothing changes about where durable state lives: caches and artifacts go to the Cloud Storage bucket your stack created in your own project, and BYOC add-ons including cache are covered by the per-minute fee. RAID-0 gives throughput and capacity with no redundancy. For a build workspace that is discarded at the end of the job, that is the right trade, and it is the reason the runner stripes rather than mirrors. ### [The workloads that gain the most](#the-workloads-that-gain-the-most)Copy Anchor Link Local SSD pays off where a job spends real time waiting on the filesystem rather than on the CPU: * Large checkouts. A full-history clone of a monorepo writes hundreds of thousands of small files, and `actions/checkout` with `fetch-depth: 0` is one of the heaviest I/O steps in a typical pipeline. * Cache-heavy builds. Restoring a large dependency cache, unpacking `node_modules` or a Gradle cache, and writing it back at the end are all extract-and-write workloads. * Container image work. Layer unpack and image build write and delete large trees quickly. * Test suites that restore a database dump or a fixture set before every run. A workflow that lands on such a runner needs no special steps, and two commands confirm the mount on the first run: .github/workflows/ci.yml Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: build: runs-on: warp-custom-gcp-use-lssd-16x steps: - name: Confirm the work directory is on the local NVMe array run: | lsblk df -h "${GITHUB_WORKSPACE}" - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm test ``` `lsblk` shows the NVMe devices and the `md` array when several were striped, and `df -h` on the workspace shows which volume the work directory actually sits on. Run both once when you create the runner configuration, then drop them. When no `-lssd` machine type is available in your stack region, or the quota conversation is going to take a while, `hyperdisk-balanced` is the other lever: it takes provisioned IOPS from 3,000 to 160,000 and provisioned throughput from 140 to 2,400 MiB/s, set in the runner creation form. [Disk types for BYOC runners on GCP](/byoc/gcp/disk-types) compares the five options and the quota lines each one needs, and [BYOC runners on GCP](/byoc/gcp) covers the stack, service account, and quota work that comes before either choice. ## [Related Questions](#related-questions)Copy Anchor Link ### [Do I have to configure anything to use Local SSD on a GCP BYOC runner?](#do-i-have-to-configure-anything-to-use-local-ssd-on-a-gcp-byoc-runner)Copy Anchor Link No. If the machine type carries bundled Local SSDs, the runner detects the NVMe devices at boot, formats them, and mounts them to the work directory before the job starts. A single device is mounted directly and several are combined into a RAID-0 array first ([local SSD documentation](/docs/ci/features/local-ssd)). The only choice you make is the machine type, which [runners with local SSD storage](/runners/local-ssd) covers across all three clouds. ### [What happens if only some machine types in my fallback list have Local SSD?](#what-happens-if-only-some-machine-types-in-my-fallback-list-have-local-ssd)Copy Anchor Link The job takes whichever type was available that morning, so it lands on Local SSD some runs and on the persistent disk on others, and duration moves with it. Keep every machine type inside one runner configuration on the same storage shape, and split the mixed shapes into separate runner configurations with separate `warp-custom-` labels. The [GCP BYOC configuration documentation](/docs/ci/byoc/gcp/config) covers the priority order and the fallback behavior. ### [Does data on a Local SSD survive the job?](#does-data-on-a-local-ssd-survive-the-job)Copy Anchor Link No. Google documents Local SSD as ephemeral storage whose data may be lost whenever the instance stops or terminates ([Google Local SSD documentation](https://cloud.google.com/compute/docs/disks/local-ssd), checked on 2026-08-13), and WarpBuild runners terminate after the job. Caches and artifacts belong in the Cloud Storage bucket your stack created, which the $0.002 per minute BYOC fee already covers. [Local SSD, defined](/glossary/local-ssd) sets out the difference between instance-attached storage and network-attached disks, and the [pricing page](/pricing) has the rest of the rate card, checked on 2026-08-13. --- # Does BYOC Support Windows Runners? | WarpBuild URL: https://www.warpbuild.com/answers/does-byoc-support-windows-runners Description: Yes. WarpBuild BYOC runs Windows Server 2022 x86-64 runners on AWS and Azure, and the AWS docs set higher instance and disk minimums for Windows than Linux. # Does BYOC Support Windows Runners? Yes. WarpBuild BYOC runs Windows Server 2022 x86-64 runners on AWS and Azure, and the AWS docs set higher instance and disk minimums for Windows than Linux. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Yes. WarpBuild BYOC runs Windows Server 2022 x86-64 GitHub Actions runners as instances inside your own cloud account, and the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config) documents a separate set of minimum infrastructure requirements for Windows that sits above the general minimums a Linux custom runner is created with. Plan for that difference before you copy a Linux runner definition and change the image. BYOC runs on AWS, GCP, and Azure. Windows coverage is narrower than Linux coverage across those three, and the [feature matrix](/docs/ci/feature-matrix) is the row that decides it, checked on 2026-08-13. | Where the runner runs | Windows x86-64 | | ------------------------- | ----------------- | | WarpBuild hosted capacity | Supported | | BYOC on AWS | Supported | | BYOC on Azure | Supported | | BYOC on GCP | Not yet available | The workflow change is the same one Linux BYOC jobs make. A custom runner is a named machine configuration, and the workflow references it by Runner ID, which is the runner name with the `warp-custom-` prefix. ## [Detail](#detail)Copy Anchor Link ### [The documented Windows minimums](#the-documented-windows-minimums)Copy Anchor Link Two numbers move when the guest operating system is Windows Server 2022 rather than Linux, and one more is a recommendation rather than a floor. All four rows come from the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config), checked on 2026-08-13. | Setting | Custom runner minimum | General best practice | Windows Server 2022 x86-64 on AWS | | --------------- | ------------------------------------- | -------------------------------------- | --------------------------------------- | | Disk size | 100 GB | 150 GB | Follows the general guidance | | Disk throughput | 125 MBps | 400 MBps | 500 MBps | | Disk IOPS | 3000 | 4000 | 6000 | | Instance size | Your instance types in priority order | Types of similar price and performance | At least 8 vCPU, m7a series recommended | The instance row is the one that surprises teams sizing from the hosted catalog. WarpBuild hosted Windows runners start at 4 vCPU with 16 GB, since the 2 vCPU Windows shapes were removed on June 8, 2026 (see the [Windows runner catalog](/runners/windows)), so a hosted 4 vCPU Windows job has no direct BYOC counterpart under this guidance. Start the BYOC equivalent at 8 vCPU and let the disk numbers follow. Give the runner more than one instance type in priority order. The workflow still names a single label, and WarpBuild picks from the list by availability, which keeps queued jobs from waiting on one instance family in one availability zone. ### [Custom Windows images](#custom-windows-images)Copy Anchor Link BYOC runners can boot your own AMI instead of a WarpBuild image, and the [custom VM images guide](/docs/ci/byoc/custom-vm-images) lists what a Windows AMI has to carry. Three requirements are specific to Windows on AWS. 1. **aria2 on the system PATH.** WarpBuild downloads job artifacts with aria2 because the default Windows method is slow. The guide includes a PowerShell block that unpacks the release into `C:\Tools\aria2` and appends that directory to the machine `Path` variable. 2. **A sysprepped instance.** The EC2 instance has to be generalized with sysprep before you create an AMI from it, either through the Amazon EC2Launch settings dialog or through the Packer sysprep commands the guide links. 3. **The runneradmin user.** Jobs run under `runneradmin`, the same user GitHub's Windows runners use, and WarpBuild adds the account if the image does not have it. User-scoped environment variables baked into some other account will not be visible to the job, so move them to machine scope or set them for `runneradmin`. ### [The workflow](#the-workflow)Copy Anchor Link A BYOC Windows label and a hosted label can sit in the same workflow file, which is how teams run Windows in their own account while keeping other platforms on hosted capacity. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. .github/workflows/build.yml Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: windows-build: runs-on: warp-custom-win2022-use1-8x steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 with: path: ~\AppData\Local\NuGet\v3-cache key: nuget-${{ hashFiles('**/packages.lock.json') }} - run: dotnet restore - run: dotnet build --configuration Release --no-restore - run: dotnet test --configuration Release --no-build linux-build: runs-on: warp-custom-linux-use1-8x steps: - uses: actions/checkout@v4 - run: make build ``` ### [What the Windows minutes cost](#what-the-windows-minutes-cost)Copy Anchor Link BYOC bills $0.002 per runner minute for Windows runners, and your AWS or Azure account bills the instance, the disk, and the data transfer at your own rates. | Route for an 8 vCPU, 32 GB Windows job | Rate per minute | Who bills the compute | | ------------------------------------------- | -------------------- | --------------------- | | GitHub-hosted 8-core Windows larger runner | $0.042 | GitHub | | WarpBuild hosted warp-windows-latest-x64-8x | $0.032 | WarpBuild | | WarpBuild BYOC on AWS or Azure | $0.002 WarpBuild fee | Your cloud account | On the hosted route the arithmetic is (0.042 - 0.032) / 0.042 = 24 percent lower list price, GitHub list price checked on 2026-08-13 at the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The BYOC row is a partial bill by design: add what your cloud charges for the instances and the higher-IOPS disks the Windows guidance calls for, since a 6000 IOPS, 500 MBps volume costs more than the 3000 IOPS floor. ### [Windows BYOC at production scale](#windows-byoc-at-production-scale)Copy Anchor Link SonarQube runs tens of thousands of GitHub Actions jobs per day on WarpBuild BYOC runners in its own AWS account, across Windows and Linux. Their team drives image updates and runner configuration changes through the WarpBuild API rather than the dashboard, which is the pattern to copy once a Windows runner definition is settled and you want the AMI refreshed on a schedule. The [SonarQube customer page](/customers/sonarqube) has the account details. ## [Related Questions](#related-questions)Copy Anchor Link ### [Which clouds run Windows BYOC runners?](#which-clouds-run-windows-byoc-runners)Copy Anchor Link AWS and Azure. BYOC runs on AWS, GCP, and Azure, and the [feature matrix](/docs/ci/feature-matrix) marks Windows x86-64 as supported on AWS and Azure and pending on GCP, checked on 2026-08-13\. A workflow that needs Windows while its Linux jobs sit on GCP BYOC keeps a hosted `warp-windows-` label for those jobs, priced on the [Windows runners page](/runners/windows). ### [What instance and disk configuration do Windows BYOC runners need on AWS?](#what-instance-and-disk-configuration-do-windows-byoc-runners-need-on-aws)Copy Anchor Link The [AWS BYOC configuration guide](/docs/ci/byoc/aws/config) recommends at least 8 vCPU with the m7a series, 6000 IOPS, and 500 MBps of disk throughput for Windows Server 2022 x86-64 runners. The general custom runner minimum is 100 GB, 125 MBps, and 3000 IOPS, so a Windows runner definition copied from a Linux one usually needs its disk settings raised. The [AWS BYOC page](/byoc/aws) walks the full stack and runner setup. ### [Can I run my own Windows AMI on BYOC?](#can-i-run-my-own-windows-ami-on-byoc)Copy Anchor Link Yes. Linux and Windows AMIs are both supported. A Windows AMI needs aria2 installed and reachable on the system PATH, and the EC2 instance has to be sysprepped before you create the image from it. Jobs run under the `runneradmin` user, the same user GitHub's Windows runners use, so check where your user-scoped environment variables live. The [custom VM images guide](/docs/ci/byoc/custom-vm-images) carries the PowerShell and the sysprep steps, and [Windows BYOC runners on AWS](/byoc/aws/windows-runners) covers the rest of the setup. --- # Does BYOC Work With IMDSv2 Required? | WarpBuild URL: https://www.warpbuild.com/answers/does-byoc-work-with-imdsv2 Description: Yes. Require IMDSv2 is a documented per-runner setting on AWS BYOC custom runners, so GitHub Actions jobs read instance credentials only with a session token. # Does BYOC Work With IMDSv2 Required? Yes. Require IMDSv2 is a documented per-runner setting on AWS BYOC custom runners, so GitHub Actions jobs read instance credentials only with a session token. ## [Answer](#answer)Copy Anchor Link Yes. The AWS BYOC configuration guide documents Require IMDSv2 as a setting on the custom runner: both IMDS v1 and v2 answer by default, and you switch a runner to the session-token version from the Update Runner page under Runner Specs, with the same field available when you first create the runner ([AWS BYOC configuration](/docs/ci/byoc/aws/config)). The [security hardening guide](/docs/ci/byoc/aws/security) lists the same toggle as hardening step 6 and ends its checklist with the line "IMDSv2 required on all runners". The setting lands on the runner class rather than on the stack, so one stack can carry a hardened runner and an unmodified one while you work through the rollout. Nothing in the workflow changes: the `runs-on` label stays the same, and the EC2 instance WarpBuild launches for the job comes up with the metadata service configured the way that runner class specifies. ## [Detail](#detail)Copy Anchor Link ### [Where the setting lives](#where-the-setting-lives)Copy Anchor Link | Item | Value | | ------------------------------ | --------------------------------------------------------------------- | | Default on a new custom runner | Both IMDS v1 and v2 answer | | Where the field lives | Custom runner configuration, Runner Specs section, "Require IMDSv2" | | When you can set it | At runner creation, and later on the Update Runner page | | Scope | One runner class, so classes in the same stack decide independently | | What it applies to | The EC2 instances launched in your AWS account for jobs on that class | Rows come from the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config), checked on 2026-08-13\. Because each job runs on a freshly provisioned instance that is terminated when the job finishes, the change takes effect on the next job queued to that class rather than needing a fleet restart ([security hardening guide](/docs/ci/byoc/aws/security)). ### [Why the session-token version matters](#why-the-session-token-version-matters)Copy Anchor Link Version 1 answers a plain GET to the link-local address. Version 2 makes the caller open a session first: a PUT to `/latest/api/token` returns a token, and every later GET carries that token in a header. AWS also has the endpoint reject requests carrying `X-Forwarded-For` and applies a hop limit to the response ([AWS instance metadata service configuration](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html)). Those three properties close the request-forgery path. A service on the instance that can be tricked into fetching an attacker-supplied URL will happily fetch the credentials path under version 1 and hand back the body. Under version 2 the same trick fails, because the forged request is a GET with no session token and no way to add the header. The WarpBuild security guide states the defense in exactly those terms: require IMDSv2 to protect against attacks that attempt to steal instance credentials ([security hardening guide](/docs/ci/byoc/aws/security)). The value of the setting scales with what the runner can reach. A runner class with no instance profile has no role credentials at the endpoint to steal. A class carrying an ECR push profile or a deploy profile does, and that is the class to harden first ([instance profile setup](/docs/ci/byoc/aws/instance-profile)). ### [Check your build tools before you enforce it fleet wide](#check-your-build-tools-before-you-enforce-it-fleet-wide)Copy Anchor Link Enforcement is a one-click change with a blast radius equal to every tool on the machine that reads metadata. Work through the consumers first. | Metadata consumer | What to check | Typical outcome | | ---------------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------- | | AWS CLI and AWS SDKs in the runner image | Version currency; current releases request a token before reading credentials | Works unchanged | | Shell steps calling 169.254.169.254 directly | Whether the curl command sends a token header | Fails until the PUT is added | | Agents baked into a custom AMI | Whether the vendor build supports the session-token version | Vendor-dependent, so test it | | Steps reading metadata from inside a container | Whether the extra network hop still reaches the endpoint under the documented hop limit | Test before enforcing | A probe job settles the question in one run. Point it at the class you just changed: .github/workflows/imds-probe.yml Copy Text ``` name: imds-probe on: workflow_dispatch jobs: probe: runs-on: warp-custom-build-use1-8x steps: - name: Read credentials with a session token run: | TOKEN=$(curl -sX PUT "http://169.254.169.254/latest/api/token" \ -H "X-aws-ec2-metadata-token-ttl-seconds: 300") curl -sf -H "X-aws-ec2-metadata-token: $TOKEN" \ http://169.254.169.254/latest/meta-data/iam/security-credentials/ - name: Fail if a tokenless read still works run: | if curl -sf --max-time 2 http://169.254.169.254/latest/meta-data/ > /dev/null; then echo "the metadata service still answers without a token" >&2 exit 1 fi - name: Run the real build steps run: make build ``` The first step proves the credential path your jobs depend on still resolves. The second proves the old path is closed. The third catches the tools you forgot about, which is the failure everyone actually hits. Before the switch, AWS publishes a per-instance CloudWatch metric that counts metadata calls made without a token, which is the way to find version 1 callers while they are still succeeding ([AWS instance metadata service configuration](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html)). A workable order is: enable on one low-traffic runner class, run the probe, leave it for a day of real jobs, then move through the remaining classes and tick the checklist line on the [security checklist for BYOC on AWS](/byoc/aws/security-checklist). The longer setup walkthrough lives on [requiring IMDSv2 on BYOC runners](/byoc/imdsv2), and the endpoint itself is defined on [instance metadata service](/glossary/instance-metadata-service). ### [What the hardening costs](#what-the-hardening-costs)Copy Anchor Link Nothing on top of the runner rate. BYOC Linux runners and BYOC Windows runners both bill $0.002 per runner minute in WarpBuild fees, and AWS bills your own account for the EC2 capacity those jobs consume ([pricing page](/pricing), checked on 2026-08-13). BYOC runs on AWS, GCP, and Azure, so read this setting as the AWS-specific one in a wider set of metadata endpoints. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does requiring IMDSv2 break jobs that use an instance profile?](#does-requiring-imdsv2-break-jobs-that-use-an-instance-profile)Copy Anchor Link No, as long as the tools reading credentials request a session token first. Current AWS SDKs and the AWS CLI do that automatically, so a job that assumes its role through the instance profile keeps working ([instance profile setup](/docs/ci/byoc/aws/instance-profile)). What breaks is a script that curls `http://169.254.169.254` with no token header, or an agent baked into a custom image that still speaks the single-request version. The probe job above finds both in one run. ### [Can I require it on some runners and not others?](#can-i-require-it-on-some-runners-and-not-others)Copy Anchor Link Yes. Require IMDSv2 sits on the custom runner configuration under Runner Specs, so each runner class in a stack decides independently ([AWS BYOC configuration](/docs/ci/byoc/aws/config)). Enable it on one low-traffic class, run a probe job, then work through the rest of the fleet. The class-by-class walkthrough is on [requiring IMDSv2 on BYOC runners](/byoc/imdsv2). ### [What about GCP and Azure BYOC runners?](#what-about-gcp-and-azure-byoc-runners)Copy Anchor Link BYOC runs on AWS, GCP, and Azure. The Require IMDSv2 toggle documented by WarpBuild is the AWS one, and GCP and Azure expose their own metadata endpoints with their own required request headers, so the compatibility check has to be run per cloud. The shared concept behind all three is covered on [instance metadata service](/glossary/instance-metadata-service), and the AWS stack layout is on the [AWS BYOC page](/byoc/aws). --- # Does Caching Work Inside a Container Job? | WarpBuild URL: https://www.warpbuild.com/answers/does-caching-work-inside-a-container-job Description: Yes, with wget and zstd in the image and the verification token passed into the container. The usual failure is a cache path that resolves to the wrong home. # Does Caching Work Inside a Container Job? Yes, with wget and zstd in the image and the verification token passed into the container. The usual failure is a cache path that resolves to the wrong home. Yes, a container job restores and saves a WarpBuild cache once the image carries `wget` and `zstd` and the job passes `WARPBUILD_RUNNER_VERIFICATION_TOKEN` into the container environment, which is the setup written down in the [WarpBuild caching documentation](/docs/ci/features/caching). The failure that costs teams an afternoon is quieter than a missing binary: the cache step runs inside the container, so `~` expands to `/github/home` and a path like `~/.cargo` points at a directory the toolchain in the image never writes to. ## [Answer](#answer)Copy Anchor Link `jobs..container` starts your image on the runner VM and runs every step of the job inside it, per [GitHub's workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidcontainer). The cache action is a step, so it runs inside the container too. Every path in its `path:` input is read through the container filesystem. Four conditions decide whether the cache works there, and each one fails in a way you can recognize from the job log. | Condition | Why it is required | What you see when it is missing | | ----------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | wget present in the image | The cache action downloads entries with wget | The restore step fails while fetching the entry | | zstd present in the image | Entries are compressed with zstd | zstd version: null, a gzip fallback, then a 404 warning on an entry saved with zstd | | WARPBUILD\_RUNNER\_VERIFICATION\_TOKEN in the container env | The variable is present on the WarpBuild runner and authenticates the action | The step cannot authenticate against the cache service | | path: resolves to where the tool writes | HOME is /github/home inside a container job | A reported hit over an empty directory, and the install step runs at full cost anyway | The first three come from the [caching documentation](/docs/ci/features/caching) and are one-line fixes. The fourth is the one worth reading the rest of this page for, because it produces no error at all. Container jobs are a Linux mechanism on both sides of this. WarpBuild caching is enabled by default on Linux runners and is not supported on Windows runners, per the [cloud runners documentation](/docs/ci/cloud-runners), so the platform question resolves the same way whether or not a container is in the picture. ## [Detail](#detail)Copy Anchor Link ### [The documented container setup](#the-documented-container-setup)Copy Anchor Link Copy Text ``` name: test on: [push] jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x container: image: golang:1.25 env: WARPBUILD_RUNNER_VERIFICATION_TOKEN: ${{ env.WARPBUILD_RUNNER_VERIFICATION_TOKEN }} steps: - uses: actions/checkout@v4 - name: Install cache prerequisites run: apt-get update && apt-get install -y --no-install-recommends wget zstd ``` The `env` block is the part that gets skipped. `WARPBUILD_RUNNER_VERIFICATION_TOKEN` is set on the runner host, and a container gets only the variables the workflow hands it, so the token has to be named explicitly. Images built on Alpine install the same two tools with `apk add --no-cache wget zstd`. ### [Where the paths move](#where-the-paths-move)Copy Anchor Link The runner mounts a fixed set of host directories into the job container and rewrites `HOME`. The mapping below is what [ContainerOperationProvider.cs in the actions/runner repository](https://github.com/actions/runner/blob/main/src/Runner.Worker/ContainerOperationProvider.cs) sets up on every container job. | Host path | Container path | What lives there | | --------------------------------------- | ----------------- | ------------------------------------------------------------ | | /home/runner/work | /\_\_w | The workspace, so GITHUB\_WORKSPACE becomes /\_\_w/repo/repo | | /home/runner/externals | /\_\_e, read only | The Node.js build that executes JavaScript actions | | /home/runner/work/\_temp | /\_\_w/\_temp | Step temp files | | /home/runner/work/\_actions | /\_\_w/\_actions | Checked-out action code | | /home/runner/work/\_temp/\_github\_home | /github/home | HOME for every step in the job | Two consequences follow. Paths relative to the workspace keep working unchanged, so a cache of `./node_modules` or `vendor/` behaves the same as it does on a plain runner. And `~` no longer means what it means on the host: it expands to `/github/home`, a directory the runner created for this job. ### [The mismatch, with two images you probably use](#the-mismatch-with-two-images-you-probably-use)Copy Anchor Link `/github/home` would be harmless if every tool inside the container honored `HOME`. Official language images do not, because they place their caches at an absolute path baked in at image build time. | Image | Variable set in the image | Where dependencies actually land | What \~/... caches instead | | ------ | ------------------------------------------------------------------------------------------------ | -------------------------------- | ----------------------------------- | | golang | GOPATH=/go ([docker-library/golang](https://github.com/docker-library/golang)) | /go/pkg/mod | /github/home/go/pkg/mod, empty | | rust | CARGO\_HOME=/usr/local/cargo ([rust-lang/docker-rust](https://github.com/rust-lang/docker-rust)) | /usr/local/cargo/registry | /github/home/.cargo/registry, empty | A workflow copied from a non-container job carries the wrong path in: Copy Text ``` - uses: WarpBuilds/cache@v1 with: path: ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} ``` That step saves an empty directory on the first run and restores an empty directory on every run after it. The log line says the cache was restored, `go mod download` still pulls every module, and the job time never moves. It looks like a key problem, so teams go and rewrite the key, which is why [cache key](/glossary/cache-key) and [why a GitHub Actions cache misses every run](/answers/why-does-my-github-actions-cache-miss-every-run) are the two pages people land on before finding this one. ### [A container job that caches the right paths](#a-container-job-that-caches-the-right-paths)Copy Anchor Link Ask the toolchain where its cache is instead of guessing. The command runs inside the container, so the answer is correct for that image without you tracking what each image sets. Copy Text ``` name: test on: [push] jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x container: image: golang:1.25 env: WARPBUILD_RUNNER_VERIFICATION_TOKEN: ${{ env.WARPBUILD_RUNNER_VERIFICATION_TOKEN }} steps: - uses: actions/checkout@v4 - name: Install cache prerequisites run: apt-get update && apt-get install -y --no-install-recommends wget zstd - name: Resolve cache paths id: paths run: | echo "modcache=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" echo "buildcache=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" - name: Restore module and build caches id: cache uses: WarpBuilds/cache@v1 with: path: | ${{ steps.paths.outputs.modcache }} ${{ steps.paths.outputs.buildcache }} key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- - name: Download modules if: steps.cache.outputs.cache-hit != 'true' run: go mod download - name: Verify the restore landed run: du -sh "${{ steps.paths.outputs.modcache }}" - run: go test ./... ``` `du -sh` on the restored path is the whole verification. A hit with a few kilobytes behind it is the empty-directory failure. A hit with hundreds of megabytes behind it is a working cache. Keep that step for a run or two after any change to the image, then drop it. The same shape works for other toolchains: `npm config get cache`, `pip cache dir`, `yarn config get cacheFolder`, `composer config cache-dir`. Each prints the path the tool resolved inside the container, which is the path the cache step needs. ### [What the cache costs, and where the container fits](#what-the-cache-costs-and-where-the-container-fits)Copy Anchor Link Cache work is billed separately from runner minutes. Cache storage is $0.20 per GB-month and each cache write or restore is $0.0001, both from the [pricing page](/pricing), checked on 2026-08-13\. A repository holding 20 GB of module and build caches across branches and running 3,000 container jobs per month, each doing one restore and one write, pays 20 x $0.20 = $4.00 in storage and 3,000 x 2 x $0.0001 = $0.60 in operations, so $4.60 per month. At $0.008 per minute on `warp-ubuntu-latest-x64-4x`, that total is worth about 575 runner minutes, which one working Go module cache recovers in a few dozen jobs. When the setup inside the image is the slow part rather than the dependencies, a snapshot runner captures the prepared VM instead, and the [persistent caches guide](/guides/persistent-caches-github-actions) covers which state belongs in a cache, a snapshot, or an image. The wider mechanics of running steps in a container are in the [guide to container jobs on GitHub Actions](/guides/container-jobs-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link ### [What does a container image need before the cache action will work?](#what-does-a-container-image-need-before-the-cache-action-will-work)Copy Anchor Link Three things, per the [WarpBuild caching documentation](/docs/ci/features/caching). The image needs `wget`, because the cache action downloads entries with it. The image needs `zstd`, because entries are compressed with it. And the job needs `WARPBUILD_RUNNER_VERIFICATION_TOKEN` passed into the container `env` block, because that variable exists on the runner host and does not cross into the container on its own. Once the container can reach the cache, the [persistent caches guide](/guides/persistent-caches-github-actions) covers which state is worth putting in it. ### [Why does my cache restore inside a container log "zstd version: null"?](#why-does-my-cache-restore-inside-a-container-log-zstd-version-null)Copy Anchor Link The image has no `zstd` binary, so the action falls back to gzip. The warning is followed by a 404 warning when the entry was saved with `zstd`, because a gzip restore cannot read a `zstd` entry. Install `zstd` in the image or in a step before the cache step, then rerun. The [caching documentation](/docs/ci/features/caching) lists this under cache restore errors. ### [Why does caching \~/.cargo inside a container job restore nothing?](#why-does-caching-cargo-inside-a-container-job-restore-nothing)Copy Anchor Link `HOME` inside a container job is `/github/home`, so `~/.cargo` expands to `/github/home/.cargo`. The official Rust image sets `CARGO_HOME` to `/usr/local/cargo`, so the registry never lands under the path being cached. The cache step reports a hit and the directory is empty. Cache the path the toolchain actually uses, which `cargo config get` will print. The same rule covers the workspace: `GITHUB_WORKSPACE` reads as `/__w/repo/repo` inside the container, so absolute host paths written into a workflow file break there too. [Cache key](/glossary/cache-key) covers the separate question of when a key is at fault, and [why a GitHub Actions cache misses every run](/answers/why-does-my-github-actions-cache-miss-every-run) covers the misses that survive a correct path. --- # Does GitHub Actions Support Apple Silicon Runners? | WarpBuild URL: https://www.warpbuild.com/answers/does-github-actions-support-apple-silicon-runners Description: Yes. GitHub Actions macOS runners are ARM64 on Apple Silicon. A label such as warp-macos-15-arm64-6x gives 6 vCPU and 14GB of memory at $0.08 a minute. # Does GitHub Actions Support Apple Silicon Runners? Yes. GitHub Actions macOS runners are ARM64 on Apple Silicon. A label such as warp-macos-15-arm64-6x gives 6 vCPU and 14GB of memory at $0.08 a minute. Last verified: 2026-08-13 Yes. Every macOS runner in the WarpBuild catalog is Apple Silicon hardware on ARM64, and a job moves onto one by changing `runs-on` to a label such as `warp-macos-15-arm64-6x`, which provisions 6 vCPU with 14GB of memory and a 120GB SSD at $0.08 per minute. The larger configuration, `warp-macos-15-arm64-12x`, provisions 12 vCPU with 28GB of memory and a 270GB SSD at $0.16 per minute ([WarpBuild cloud runners](/docs/ci/cloud-runners), checked on 2026-08-13). This page covers what Apple Silicon means once a GitHub Actions job is running on it: the sizes and rates in the catalog, what changes inside the job when the machine is ARM64, the three limits worth planning around, and the list-price arithmetic against GitHub's published macOS rate. ## [Answer](#answer)Copy Anchor Link There are three ways to put a GitHub Actions job on Apple Silicon, and all three are native ARM64 execution. **GitHub-hosted macOS runners.** GitHub publishes `macos-latest` and versioned labels such as `macos-15`, plus larger macOS runners on paid plans. The per-minute rates are in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and on the [GitHub pricing page](https://github.com/pricing). **Self-hosted Macs.** The runner agent ships a macOS ARM64 release on [github.com/actions/runner](https://github.com/actions/runner), so a Mac you own registers against a repository or organization and advertises labels. You own the hardware, the Xcode installs, the disk cleanup, and the state left behind between jobs. **Managed macOS runners.** A provider registers ephemeral macOS virtual machines against your GitHub organization and you select them by label. WarpBuild runs this shape. The macOS line is ARM64 only. The catalog carries multiple sizes and configurations per chip. On macOS there are two, and they scale memory and disk with the core count rather than holding either fixed. Every figure comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Configuration | vCPU | Memory | Storage | Per minute | Per vCPU-minute | Labels carrying it | | ------------- | ---- | ------ | --------- | ---------- | --------------- | ---------------------------------------------------------------------- | | 6x | 6 | 14GB | 120GB SSD | $0.08 | $0.0133 | warp-macos-26-arm64-6x, warp-macos-15-arm64-6x, warp-macos-14-arm64-6x | | 12x | 12 | 28GB | 270GB SSD | $0.16 | $0.0133 | warp-macos-26-arm64-12x, warp-macos-15-arm64-12x | Five labels cover three images: macOS 26, macOS 15, and macOS 14\. The label grammar is `warp-macos--arm64-x`, so the architecture segment reads `arm64` on every macOS label in the catalog. `warp-macos-latest-arm64-6x` and `warp-macos-latest-arm64-12x` are aliases that resolve to the macOS 15 image, in sync with GitHub's own `macos-latest` tag, so a job that must stay on one image names the versioned label instead of the alias. The full per-label list is on the [macOS runner catalog](/runners/macos). A workflow that builds a Swift binary for both architectures looks like this. The build runs natively on ARM64 and cross-compiles the x86\_64 slice from the same machine: Copy Text ``` name: macos-universal-build on: pull_request: push: branches: [main] jobs: build: runs-on: warp-macos-26-arm64-12x steps: - uses: actions/checkout@v4 - name: Record the machine run: | uname -m arch sysctl -n hw.ncpu sw_vers -productVersion - name: List the Xcode versions on this image run: | ls -1 /Applications | grep '^Xcode' xcodebuild -version - name: Resolve Swift package dependencies run: swift package resolve - name: Build a universal binary run: swift build -c release --arch arm64 --arch x86_64 - name: Confirm both slices are present run: lipo -archs .build/apple/Products/Release/mytool - uses: actions/upload-artifact@v4 with: name: mytool-universal path: .build/apple/Products/Release/mytool ``` Keep the Xcode listing step in the log while you are moving labels around. It prints the exact application paths on the image, which is what you pin `DEVELOPER_DIR` to when a project needs one specific Xcode rather than the image default. ## [Detail](#detail)Copy Anchor Link ### [What changes inside a job when the machine is Apple Silicon](#what-changes-inside-a-job-when-the-machine-is-apple-silicon)Copy Anchor Link An Apple Silicon runner runs an arm64 kernel and an arm64 userspace, so the compiler emits arm64 instructions, the linker resolves arm64 objects, and the simulator runtimes are arm64 images. Nothing is translated on the way through. Five values are worth printing on your first run, because they are the ones a migrated workflow tends to assume: | Command | Value on an Apple Silicon runner | | ------------------------ | ------------------------------------- | | uname -m | arm64 | | arch | arm64 | | sysctl -n hw.ncpu | 6 on a 6x label, 12 on a 12x label | | swift -print-target-info | a triple beginning arm64-apple-macosx | | Homebrew prefix | /opt/homebrew | The Homebrew prefix is the most common source of a broken step. Scripts written against Intel Macs hardcode `/usr/local/bin/...` for tools installed by Homebrew, while the Apple Silicon prefix is `/opt/homebrew` ([Homebrew installation docs](https://docs.brew.sh/Installation)). Call the tool by name and let `PATH` resolve it, or read the prefix with `brew --prefix` instead of writing the path in. The second source is a downloaded binary. Any step that fetches a release asset, a linter, or a code-signing helper by URL needs the `arm64` or `universal` asset rather than the `x86_64` one. The failure shows up as a bad CPU type error rather than as a missing file, so it is easy to misread as a permissions problem. Cross-compilation runs in the other direction from what teams expect. Producing an x86\_64 slice on an Apple Silicon runner is a build flag, as in the `swift build --arch arm64 --arch x86_64` step above, or `ARCHS="arm64 x86_64"` for `xcodebuild`. Verify the result with `lipo -archs` in the same job so a single-slice binary never reaches a release. The tooling on each image is the set published by GitHub for the matching upstream image, so an existing Xcode workflow works as a drop-in target. Package lists are per image: [macOS 14 ARM64](https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md), [macOS 15 ARM64](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-arm64-Readme.md), and [macOS 26 ARM64](https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md). The macOS 26 labels also ship Xcode 27.0 (build 27A266a) with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes, an addition on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 runner image is in beta. A dedicated macOS 27 image follows once that image is released. The per-image matrix is on the [Xcode image reference](/runners/macos/xcode-images). Tailscale is preinstalled on every runner image, including macOS, and the daemon starts on demand when networking is configured for the runner ([preinstalled software documentation](/docs/ci/preinstalled-software)). ### [Choosing between the two configurations](#choosing-between-the-two-configurations)Copy Anchor Link Both configurations bill at $0.0133 per vCPU-minute, so the question is whether the job can use twelve cores and whether it needs the disk. Xcode work parallelizes unevenly. Compiling a large module graph and running a test plan split across several simulators both spread across cores, while a single simulator boot, a code-signing step, and an archive upload do not. A job dominated by the serial steps spends the same wall clock on 12x as on 6x and bills twice as much for it, so measure the step timings before you resize. Disk is the specification that decides more macOS failures than cores. Derived data, a resolved Swift package checkout, several simulator runtimes, and an archive of a large app add up quickly against the 120GB SSD on the 6x configuration. The 12x configuration takes that to 270GB. If a workflow currently spends steps deleting simulator runtimes or clearing derived data to stay under a ceiling, the 12x size removes the reason for those steps and the deleted minutes come back off the bill. Runner storage is ephemeral in both cases and is deleted when the runner terminates, so `.xcresult` bundles, crash logs, and build products go to an artifact before the job ends. ### [Three limits to plan around](#three-limits-to-plan-around)Copy Anchor Link **No Docker.** macOS runners do not support nested virtualization and cannot run Docker ([cloud runners documentation](/docs/ci/cloud-runners)). A pipeline that builds an iOS app and then packages a container image needs two jobs on two platforms. **The container half moves elsewhere.** Container builds go to a Linux runner or to a remote Docker builder while the macOS job keeps Xcode, Swift, and the simulators. **Concurrency is quota governed.** Since July 27, 2026, concurrency on the macOS fleet is governed by per-organization quotas, and jobs beyond the quota wait for capacity to free up ([July 2026 changelog](/docs/ci/changelog/2026-july)). Size a wide macOS matrix against the quota your organization holds, and contact support to raise it rather than assuming the Linux and Windows behavior carries over. ### [What an Apple Silicon minute costs](#what-an-apple-silicon-minute-costs)Copy Anchor Link Both sides below are list prices. The WarpBuild rate comes from the [cloud runners documentation](/docs/ci/cloud-runners). The GitHub rate comes from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing), checked on 2026-08-13. | Label | Shape | Per minute | Per vCPU-minute | GitHub-hosted comparison | GitHub per minute | GitHub per vCPU-minute | | --------------------------- | ------------- | ---------- | --------------- | ---------------------------------------------------------- | ----------------- | ---------------------- | | warp-macos-latest-arm64-6x | 6 vCPU, 14GB | $0.08 | $0.0133 | Largest GitHub-hosted macOS ARM64 runner, 5 vCPU with 14GB | $0.102 | $0.0204 | | warp-macos-latest-arm64-12x | 12 vCPU, 28GB | $0.16 | $0.0133 | No shape match at 12 vCPU | not compared | not compared | `warp-macos-latest-arm64-6x` at 6 vCPU and 14GB costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14GB: 22 percent lower list price, with one more vCPU and the same memory on the WarpBuild side. GitHub list price checked on 2026-08-13. **Worked model: one iOS repository.** The repository queues 1,500 macOS jobs a month at 9 minutes each on a 6 vCPU runner. That is 13,500 macOS minutes. * `warp-macos-latest-arm64-6x` at $0.08 per minute: 13,500 x $0.08 = **$1,080.00 per month**. * Largest GitHub-hosted macOS ARM64 runner at $0.102 per minute: 13,500 x $0.102 = **$1,377.00 per month**. * Difference: $297.00 per month, $3,564.00 over twelve months, which is 22 percent lower list price (GitHub pricing, checked on 2026-08-13). * The $10 in signup credits covers the first $10 of that bill, so month one lands at $1,070.00 out of pocket at this volume. Every cost statement on this page carries a number, a source link, and a checked-on date, and the same numbers appear on the [macOS runner cost guide](/guides/github-actions-macos-runner-cost), where you can run the model against your own job count and minute length. ## [Related Questions](#related-questions)Copy Anchor Link **Are GitHub Actions macOS runners ARM64 or x86-64?** ARM64\. Every macOS label in the WarpBuild catalog carries the `arm64` architecture segment and runs on Apple Silicon, so `uname -m` prints `arm64` and the toolchain produces arm64 slices without emulation. The label list is on the [macOS runner catalog](/runners/macos). **What sizes do Apple Silicon runners come in?** Two configurations. 6 vCPU with 14GB of memory and a 120GB SSD at $0.08 per minute, and 12 vCPU with 28GB of memory and a 270GB SSD at $0.16 per minute. Both work out to $0.0133 per vCPU-minute, so the choice is about cores and disk rather than about rate efficiency. **Can an Apple Silicon runner run Docker?** No. macOS runners do not support nested virtualization and cannot run Docker. Container builds move to Linux runners or to remote Docker builders, and the macOS job keeps the Xcode, Swift, and simulator work. The [macOS support answer](/answers/do-github-actions-runners-support-macos) covers the job split. **How many Apple Silicon jobs can run at once?** Concurrency on the macOS fleet is governed by per-organization quotas as of July 27, 2026\. Jobs beyond the quota wait for capacity to free up, and support raises the quota on request ([July 2026 changelog](/docs/ci/changelog/2026-july)). Pick a label from the [macOS runner catalog](/runners/macos), check the Xcode set for that image on the [Xcode image reference](/runners/macos/xcode-images), price the change on the [pricing page](/pricing), and change one `runs-on` line to move the job. --- # Do Managed Runners Change My GitHub Permissions Model? | WarpBuild URL: https://www.warpbuild.com/answers/does-using-managed-runners-change-my-github-permissions-model Description: No. Managed runners install as a GitHub App with a scoped permission set, and job level access stays governed by GITHUB_TOKEN and your permissions block. # Do Managed Runners Change My GitHub Permissions Model? No. Managed runners install as a GitHub App with a scoped permission set, and job level access stays governed by GITHUB\_TOKEN and your permissions block. No. Adopting a managed runner platform adds one GitHub App installation with a scoped, reviewable permission set at the organization or repository level, and it leaves the permissions inside a workflow run alone: the `GITHUB_TOKEN` your steps use is still minted by GitHub Actions for that run, and it still carries whatever the [permissions block](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#permissions) in your workflow file resolves to (GitHub workflow syntax reference, checked on 2026-08-13). Two separate permission surfaces are in play, and the confusion usually comes from treating them as one. The installation surface governs what the platform may do against your GitHub account. The workflow surface governs what your own jobs may do, and adopting managed runners does not touch it. ## [Answer](#answer)Copy Anchor Link Here is each control, who owns it, and whether adopting WarpBuild runners changes it. | Control | Who owns it | Where it is configured | Changes with managed runners | | ------------------------------------------ | ----------------------------- | --------------------------------------------------- | --------------------------------------------- | | App permission set | GitHub, at app registration | The app's declared permissions, approved at install | New surface, reviewable before you accept | | Repository access for the app | Your org or repo admin | GitHub installation settings | New surface, all repositories or a named list | | GITHUB\_TOKEN default scope | Your org or repo admin | Repository and organization Actions settings | No change | | permissions block in the workflow | The workflow author | The workflow file | No change | | Repository and organization secrets | Your repo and org admins | GitHub secrets storage | No change | | OIDC claims and cloud trust policies | GitHub and your cloud account | The id-token request and your role trust policy | No change | | Environment protection rules and reviewers | Your repo admins | GitHub environments | No change | | Token on pull requests from forks | GitHub | Fixed read only behavior | No change | | runs-on label | The workflow author | The workflow file | The one edit a job needs | The installation row is the only one that adds anything. A GitHub App has its own identity and its own declared permissions, which an installer approves at install time and can inspect afterwards, described further in [GitHub App](/glossary/github-app). To manage self-hosted runners, the permission set covers read on repository Metadata, read on repository Actions so the app receives the `workflow_job` event and reads run state, read and write on repository Administration for repository level runners, and read and write on the organization Self-hosted runners permission for organization level runners and runner groups, per GitHub's [permissions required for GitHub Apps](https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps) reference, checked on 2026-08-13\. Repository Contents sits outside that set. Secrets stay on the GitHub side of the boundary. WarpBuild does not access or store any build secrets; they are stored in your source code repository and reach only your runner environment, documented in the [runner security reference](/docs/ci/security). ## [Detail](#detail)Copy Anchor Link ### [What the installation is used for](#what-the-installation-is-used-for)Copy Anchor Link You sign up, then install the app from the WarpBuild dashboard and pick the repositories it may reach, which is the flow in the [quick start](/docs/ci/quick-start). From then on the app has one job: it receives the queued-job webhook, provisions a virtual machine, registers an ephemeral runner against your repository or organization, and tears the machine down when the job ends. Each runner gets its own virtual machine and its own encrypted volume, both destroyed after the build, per the [runner security reference](/docs/ci/security). If your organization uses a GitHub IP allow list, add the three WarpBuild control plane egress IPs, `3.13.16.127`, `3.141.157.169`, and `3.151.226.250`, listed in the [GitHub Enterprise docs](/docs/ci/ghe#control-plane-egress-ips) and checked on 2026-08-13\. That is an allow list edit rather than a permission grant. ### [The workflow-side controls that stay in your hands](#the-workflow-side-controls-that-stay-in-your-hands)Copy Anchor Link A job moved to WarpBuild changes one line. Everything governing what the job may do stays where it was: Copy Text ``` name: build on: pull_request: permissions: contents: read packages: write id-token: write jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/ci-deploy aws-region: us-east-1 ``` The `permissions` key resolves the same way it did before, and the same replacement rule applies: naming any scope drops every scope left out to `none`, covered in [workflow permissions](/glossary/workflow-permissions). The OIDC token requested by `id-token: write` is issued by GitHub Actions with the repository and ref claims your trust policy already matches on, per GitHub's [OIDC hardening guide](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect), checked on 2026-08-13\. The runner platform sits outside that exchange. Runs triggered by a pull request from a fork keep their read only token, per GitHub's [events that trigger workflows](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows) reference, checked on 2026-08-13, and environment protection rules keep gating deployments the same way GitHub's [environments documentation](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment) describes, checked on 2026-08-13\. This model is identical across all four. ### [What a reviewer can check afterwards](#what-a-reviewer-can-check-afterwards)Copy Anchor Link Repository access and removal stay under your control through GitHub's [reviewing installed apps](https://docs.github.com/en/apps/using-github-apps/reviewing-and-modifying-installed-github-apps) settings, checked on 2026-08-13\. If an app later asks for a permission it did not declare, every existing installation has to approve the change before it takes effect, so the permission set cannot widen quietly. ### [The GitHub Enterprise Server path](#the-github-enterprise-server-path)Copy Anchor Link On GitHub Enterprise Server and on GitHub Enterprise Cloud with data residency, you register the GitHub App on your own enterprise instead of installing a shared one, on the enterprise plan. WarpBuild supplies a manifest so GitHub prefills the name, webhook URL, callback URL, permissions, and events, and your enterprise owns the resulting app, its App ID, client secret, webhook secret, and private key. Those credentials rotate through the Edit GHE app dialog without reinstalling. Leave the permissions and events as the manifest created them, since editing them can fail job runs. Steps are in the [GitHub Enterprise docs](/docs/ci/ghe) and the shape of the rollout is in [runners for GitHub Enterprise Server and Cloud](/enterprise/github-enterprise-runners). ### [What the change costs](#what-the-change-costs)Copy Anchor Link On a workload of 6,000 minutes a month at 4 vCPU: | Line item | Rate | Monthly | | ------------------------------------------------------- | ----------------- | ------- | | warp-ubuntu-latest-x64-4x, 6,000 minutes | $0.008 per minute | $48.00 | | GitHub-hosted 4-core Linux larger runner, 6,000 minutes | $0.012 per minute | $72.00 | WarpBuild rates come from the [pricing page](/pricing). The GitHub rate comes from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. The arithmetic is (0.012 - 0.008) / 0.012, which is 33 percent lower list price at the same 4 vCPU and 16 GB shape. ## [Related Questions](#related-questions)Copy Anchor Link **Does WarpBuild need write access to our source code?** No. The permission set a GitHub App needs to manage self-hosted runners covers read on repository Metadata, read on repository Actions, read and write on repository Administration for repository level runners, and read and write on the organization Self-hosted runners permission for organization level runners, per GitHub's [permissions reference](https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps). Repository Contents sits outside that set, and `actions/checkout` clones with the token GitHub mints for the run, described in [GitHub App](/glossary/github-app). **Do we have to change the permissions block in our workflows?** No. The only edit a job needs is the `runs-on` value. The `permissions` key, the repository or organization default it falls back to, environment protection rules, and the read only token on pull requests from forks all keep behaving as documented, covered in [workflow permissions](/glossary/workflow-permissions). **Does OIDC to our cloud account still work?** Yes. The `id-token` is minted by GitHub Actions for the run, so a trust policy keyed on the repository and ref claims keeps matching. The job still needs `id-token: write` in its permissions block, the same requirement it had on GitHub-hosted runners. **Who can install or remove the app?** An organization owner or a repository admin, through GitHub's installation settings. Removing the installation revokes its tokens and stops runner provisioning, which is the rollback answer most reviewers ask for. Reviewers working through a formal questionnaire will want the [security review material](/enterprise/ci-security-review), and enterprise instances follow [runners for GitHub Enterprise Server and Cloud](/enterprise/github-enterprise-runners). Review the permission set in the [runner security reference](/docs/ci/security), price the move on the [pricing page](/pricing), and install against a single repository first to keep the blast radius small. --- # Is Visual Studio Preinstalled on Windows Runners? | WarpBuild URL: https://www.warpbuild.com/answers/does-visual-studio-come-preinstalled-on-github-actions-runners Description: Yes. Every WarpBuild Windows runner image ships Visual Studio Enterprise. The vs2026 labels carry the 2026 edition, the other Windows labels carry 2022. # Is Visual Studio Preinstalled on Windows Runners? Yes. Every WarpBuild Windows runner image ships Visual Studio Enterprise. The vs2026 labels carry the 2026 edition, the other Windows labels carry 2022. Yes, every WarpBuild Windows runner image ships a full Visual Studio Enterprise install, and the `runs-on` label decides which edition you get: the `warp-windows-2025-vs2026-x64-` labels carry Visual Studio 2026, while the Windows Server 2022 labels and the other Windows Server 2025 labels carry Visual Studio 2022 ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). The images carry the same tooling as the GitHub-hosted Windows images, so the Visual Studio install, MSBuild, the Windows SDKs, and the MSVC toolsets are on the machine before your first step runs ([preinstalled software documentation](/docs/ci/preinstalled-software)). ## [Answer](#answer)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Windows fleet is x86-64 and splits into three image families that share one set of four machine shapes and one set of rates. | Runner label | Image | Visual Studio | vCPU and memory | Per minute | | -------------------------------- | ----------------------------- | --------------- | --------------- | ---------- | | warp-windows-latest-x64-4x | Windows Server 2022 | Enterprise 2022 | 4 vCPU, 16 GB | $0.016 | | warp-windows-latest-x64-8x | Windows Server 2022 | Enterprise 2022 | 8 vCPU, 32 GB | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | Enterprise 2022 | 16 vCPU, 64 GB | $0.064 | | warp-windows-latest-x64-32x | Windows Server 2022 | Enterprise 2022 | 32 vCPU, 128 GB | $0.128 | | warp-windows-2025-x64-4x | Windows Server 2025 | Enterprise 2022 | 4 vCPU, 16 GB | $0.016 | | warp-windows-2025-x64-8x | Windows Server 2025 | Enterprise 2022 | 8 vCPU, 32 GB | $0.032 | | warp-windows-2025-x64-16x | Windows Server 2025 | Enterprise 2022 | 16 vCPU, 64 GB | $0.064 | | warp-windows-2025-x64-32x | Windows Server 2025 | Enterprise 2022 | 32 vCPU, 128 GB | $0.128 | | warp-windows-2025-vs2026-x64-4x | Windows Server 2025 (VS 2026) | Enterprise 2026 | 4 vCPU, 16 GB | $0.016 | | warp-windows-2025-vs2026-x64-8x | Windows Server 2025 (VS 2026) | Enterprise 2026 | 8 vCPU, 32 GB | $0.032 | | warp-windows-2025-vs2026-x64-16x | Windows Server 2025 (VS 2026) | Enterprise 2026 | 16 vCPU, 64 GB | $0.064 | | warp-windows-2025-vs2026-x64-32x | Windows Server 2025 (VS 2026) | Enterprise 2026 | 32 vCPU, 128 GB | $0.128 | Labels, images, and rates come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. Every size carries 256GB of SSD storage, and the 2 vCPU Windows tier was removed on June 8, 2026, so 4 vCPU is the smallest Windows shape. The [Windows runner hub](/runners/windows) lists the aliases and the rest of the fleet detail. Two things follow from that table. Price does not move with the Visual Studio version, so choosing the image your solution needs is a toolset decision rather than a budget decision. And `warp-windows-latest-x64-` resolves to Windows Server 2022 today, so a job that names `latest` is asking for Visual Studio 2022 whether or not that was the intent. The `vs2026` labels are transitional. They run the same Windows Server 2025 base image with Visual Studio 2026 installed in place of Visual Studio 2022, and Visual Studio 2026 may become the default on the Windows Server 2025 labels in a later update, tracking [GitHub's rollout of the Windows Server 2025 with Visual Studio 2026 image](https://github.com/actions/runner-images/issues/14016). Pin the explicit label when the toolset version decides whether your build compiles. ## [Detail](#detail)Copy Anchor Link ### [The edition and the install path differ between the two families](#the-edition-and-the-install-path-differ-between-the-two-families)Copy Anchor Link Both families install the Enterprise edition, and they install it in different places. The upstream image readmes record the exact instance, and WarpBuild tracks those images. | Image | Product | Installation path | Readme | | ----------------------------- | -------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | | Windows Server 2022 | Visual Studio Enterprise 2022, 17.14.37516.0 | C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise | [Windows2022-Readme.md](https://github.com/actions/runner-images/blob/main/images/windows/Windows2022-Readme.md) | | Windows Server 2025 | Visual Studio Enterprise 2022, 17.14.37516.0 | C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise | [Windows2025-Readme.md](https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md) | | Windows Server 2025 (VS 2026) | Visual Studio Enterprise 2026, 18.8.12023.21 | C:\\Program Files\\Microsoft Visual Studio\\18\\Enterprise | [Windows2025-VS2026-Readme.md](https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md) | Version numbers advance with each image release, so treat the readmes as the per-build inventory and this table as the shape of the answer. The path is the part that breaks workflows. Visual Studio 2026 installs under a major-version directory, `18`, rather than a year directory, so any step with a hardcoded `\2022\Enterprise` path, including a `vcvarsall.bat` call or a direct `MSBuild.exe` path, stops resolving the moment the job moves to a `vs2026` label. Resolve the path at run time instead of writing it into the workflow. Component coverage also differs, and three differences account for most first-run failures on a new label: * The Windows Server 2022 image lists `Microsoft.VisualStudio.Component.Windows10SDK.19041` and `Microsoft.VisualStudio.Component.Windows11SDK.22621` alongside `Windows11SDK.26100`. Both Windows Server 2025 images list `Windows11SDK.26100` without those two, so a project pinned to an older `WindowsTargetPlatformVersion` needs either an SDK install step or the Windows Server 2022 label. * The Visual Studio 2026 image adds `Microsoft.VisualStudio.Component.VC.14.44.17.14.x86.x64`, which puts the 17.14 MSVC toolset on the machine side by side with the 2026 default. A solution that pins `v143` can build on the 2026 image through that component. * `Microsoft.VisualStudio.Component.VC.Tools.ARM`, the 32-bit ARM MSVC toolset, is listed on both Visual Studio 2022 images and is absent from the Visual Studio 2026 readme. Cross-compiling to that target keeps the job on a Visual Studio 2022 label. The [Visual Studio image page](/runners/windows/visual-studio-images) tracks these per-image differences as the readmes change, and the [preinstalled software page](/runners/preinstalled-software) links the readme for every WarpBuild image in one place. ### [Check the workloads from inside the job with vswhere](#check-the-workloads-from-inside-the-job-with-vswhere)Copy Anchor Link Reading a readme tells you what the image shipped with. `vswhere.exe` tells you what is on the machine your job actually booted, and it is the check to run before you assume a component exists. Microsoft ships it with the installer at a fixed path, `%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe`, on every install of Visual Studio 2017 version 15.2 and later ([microsoft/vswhere](https://github.com/microsoft/vswhere)). The Windows Server 2025 images list vswhere 3.1.7 in their tool inventory. Copy Text ``` name: windows-build on: pull_request: branches: [main] jobs: build: runs-on: warp-windows-2025-vs2026-x64-8x steps: - uses: actions/checkout@v4 - name: Record the Visual Studio instance in the job log shell: pwsh run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" & $vswhere -latest -products * -format json | ConvertFrom-Json | Select-Object displayName, installationVersion, installationPath - name: Fail early when the MSVC workload is missing shell: pwsh run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" $root = & $vswhere -latest -products * ` -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` -property installationPath if (-not $root) { throw "The x86 and x64 MSVC toolset is not installed on this image" } "VSINSTALLROOT=$root" >> $env:GITHUB_ENV - name: Build shell: pwsh run: | & "$env:VSINSTALLROOT\MSBuild\Current\Bin\MSBuild.exe" App.sln ` /m /p:Configuration=Release ``` Three flags carry the work. `-products *` widens the search past the Community, Professional, and Enterprise IDEs so Build Tools installs are found as well. `-requires ` filters to instances that carry a component, taking the same IDs the image readmes list. `-property installationPath` prints the root instead of the full record, which is what a later step wants for building a path. Add `-prerelease` when the instance you need comes from a preview channel, because vswhere skips prerelease instances by default. The `-find` flag matches file patterns under the selected instances, so `-find MSBuild\**\Bin\MSBuild.exe` resolves the compiler binary without you writing the version directory yourself. Running that check as its own step costs a few seconds and puts the toolset that compiled the artifact in the log, which is the record you want when a build starts failing after an image release. ### [Installing a missing workload at job time is a recurring bill](#installing-a-missing-workload-at-job-time-is-a-recurring-bill)Copy Anchor Link Runners are ephemeral. Storage is deleted when the runner terminates ([cloud runners documentation](/docs/ci/cloud-runners)), so a Visual Studio workload installed by a workflow step is installed again on the next job, and again on every job after that. The cost is the runner rate multiplied by the install time multiplied by the job count. | Windows size | Per minute | Each added minute across 1,000 jobs a month | Across 5,000 jobs a month | | --------------- | ---------- | ------------------------------------------- | ------------------------- | | 4 vCPU, 16 GB | $0.016 | $16.00 | $80.00 | | 8 vCPU, 32 GB | $0.032 | $32.00 | $160.00 | | 16 vCPU, 64 GB | $0.064 | $64.00 | $320.00 | | 32 vCPU, 128 GB | $0.128 | $128.00 | $640.00 | Read a row as the monthly cost of one minute of install time. A workflow that spends four minutes running `vs_installer.exe modify` on the 8 vCPU label across 1,000 jobs a month pays $128.00 a month for the install alone, on top of the build it was trying to run, and those four minutes also sit on the wall clock of every pull request. Selecting the label that already carries the component costs nothing extra, because all three image families price identically at each size. Between `warp-windows-2025-x64-8x` and `warp-windows-2025-vs2026-x64-8x` the bill is $0.032 per minute either way. That is the reason to spend the vswhere check once, learn which family your solution needs, and encode it in `runs-on` rather than in an install step. Where a standalone toolchain is still the right call, such as a container image you build yourself, the [Build Tools guide](/guides/visual-studio-build-tools-github-actions) covers that path. ## [Related Questions](#related-questions)Copy Anchor Link **Which Visual Studio edition is preinstalled on the Windows runner images?** Enterprise. The Windows Server 2022 and Windows Server 2025 images carry Visual Studio Enterprise 2022 under `C:\Program Files\Microsoft Visual Studio\2022\Enterprise`, and the `vs2026` images carry Visual Studio Enterprise 2026 under `C:\Program Files\Microsoft Visual Studio\18\Enterprise`. Per-image component lists are on the [Visual Studio image page](/runners/windows/visual-studio-images). **Does the Visual Studio 2026 image cost more per minute?** No. All three Windows image families are priced the same at each size, from $0.016 per minute at 4 vCPU to $0.128 per minute at 32 vCPU, so moving a job from `warp-windows-2025-x64-8x` to `warp-windows-2025-vs2026-x64-8x` changes the toolset while the rate stays the same. Sizes and rates are on the [Windows runner hub](/runners/windows). **How do I check which workloads are installed before the build step runs?** Run `vswhere.exe` from `C:\Program Files (x86)\Microsoft Visual Studio\Installer` with `-requires` and the component ID you need. It prints the installation path when the component is present and nothing when it is absent, so the job can fail in seconds instead of failing inside MSBuild several minutes later. Pick a Windows label on the [Windows runner hub](/runners/windows), confirm the components on the [Visual Studio image page](/runners/windows/visual-studio-images), and price your own Windows minutes on the [pricing page](/pricing). --- # How Are Snapshot Storage Costs Billed? | WarpBuild URL: https://www.warpbuild.com/answers/how-are-snapshot-runner-storage-costs-billed Description: Snapshot storage bills $0.025 per snapshot-hour for every hour a snapshot exists, so the count of live aliases and the 15 day lifetime set the monthly total. # How Are Snapshot Storage Costs Billed? Snapshot storage bills $0.025 per snapshot-hour for every hour a snapshot exists, so the count of live aliases and the 15 day lifetime set the monthly total. Snapshot storage bills at $0.025 per snapshot-hour for every hour a snapshot exists, which makes it a wall-clock charge rather than a per-job one, so the monthly figure is the number of live snapshots multiplied by the hours they stay alive. Snapshots are deleted after 15 days per the [snapshot runners documentation](/docs/ci/features/snapshot-runners), which caps one forgotten snapshot at $9.00 (15 days times 24 hours times $0.025), and the daywise storage cost endpoint reports what actually accrued each day. ## [Answer](#answer)Copy Anchor Link Two metered lines sit on top of runner minutes when a workflow uses snapshots, both from the [pricing page](/pricing), checked on 2026-08-13. | Line item | Rate | What drives it | | ---------------- | ------------------------ | -------------------------------------------- | | Snapshot restore | $0.04 per job | Number of jobs that boot from a snapshot | | Snapshot storage | $0.025 per snapshot-hour | Number of snapshots alive, times hours alive | Restore is the line most teams budget for, because it moves with pipeline volume and appears per job. Storage is the line that surprises them, because it accrues on a repository that ran nothing all week. An alias created before a two week holiday bills $0.60 per day until the 15 day lifetime deletes it. The rate carries no per-gigabyte term. A snapshot of a 40 GB disk and a snapshot of a 120 GB disk bill the same $0.025 for the same hour, so the two variables you control are how many snapshots you keep and how long each one lives. Disk size still matters for other reasons: capture time rises with the disk, which is why `WarpBuilds/snapshot-save` takes a `wait-timeout-minutes` input that defaults to 30, and a snapshot boot takes 45 to 60 seconds per the [snapshot runners documentation](/docs/ci/features/snapshot-runners). That gives a small reference table you can read straight off the rate. | Snapshots alive | Per day | Full 15 day lifetime | 30 day month, refreshed | | --------------- | ------- | -------------------- | ----------------------- | | 1 | $0.60 | $9.00 | $18.00 | | 5 | $3.00 | $45.00 | $90.00 | | 10 | $6.00 | $90.00 | $180.00 | | 25 | $15.00 | $225.00 | $450.00 | The middle column is the ceiling on anything nobody deletes, since the 15 day lifetime ends the meter on its own. The right column applies to an alias a workflow keeps refreshing, which never reaches expiry and therefore bills for the whole month. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, so this bill exists on part of the catalog, per the [snapshot runners documentation](/docs/ci/features/snapshot-runners). A snapshot label applied to a Windows, macOS, or BYOC runner is silently ignored, which also means it produces no storage charge. ## [Detail](#detail)Copy Anchor Link ### [The endpoint that reports storage by day](#the-endpoint-that-reports-storage-by-day)Copy Anchor Link Snapshot restore shows up per job in the CI Billing report, where the summary cards break out total cost, runner cost, snapshot cost, and total jobs, and each table row carries a cost breakdown of runner plus snapshot, per the [reports documentation](/docs/ci/features/reports). Storage does not attach to a job, so it needs its own read path. It authenticates with a bearer API key created from the API keys page, the same key type the [automation documentation](/docs/ci/api-keys/automation) uses for the runner and runner image endpoints. Copy Text ``` curl -X GET 'https://api.warpbuild.com/api/v1/runner-images/daywise-snapshot-storage-costs?start_date=2026-07-01&end_date=2026-07-31' \ -H 'Accept: application/json' \ -H 'Authorization: Bearer wkey-xxxx' ``` Both query parameters are required. The response is an array with one object per day, and each object carries three fields. | Field | Type | Meaning | | ------------------ | ------ | ---------------------------------------- | | date | string | The day the storage was metered | | amount | number | Storage charge for that day alone | | cumulative\_amount | number | Running total across the requested range | A two day window for an account that held one snapshot on the first day and two on the second reads like this, and the arithmetic is the flat rate applied to snapshot-hours: 24 hours at $0.025 is $0.60, and 48 snapshot-hours is $1.20. Copy Text ``` [ { "date": "2026-07-01", "amount": 0.6, "cumulative_amount": 0.6 }, { "date": "2026-07-02", "amount": 1.2, "cumulative_amount": 1.8 } ] ``` The `cumulative_amount` field is what makes this useful for a budget check in a scheduled workflow: read the last element of the array for month-to-date without summing on the client. The endpoint is marked alpha in the API reference, so pin your tooling to a checked response shape and re-read the reference before relying on it in a billing alert. ### [A worked model: snapshot count against retention policy](#a-worked-model-snapshot-count-against-retention-policy)Copy Anchor Link Take a repository running 1,000 snapshot jobs a month and price three alias policies against each other. Restore fees are identical in all three at 1,000 jobs times $0.04, which is $40.00\. Storage is what separates them. | Policy | Snapshots alive | Storage per month | Restore fees | Snapshot total | | ----------------------------------------------------- | ------------------------- | -------------------- | ------------ | -------------- | | One alias refreshed on the default branch | 1 | $18.00 | $40.00 | $58.00 | | One alias per open feature branch, 12 open at a time | 12 | $216.00 | $40.00 | $256.00 | | Per branch aliases left to expire, 40 merged branches | 12 live plus 40 aging out | $216.00 plus $360.00 | $40.00 | $616.00 | The third row is the one worth staring at. Forty snapshots that nobody deletes each live out the full 15 days at $9.00, which adds $360.00 of storage for branches that were merged and forgotten. The first row costs $58.00 for the same 1,000 jobs. Spread across jobs, the same numbers give a per-job figure to compare against the restore fee. One alias at $18.00 a month divided by 1,000 jobs adds $0.018 per job, below the $0.04 restore fee. The same alias serving 50 jobs a month adds $0.36 per job against a $0.04 restore fee, and that $0.36 usually erases the runner minutes the snapshot saved. Low traffic repositories are where alias sprawl hurts most. ### [Keeping aliases from accumulating](#keeping-aliases-from-accumulating)Copy Anchor Link The pattern the [snapshot runners documentation](/docs/ci/features/snapshot-runners) documents for feature branch work is one alias per project, refreshed by every run, rather than one alias per branch. Each push boots from the previous snapshot for that alias and writes a new one, so state compounds run over run while the live snapshot count stays flat. Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-4x;snapshot.key=my-project-snapshot steps: - uses: actions/checkout@v5 - name: Install dependencies run: npm ci - name: Build run: npm run build - name: Cleanup credentials run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot uses: WarpBuilds/snapshot-save@v1 with: alias: "my-project-snapshot" fail-on-error: true wait-timeout-minutes: 60 ``` The shape that grows your bill is an alias interpolated from the branch name, such as `snapshot.key=deps-${{ github.head_ref }}`. It mints a snapshot for every branch that ever runs the workflow and leaves each one to age out over 15 days, which is exactly the third row of the table above. Reach for per branch aliases only where branches genuinely need isolated disk state, and expect to pay $9.00 per branch for the privilege. For anything that needs deleting before the lifetime expires, the API reference lists list, get, and delete operations under the snapshotter tag, including [DELETE /snapshots/{id}](/docs/api/snapshotter/DeleteSnapshot), all marked alpha. ### [What the storage line never adds](#what-the-storage-line-never-adds)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the storage line moves with snapshot-hours and nothing else. Signup includes $10 free credits, which covers roughly 400 snapshot-hours while you measure how many aliases your workflows actually keep alive. Every rate on this page comes from the [pricing page](/pricing) and was checked on 2026-08-13. ## [Related Questions](#related-questions)Copy Anchor Link ### [Do I pay for snapshot storage on days when no job runs?](#do-i-pay-for-snapshot-storage-on-days-when-no-job-runs)Copy Anchor Link Yes. Storage bills $0.025 per snapshot-hour for every hour a snapshot exists, whether or not a job boots from it. One snapshot left alive costs $0.60 per day and $18.00 over a 30 day month, and the restore fee of $0.04 per job is the only part of the snapshot bill tied to job count. Both rates are listed on the [WarpBuild pricing page](/pricing), and [How long do runner snapshots last?](/answers/how-long-do-runner-snapshots-last) covers the lifetime that ends the meter. ### [Does a larger snapshot cost more to store?](#does-a-larger-snapshot-cost-more-to-store)Copy Anchor Link The published rate is $0.025 per snapshot-hour and carries no per-gigabyte term, so a 40 GB snapshot and a 120 GB snapshot bill the same for the same hours. Disk size shows up in two other places. Capture takes longer, which is what the `wait-timeout-minutes` input on the save action controls, and a snapshot boot takes 45 to 60 seconds. The [snapshot runners page](/runners/snapshot) lists the Ubuntu sizes that accept snapshot labels and their per-minute rates. ### [How do I see snapshot storage cost broken out by day?](#how-do-i-see-snapshot-storage-cost-broken-out-by-day)Copy Anchor Link Call GET /api/v1/runner-images/daywise-snapshot-storage-costs with the required `start_date` and `end_date` query parameters. The response is an array with one object per day carrying `date`, `amount`, and `cumulative_amount`, so month-to-date arrives without summing on the client. Retiring an alias early is covered in [How do I invalidate a runner snapshot?](/answers/how-do-i-invalidate-a-runner-snapshot). --- # How Do I Attach Build Provenance to an Image? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-attach-build-provenance-in-github-actions Description: Set provenance mode=max on the build and push step so the builder attaches the attestation, then sign the pushed digest from the workflow with actions/attest. # How Do I Attach Build Provenance to an Image? Set provenance mode=max on the build and push step so the builder attaches the attestation, then sign the pushed digest from the workflow with actions/attest. Enable provenance output on the build itself: set `provenance: mode=max` on the build and push step and push the image straight to a registry, and the builder produces a SLSA provenance attestation and pushes it into the same image index ([Docker provenance attestations](https://docs.docker.com/build/metadata/attestations/slsa-provenance/), checked on 2026-08-13). Then add a second attestation from the workflow with `actions/attest`, which binds the pushed digest to the workflow identity, signs it with a short-lived Sigstore certificate, and uploads it to GitHub's attestations API ([actions/attest](https://github.com/actions/attest), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link Two producers write provenance for one image, and they are complementary. | Producer | Where it runs | What it records | Where it lands | How it is read | | ----------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | BuildKit, via provenance: mode=max | the builder that executed the build | materials, source revision, build platform, and at max the build steps | the image index in the registry, as an attestation manifest | docker buildx imagetools inspect IMAGE --format "{{ json .Provenance }}" | | GitHub artifact attestation, via actions/attest | the runner, after the push | the subject digest bound to the workflow identity in the OIDC token | GitHub's attestations API, plus the registry when push-to-registry is true | gh attestation verify oci://IMAGE -R OWNER/REPO | Both fit in one job. This workflow builds on a WarpBuild remote Docker builder profile, pushes to GitHub Container Registry, and attests the digest that comes back. Copy Text ``` name: build-attested-image on: push: branches: [main] permissions: contents: read packages: write id-token: write attestations: write jobs: image: runs-on: warp-ubuntu-latest-x64-4x env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push id: push uses: Warpbuilds/build-push-action@v6 with: context: . push: true provenance: mode=max sbom: true tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} profile-name: "image-builder" - name: Attest the pushed digest uses: actions/attest@v4 with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true ``` Four details in that file carry weight. **`push: true` is required.** Attestations ride in the image index, and the local image store cannot load an image that has them, so a build with `load: true` or the `docker` exporter drops the attestation silently ([Docker attestations with GitHub Actions](https://docs.docker.com/build/ci/github-actions/attestations/), checked on 2026-08-13). **`mode=max` changes what the attestation says.** In `mode=min`, the default, BuildKit records build timestamps, the frontend used, build materials, the source repository and revision, the build platform, and reproducibility, while leaving out build argument values, secret identities, and rich layer metadata. In `mode=max` it adds the LLB definition of the build, the Dockerfile itself as a base64-encoded copy, and source maps tying build steps to image layers. The trade is that `mode=max` exposes build argument values, so move any credential to a secret mount before you turn it on ([Docker provenance attestations](https://docs.docker.com/build/metadata/attestations/slsa-provenance/)). Defaults already differ by repository visibility: `docker/build-push-action` attaches `mode=max` on public repositories and `mode=min` on private ones. Add `version=v1` to `provenance: mode=max,version=v1` if your consumers want the SLSA v1 schema rather than the v0.2 default. **`subject-name` takes the fully qualified image name with no tag.** The digest identifies the image; a tag in that field breaks the registry push of the attestation. **Permissions are the usual failure.** `id-token: write` mints the OIDC token, `attestations: write` persists the attestation, and `packages: write` covers the GHCR push. Adding `artifact-metadata: write` also creates a storage record on your organization's linked artifacts page ([actions/attest](https://github.com/actions/attest)). One plan constraint applies: artifact attestations work in public repositories on all current GitHub plans, private and internal repositories need GitHub Enterprise Cloud, and GitHub Enterprise Server does not support them. `actions/attest-build-provenance@v3` takes the same inputs if you already use it. From version 4 it is a thin wrapper around `actions/attest`, and new workflows should call `actions/attest` directly. ## [Detail](#detail)Copy Anchor Link ### [What a verifier can check afterwards](#what-a-verifier-can-check-afterwards)Copy Anchor Link The consumer side is one command: Copy Text ``` gh attestation verify oci://ghcr.io/OWNER/IMAGE:TAG -R OWNER/REPO ``` Verification succeeds only when a signed attestation for that digest exists, the Sigstore certificate chains back to a trusted root, and the certificate's identity matches what you asked for. The flags decide how strict "what you asked for" is ([gh attestation verify manual](https://cli.github.com/manual/gh%5Fattestation%5Fverify), checked on 2026-08-13). | Check | Flag | Question it settles | | -------------------- | ----------------------------------------------------------- | ----------------------------------------------------- | | Predicate type | \--predicate-type, defaults to SLSA provenance v1 | is this build provenance or an SBOM statement | | Signing workflow | \--signer-workflow OWNER/REPO/.github/workflows/release.yml | which workflow file signed the artifact | | Certificate identity | \--cert-identity, \--cert-identity-regex | exact match on the certificate SubjectAlternativeName | | Source commit | \--source-digest | which commit the artifact was built from | | Runner environment | \--deny-self-hosted-runners | whether the job ran on a GitHub-hosted runner | Inside the predicate, three facts do most of the work. The source commit arrives as a resolved dependency in SPDX download-location form, `git+https://github.com/OWNER/REPO@refs/heads/main` with the commit id. The build parameters arrive as external parameters naming the workflow repository, ref, and path, plus internal parameters carrying numeric ids for the actor, repository, and repository owner so renames cannot launder an identity. The builder identity arrives as `builder.id` ([SLSA GitHub Actions workflow build type](https://github.com/slsa-framework/github-actions-buildtypes/tree/main/workflow/v1)) and, in the signing certificate, as the Build Signer URI extension `1.3.6.1.4.1.57264.1.9` next to the Runner Environment extension `1.3.6.1.4.1.57264.1.11` ([Fulcio OID reference](https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md)). For the BuildKit half, read the attestation off the registry with `docker buildx imagetools inspect IMAGE --format "{{ json .Provenance }}"`. ### [How provenance behaves when the build runs off the runner](#how-provenance-behaves-when-the-build-runs-off-the-runner)Copy Anchor Link A remote Docker builder moves the build to a dedicated builder virtual machine that keeps its layer cache on local disk, while the runner keeps the workflow, the checkout, and the push credentials ([Docker builders documentation](/docs/ci/docker-builders)). That split lands cleanly on both attestations. BuildKit provenance is produced where the build ran. The builder writes the attestation for the layers it actually assembled and pushes it with the image, so the record stays accurate even though the runner never saw a `RUN` step ([Docker builders documentation](/docs/ci/docker-builders)). The GitHub attestation is produced on the runner from `steps.push.outputs.digest`, so it binds the same digest and the same workflow identity no matter which machine assembled the layers ([actions/attest](https://github.com/actions/attest)). Neither producer needs to know about the other. One consumer-side detail is worth planning for. WarpBuild registers as a self-hosted runner in the Default runner group of your GitHub organization ([public repositories documentation](/docs/ci/public-repos)), so the OIDC token that signs the attestation carries `runner_environment: self-hosted`, the same value ARC pods and your own registered machines produce. A verifier running `--deny-self-hosted-runners` will reject those attestations. Pin `--signer-workflow` or `--cert-identity` instead: naming the workflow file that signed is a tighter check than naming the machine class it ran on. If the build step runs on a runner that is not a WarpBuild runner, pass `api-key: ${{ secrets.WARPBUILD_API_KEY }}` to the build action so it can reach the builder profile. The job producing the attestation can sit anywhere in the catalog. Each runner runs in its own virtual machine that is created on demand and destroyed after the build, with an encrypted storage volume per runner ([security documentation](/docs/ci/security)). Provenance adds a signing step measured in seconds and no separate line item. You pay runner minutes and builder session minutes: `warp-ubuntu-latest-x64-4x` is $0.008 per minute, and the 16 vCPU, 32 GB, 100GB disk builder profile is $0.06 per minute, billed per session across the jobs sharing that profile ([Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13). A repository pushing 200 attested images a month, 4 minutes of builder session and 5 minutes of runner time each, pays 800 builder minutes at $0.06 for $48.00 plus 1,000 runner minutes at $0.008 for $8.00, so $56.00 in total. ## [Related Questions](#related-questions)Copy Anchor Link **Do I need both the BuildKit attestation and the GitHub attestation?** They answer different questions. The BuildKit attestation describes how the image was built, down to the build steps at `mode=max`. The GitHub attestation binds the pushed digest to a workflow identity signed by Sigstore, which is what `gh attestation verify` reads. Teams publishing images to external consumers usually ship both. The [provenance attestation glossary entry](/glossary/provenance-attestation) covers the format and the terms. **How do I add an SBOM alongside provenance?** Set `sbom: true` on the build and push step to attach a BuildKit SBOM attestation to the image, or pass `sbom-path` to `actions/attest` to sign an SPDX or CycloneDX file you generated yourself. Verifying it takes the `--predicate-type` flag, since SBOM statements use a different predicate from provenance. Details are in [generating an SBOM during a GitHub Actions build](/answers/how-do-i-generate-an-sbom-during-a-github-actions-build). **Does a remote builder change any of this?** No. The builder writes BuildKit provenance for the layers it built and pushes it with the image, and the attest step on the runner signs the returned digest. Builder profiles, sizes, and session billing are in the [remote Docker builder catalog](/runners/docker-builders), and the workflow shape around them is in the [Docker builds on GitHub Actions guide](/solutions/docker-builds-github-actions). Wire provenance into an existing image pipeline with the [Docker builds on GitHub Actions guide](/solutions/docker-builds-github-actions), price the builder profile against your own build minutes on the [pricing page](/pricing), and check the [Docker builders documentation](/docs/ci/docker-builders) for the action inputs. --- # How Do I Audit Runner Configuration Changes? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-audit-who-changed-runner-settings Description: WarpBuild records every runner configuration change in the organization's audit log, with the user or API key owner that made it, so dashboard and API changes are both attributable. # How Do I Audit Runner Configuration Changes? WarpBuild records every runner configuration change in the organization's audit log, with the user or API key owner that made it, so dashboard and API changes are both attributable. Last verified: 2026-10-26 ## [Answer](#answer)Copy Anchor Link WarpBuild records every runner configuration change in the organization's [audit log](/docs/ci/features/audit-logs), whether it was made in the dashboard or through the API. Each event names the user who made it, or the owner of the API key that made it, with the outcome and the time, and organization admins can filter the log and export it as CSV. One key per person and one key per automation, never one shared key for the whole platform team, keeps that attribution meaningful. Runner configuration spans three surfaces, so the record sits in three places. Find the layer your change belongs to first, because the answer to "who changed this" comes from a different system in each one. | What changed | Where the change happens | What carries the record | | --------------------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Which runner a job uses (the runs-on label) | The workflow file in your repository | The commit, the pull request, and git blame on that line | | Runner registration, removal, and runner group membership | Your GitHub organization | GitHub organization audit log events, including org.register\_self\_hosted\_runner, org.remove\_self\_hosted\_runner, org.runner\_group\_updated, and org.runner\_group\_runners\_added ([GitHub audit log events](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/audit-log-events-for-your-organization), checked 2026-08-13) | | Custom runner definitions, runner images, pool sizes, and BYOC stacks | The WarpBuild dashboard or the WarpBuild API | The [WarpBuild audit log](/docs/ci/features/audit-logs): the user who made the change, or the owner of the API key that made it, with the outcome and the time, kept for 13 months and exportable as CSV | The third row covers dashboard and API changes alike. The audit log answers who changed a setting and when, and the runner object itself holds the current values. Routing configuration through a workflow adds what the log does not hold: a diff, a reviewer, and a run log. ## [Detail](#detail)Copy Anchor Link ### [The API key model](#the-api-key-model)Copy Anchor Link API keys are created on the [API keys page](/docs/ci/api-keys) in your WarpBuild settings, and each key is scoped to a combination of WarpBuild products: CI, Cache, and Helios. The key is displayed once at creation, so it has to be stored at that moment. The name and the scope stay editable afterwards, which matters for the scheme below, because a key keeps working through a rename. Calls carry the key in the `Authorization` header, and the key prefix makes it recognizable in a secret store: Copy Text ``` curl -sS -X GET 'https://api.warpbuild.com/api/v1/runners?only_custom_runners=true' \ -H 'Accept: application/json' \ -H 'Authorization: Bearer wkey-xxxx' ``` The [automation documentation](/docs/ci/api-keys/automation) covers the runner and runner image endpoints and states that a key needs the `ci` and `cache` scopes for that work. It also flags one operational fact worth writing into a runbook: deleting a runner through `DELETE /api/v1/runners/` is irreversible, so that call belongs behind a review gate rather than in a shell history. ### [A key scheme that keeps changes attributable](#a-key-scheme-that-keeps-changes-attributable)Copy Anchor Link Scopes are product level, so a `ci` key that can read runners can also write them. The separation that holds up is one key per caller plus a workflow that only issues the calls it needs. The WarpBuild audit log attributes a change made with an API key to the user who owns the key, so an automation key should belong to the account that is responsible for that automation. | Key name | Scope | Held by | Calls it makes | Revoke or rotate | | -------------------------- | --------- | ---------------------------------------------------------- | ------------------------------------- | ---------------------------------------- | | person-asharma-ci | CI | One named platform engineer, in her password manager | Ad hoc reads and one-off fixes | At offboarding, and on a 90 day rotation | | svc-runner-images-ci-cache | CI, Cache | The image build workflow, as a repository secret | POST and PUT on /api/v1/runner-images | 90 day rotation | | svc-pool-apply-ci | CI | The apply workflow, as a secret on a protected environment | PATCH on /api/v1/runners | 90 day rotation | | svc-inventory-read-ci | CI | The nightly snapshot workflow | GET on /api/v1/runners only | 180 day rotation | Two rules make the table work. No key is shared between a person and an automation, because a shared key erases the difference between a scripted change and a manual one. Every key name starts with `person-` or `svc-`, so an unexpected change is traced to a human or to a workflow before anyone opens a log. ### [Put the change in a pull request](#put-the-change-in-a-pull-request)Copy Anchor Link The strongest record is the one GitHub already keeps for you. Store the runner payload as a file in your repository, apply it from a workflow on merge, and the change carries a diff, a reviewer, and an Actions run log with a timestamp. Copy Text ``` name: apply-runner-config on: push: branches: [main] paths: - "runners/**.json" permissions: contents: read jobs: apply: runs-on: warp-ubuntu-latest-x64-2x environment: runner-admin env: RUNNER_ID: wrslzvbl322yttsc steps: - uses: actions/checkout@v5 - name: Apply the runner definition env: WARPBUILD_API_KEY: ${{ secrets.WARPBUILD_POOL_APPLY_KEY }} run: | curl -sS --fail-with-body -X PATCH \ "https://api.warpbuild.com/api/v1/runners/${RUNNER_ID}" \ -H 'Accept: application/json' \ -H "Authorization: Bearer ${WARPBUILD_API_KEY}" \ -H 'Content-Type: application/json' \ -d @runners/build-pool.json ``` The `environment: runner-admin` line is the review gate. Configure that environment with required reviewers in your repository settings and the job waits for a named approver before the `PATCH` is sent, so the approval is recorded next to the diff. ### [Snapshot the inventory on a schedule](#snapshot-the-inventory-on-a-schedule)Copy Anchor Link Reviews ask what changed between two dates, and a scheduled read answers that without any product feature behind it. The runner objects returned by `GET /api/v1/runners` include `updated_at`, so a daily commit of the normalized list gives you a diffable history. Copy Text ``` name: runner-inventory on: schedule: - cron: "0 6 * * *" workflow_dispatch: permissions: contents: write jobs: snapshot: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v5 - name: Fetch the runner inventory env: WARPBUILD_API_KEY: ${{ secrets.WARPBUILD_INVENTORY_READ_KEY }} run: | curl -sS --fail-with-body \ 'https://api.warpbuild.com/api/v1/runners?only_custom_runners=true' \ -H 'Accept: application/json' \ -H "Authorization: Bearer ${WARPBUILD_API_KEY}" \ | jq -S '[.[] | {name, active, labels, updated_at, image: .configuration.image, capacity_type: .configuration.capacity_type}]' \ > inventory/runners.json - name: Commit the diff run: | git config user.name 'runner-inventory' git config user.email 'runner-inventory@users.noreply.github.com' git add inventory/runners.json git diff --cached --quiet || git commit -m "runner inventory $(date -u +%F)" git push ``` `jq -S` sorts keys so the diff is the change rather than a reordering. When a review asks who moved a pool from on demand to spot capacity in March, the commit that changed `capacity_type` is the answer, and the key that made the call is named in the apply workflow that produced it. ### [What the GitHub side already logs](#what-the-github-side-already-logs)Copy Anchor Link WarpBuild runners register with GitHub as self-hosted runners, so GitHub's own organization audit log carries the registration and group events listed in the first table. Three limits are worth knowing before you rely on it. The log holds the last 180 days of events, only organization owners can open it, and the web view shows the past three months unless you pass a `created` date range, per the [GitHub audit log documentation](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/reviewing-the-audit-log-for-your-organization) checked on 2026-08-13\. Organizations on GitHub Enterprise Cloud can read the same events through the audit log REST and GraphQL APIs, which is the path to a longer retention window in your own store. ### [Why the label line deserves the same review](#why-the-label-line-deserves-the-same-review)Copy Anchor Link A one-line edit to `runs-on` changes what a job costs, which is the usual reason a finance question turns into an audit question. WarpBuild provides [Linux x64, Linux ARM64, macOS, and Windows runners](/docs/ci/cloud-runners), and the per-minute rate is set by the label: | Label | vCPU | RAM | USD per minute | | -------------------------- | ---- | ------ | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 | Rates from the [pricing page](/pricing), checked on 2026-08-13\. A job that runs 20,000 minutes a month costs $160 on `warp-ubuntu-latest-x64-4x` and $640 on `warp-ubuntu-latest-x64-16x`, a difference of $480 a month from one edited line. It shows up in the usage line, which is why the commit that made it is the artifact you want. ### [SSO for teams managing access centrally](#sso-for-teams-managing-access-centrally)Copy Anchor Link SSO decides who can reach the account where these settings live. WarpBuild supports SAML 2.0 and OIDC on enterprise plans for a flat $250 per month, whatever the user count, and works with Okta, Microsoft Entra ID, Google Workspace, Auth0, Microsoft AD FS, OneLogin, PingOne, JumpCloud, Rippling, and any SAML 2.0 or OIDC-compliant provider, per the [SSO documentation](/docs/sso). Provisioning runs through support, and the identity provider connection is then configured from a one-time setup link with no WarpBuild login required. The tie-in to this page is offboarding. Removing a person in your identity provider ends their dashboard access, and it does nothing to an API key they created, because keys are issued and revoked inside the account. An offboarding checklist that covers both is the reason the `person-` prefix in the key table exists. ## [Related Questions](#related-questions)Copy Anchor Link ### [Where is the record of a runner configuration change?](#where-is-the-record-of-a-runner-configuration-change)Copy Anchor Link In three places, depending on which setting moved. The `runs-on` label lives in the workflow file, so the commit and the pull request are the record. Runner registration and runner group membership are GitHub organization events that land in the [GitHub audit log](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/reviewing-the-audit-log-for-your-organization). Runner definitions, images, and pools live in WarpBuild, where every change made in the dashboard or through the API is recorded in the [WarpBuild audit log](/docs/ci/features/audit-logs) with who made it, the outcome, and the time. The [audit log glossary entry](/glossary/audit-log) covers the general shape of an audit record, and the [security review checklist](/enterprise/ci-security-review) puts this question in the order reviewers ask it. ### [How should we scope API keys so changes stay attributable?](#how-should-we-scope-api-keys-so-changes-stay-attributable)Copy Anchor Link One key per person and one key per automation, with the owner and the purpose in the name. Scopes are product level (CI, Cache, and Helios), so the key name is what identifies the caller rather than the scope. Keys are displayed once at creation, so each one goes straight into the secret store that belongs to its holder: a password manager for a person, a repository or environment secret for a workflow. Revoke at offboarding and rotate on a schedule you write down. The [API keys documentation](/docs/ci/api-keys) covers creation, scopes, and editing. ### [Does SSO change who can edit runner settings?](#does-sso-change-who-can-edit-runner-settings)Copy Anchor Link SSO governs who can sign in to the WarpBuild account where runners, BYOC stacks, usage, and billing live, so deprovisioning a user in your identity provider removes that access. SSO costs a flat $250 per month, whatever the user count. API keys are separate and are revoked inside the account, so both belong on the same offboarding checklist. [SSO for GitHub Actions runner access](/enterprise/sso-github-actions) covers the protocols, the attribute mapping, and what happens to running workflows during a rollout. --- # How Do I Authenticate to a Private Registry? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-authenticate-to-a-private-registry-in-github-actions Description: Run one login step in the job before anything pulls or pushes an image, using a registry token from secrets or short lived credentials from an OIDC exchange. # How Do I Authenticate to a Private Registry? Run one login step in the job before anything pulls or pushes an image, using a registry token from secrets or short lived credentials from an OIDC exchange. Add one login step to the job before any step pulls or pushes an image, and give that step either a registry token stored as a repository secret or short lived credentials obtained from an OpenID Connect exchange with your cloud provider. The login writes a credential into the Docker config on the runner, every later step in the same job reads it from there, and the credential goes away with the runner. The choice between the two paths decides what your repository stores. A token secret is a long lived string you have to rotate and revoke by hand. An OIDC exchange stores nothing and trades a signed statement about the running job for credentials that expire on their own. ## [Answer](#answer)Copy Anchor Link **Path one: a token from repository secrets.** [docker/login-action](https://github.com/docker/login-action) runs `docker login` against a registry host with a username and a password or token. For GitHub Container Registry the password can be the per job `GITHUB_TOKEN`, which needs `packages: write` in the workflow or job `permissions` block ([working with the container registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)). Copy Text ``` name: publish-image on: push: branches: [main] permissions: contents: read packages: write jobs: publish: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} profile-name: super-fast-builder ``` For a registry outside GitHub, keep the same step and swap the three inputs. The `packages: write` scope comes off, because the credential now comes from your own secrets rather than from `GITHUB_TOKEN`: Copy Text ``` - name: Log in to the registry uses: docker/login-action@v3 with: registry: registry.example.com username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_TOKEN }} ``` **Path two: an OIDC exchange for a cloud registry.** The job asks GitHub for an identity token describing the repository, branch, and workflow it is running from, then trades that token with the cloud provider for temporary credentials. Requesting the token needs `id-token: write` on the job ([security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)). Nothing about the cloud account is stored in the repository; the scoping lives in a trust policy on the cloud side that names one repository and one ref. Copy Text ``` name: publish-image-oidc on: push: branches: [main] permissions: contents: read id-token: write jobs: publish: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Exchange the OIDC token for AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-ecr-push aws-region: us-east-1 - name: Log in to Amazon ECR id: ecr uses: aws-actions/amazon-ecr-login@v2 - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ${{ steps.ecr.outputs.registry }}/api:${{ github.sha }} profile-name: super-fast-builder ``` Two steps rather than one, because a cloud registry splits the work. The first step performs the token exchange and puts role credentials in the environment. The second step calls the registry's authorization API with those credentials and runs `docker login` with what comes back ([aws-actions/amazon-ecr-login](https://github.com/aws-actions/amazon-ecr-login)). Azure Container Registry and Google Artifact Registry follow the same shape with their own exchange and login actions. Pricing on WarpBuild is purely usage based. The runner in the examples above, `warp-ubuntu-latest-x64-4x`, is $0.008 per minute and the 16 vCPU builder profile is $0.06 per minute, both figures from the [pricing page](/pricing), checked on 2026-08-13. ## [Detail](#detail)Copy Anchor Link ### [What the login step actually writes](#what-the-login-step-actually-writes)Copy Anchor Link `docker login` does not hold a session open. It writes an entry keyed by registry host into `~/.docker/config.json` on the machine running the command, and every later `docker pull`, `docker push`, and buildx build reads that file to find a credential for the host in the image reference. The credential itself is exchanged again per request: the registry answers an unauthenticated call with `401` and a `WWW-Authenticate: Bearer` header naming a scope such as `repository:acme/api:pull,push`, the client trades the stored credential for a bearer token carrying exactly that scope, and the request is retried. The [container registry glossary entry](/glossary/container-registry) covers that handshake and the API routes a push and a pull touch. Two consequences follow from the credential living in a file on the runner. Order matters, so the login step goes above anything that resolves a private reference. And scope is per host, so a workflow that pulls a base image from one registry and pushes the result to another runs two login steps. WarpBuild does not access or store build secrets. Each runner is its own virtual machine, created on demand and destroyed after the build, so the Docker config that the login step wrote is destroyed with it ([security documentation](/docs/ci/security)). ### [The permission scope each path needs](#the-permission-scope-each-path-needs)Copy Anchor Link Declaring a `permissions` block at the workflow or job level sets every scope you do not list to `none`, so the block is the whole grant for `GITHUB_TOKEN` in that job ([workflow syntax reference](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#permissions)). | Path | Job permissions | Stored in the repository | Credential lifetime | | --------------------------------------------- | ------------------------------- | ------------------------------------------------- | -------------------------------------------------- | | GitHub Container Registry with GITHUB\_TOKEN | contents: read, packages: write | Nothing; the token is minted for the job | Ends with the job, 24 hours at most | | Any registry with a username and token secret | contents: read | The token, as a repository or organization secret | Until you rotate it | | Cloud registry through an OIDC exchange | contents: read, id-token: write | Nothing; a trust policy names the repository | One hour by default on AWS, up to the role maximum | The `GITHUB_TOKEN` lifetime comes from [automatic token authentication](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication). The AWS session length comes from [AssumeRoleWithWebIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API%5FAssumeRoleWithWebIdentity.html), and the ECR authorization token that `amazon-ecr-login` writes is separately valid for 12 hours ([ECR registry authentication](https://docs.aws.amazon.com/AmazonECR/latest/userguide/registry%5Fauth.html)). The middle row is the one that carries standing risk. A registry token in repository secrets stays valid whether or not a workflow is running, works from any machine that holds a copy, and has to be rotated on a schedule someone owns. The OIDC row removes that object entirely: the token GitHub mints is bound to one run, the cloud side verifies its signature against GitHub's published keys, and the trust policy compares the `sub` claim against one repository and one ref before handing back anything. The [AWS OIDC answer](/answers/how-do-i-authenticate-to-aws-from-github-actions-with-oidc) walks through the role and trust policy setup. ### [Where the login goes when the build runs on a remote builder](#where-the-login-goes-when-the-build-runs-on-a-remote-builder)Copy Anchor Link A remote Docker builder is a separate VM reached over TLS, and it holds no registry credentials of its own. The login step still runs in the job, on the runner, above the build step. Buildx sends the registry auth from the runner's Docker config to the builder as part of the build session, which is what lets the builder resolve a private `FROM` line and push the finished tags. That ordering also holds when the runner is not a WarpBuild runner. In that case the job carries a second, unrelated credential: the WarpBuild API key that assigns the builder, which is not required on WarpBuild runners ([Docker builders documentation](/docs/ci/docker-builders)). Copy Text ``` jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Configure the WarpBuild builder uses: Warpbuilds/docker-configure@v1 with: api-key: ${{ secrets.WARPBUILD_API_KEY }} profile-name: super-fast-builder - name: Build and push run: | docker buildx build \ --push \ --tag ghcr.io/${{ github.repository }}:${{ github.sha }} \ . ``` Builder profiles keep their layer cache on the builder disk, so `cache-from` and `cache-to` registry references are not needed on this path and there is no third registry credential to arrange for them. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the product surface, and profile sizes and rates are in the [remote Docker builder catalog](/runners/docker-builders). The [Docker builds on GitHub Actions solution](/solutions/docker-builds-github-actions) covers the swap from a runner local build. ### [Failures worth recognizing](#failures-worth-recognizing)Copy Anchor Link **Pull succeeds and push returns 403 or `denied`.** The credential authenticated but the scope it received does not include `push`. On the `GITHUB_TOKEN` path this is a missing `packages: write` in the `permissions` block. On a token secret path it is a read only token. **A pull request from a fork cannot push.** `GITHUB_TOKEN` is read only for workflows triggered by a fork pull request, and repository secrets are not exposed to those runs, so both paths fail by design. Publish from `push` on a protected branch or from a `pull_request_target` workflow you have reviewed. **The build fails on the `FROM` line.** The base image lives in a private registry that the job never logged in to. Add a login step for that host as well; two logins for two hosts is normal. **The OIDC exchange returns `Not authorized to perform sts:AssumeRoleWithWebIdentity`.** The token was minted but the trust policy did not match it. Compare the policy condition against the `sub` claim the run actually produced, remembering that the claim differs between a branch push, a tag, and a job that declares an environment. ## [Related Questions](#related-questions)Copy Anchor Link **Does the login step have to run before the build step?** Yes. The login step writes the credential into the Docker config on the runner, and every later step reads it from there. A build step placed above the login step fails as soon as it resolves a private base image or pushes a tag. The [container registry glossary entry](/glossary/container-registry) explains what the client is doing at each of those moments. **Where does the login happen when the build runs on a remote Docker builder?** In the job, on the runner, above the build step. The builder VM holds no registry credentials of its own, and buildx sends the auth from the runner's Docker config to the builder over the build session. Profile sizes and per minute rates are in the [remote Docker builder catalog](/runners/docker-builders). **Which permissions does each path need?** Pushing to GitHub Container Registry with `GITHUB_TOKEN` needs `contents: read` and `packages: write`. A username and token secret needs no extra permission scope, because the credential comes from secrets. An OIDC exchange needs `contents: read` and `id-token: write`, and the trust policy on the cloud side does the rest of the scoping. The [AWS OIDC answer](/answers/how-do-i-authenticate-to-aws-from-github-actions-with-oidc) shows that policy. Set up the login step against the runner catalog in the [Docker builds on GitHub Actions solution](/solutions/docker-builds-github-actions), size a builder profile on the [pricing page](/pricing), and read the isolation model for build secrets in the [security documentation](/docs/ci/security). --- # How Do I Authenticate to AWS With OIDC? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-authenticate-to-aws-from-github-actions-with-oidc Description: Grant id-token write in the job, exchange the signed token with AWS STS for temporary credentials, and pin the role trust policy to one repository and branch. # How Do I Authenticate to AWS With OIDC? Grant id-token write in the job, exchange the signed token with AWS STS for temporary credentials, and pin the role trust policy to one repository and branch. ## [Answer](#answer)Copy Anchor Link Request an identity token inside the job, exchange it with AWS STS for temporary credentials, and let an IAM role trust policy decide which repository and which ref are allowed to make that exchange. Three pieces have to line up: the `id-token: write` permission on the job, an IAM OpenID Connect identity provider registered once per AWS account for `token.actions.githubusercontent.com`, and a role whose trust policy pins the `aud` and `sub` claims from the token GitHub signs ([GitHub: security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect), checked on 2026-08-13). Nothing long lived ends up in the repository. The credentials AWS returns expire on their own, and the branch restriction is enforced by AWS against a claim GitHub signed, so editing the workflow file cannot widen it. The exchange happens between the job, the GitHub token endpoint, and the AWS STS endpoint, so it behaves the same on any runner that can reach both. Every label in that catalog runs the exchange the same way. The workflow side is one grant and one step: Copy Text ``` name: publish-image on: push: branches: [main] permissions: {} jobs: publish: runs-on: warp-ubuntu-latest-x64-4x permissions: contents: read id-token: write steps: - uses: actions/checkout@v5 - name: Exchange the GitHub identity token for AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-publish role-session-name: publish-${{ github.run_id }} aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - name: Build and push the image run: | docker build -t "${{ steps.ecr.outputs.registry }}/api:${{ github.sha }}" . docker push "${{ steps.ecr.outputs.registry }}/api:${{ github.sha }}" ``` `permissions: {}` at the top of the file sets every scope to `none` for jobs that stay silent, and the `publish` job restates the two grants it needs. The credentials action requests a token with audience `sts.amazonaws.com`, calls [sts:AssumeRoleWithWebIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API%5FAssumeRoleWithWebIdentity.html), and exports the returned access key, secret key, and session token as environment variables for the remaining steps ([aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials)). ## [Detail](#detail)Copy Anchor Link ### [Register the identity provider once per AWS account](#register-the-identity-provider-once-per-aws-account)Copy Anchor Link AWS needs to know the issuer before any role can trust it. The provider is an account level object, so one registration serves every role and every repository in that account: Copy Text ``` aws iam create-open-id-connect-provider \ --url https://token.actions.githubusercontent.com \ --client-id-list sts.amazonaws.com ``` The client ID list is the set of audience values AWS will accept, and `sts.amazonaws.com` is what the credentials action requests by default. AWS documents when a thumbprint list is required for this call and how to obtain the value ([AWS: obtain the thumbprint for an OpenID Connect identity provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id%5Froles%5Fproviders%5Fcreate%5Foidc%5Fverify-thumbprint.html)). Missing this step is the cause of the `No OpenIDConnect provider found in your account` error further down. ### [Scope the trust policy to one repository and one ref](#scope-the-trust-policy-to-one-repository-and-one-ref)Copy Anchor Link The trust policy is where the security of the pattern lives. It names the provider as a federated principal, allows the one STS action, and compares two claims from the signed token using the condition keys `token.actions.githubusercontent.com:aud` and `token.actions.githubusercontent.com:sub`: Copy Text ``` { "Version": "2012-10-17", "Statement": [ { "Sid": "GitHubActionsPublishFromMain", "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:example-org/example-repo:ref:refs/heads/main" } } } ] } ``` The `sub` value follows the trigger that started the job, and the four shapes below cover most pipelines ([GitHub: security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect), checked on 2026-08-13): * `repo:example-org/example-repo:ref:refs/heads/main` for a push to a branch. * `repo:example-org/example-repo:ref:refs/tags/v1.2.0` for a tag. * `repo:example-org/example-repo:environment:production` when the job declares a deployment environment. * `repo:example-org/example-repo:pull_request` for a pull request run. Two conditions belong in every policy. Pinning `aud` stops a token minted for another service from being replayed at STS. Pinning the full `sub` with `StringEquals` keeps the role attached to one repository and one ref. A `StringLike` condition on a prefix such as `repo:example-org/*` grants the role to every repository in the organization, which turns a per pipeline credential into an organization wide one. Tag deploys are the case where a wildcard is tempting, because tag names change every release. Prefer a deployment environment there: gate the job with `environment: production`, match `repo:example-org/example-repo:environment:production`, and let GitHub's required reviewers decide when the credential is reachable. ### [What each failure message means](#what-each-failure-message-means)Copy Anchor Link The exchange fails in a small number of ways, and the message names the half that is wrong. Messages come from the credentials action and from AWS STS ([aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials), [AWS STS API reference](https://docs.aws.amazon.com/STS/latest/APIReference/API%5FAssumeRoleWithWebIdentity.html)). | Message you see | Half that is wrong | Fix | | ---------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Unable to get ACTIONS\_ID\_TOKEN\_REQUEST\_URL env variable | Workflow | Add id-token: write to the job, and check that no other permissions block above it dropped the scope | | Credentials could not be loaded, please check your action inputs | Workflow | Same missing grant, or role-to-assume absent from the step inputs | | Not authorized to perform sts:AssumeRoleWithWebIdentity | Trust policy | The sub in the token differs from the condition. A release tag, a fork pull request, or a job in an environment produces a different subject than a push to a branch | | Incorrect token audience | Trust policy | The aud condition and the audience the action requested disagree. Leave the default sts.amazonaws.com on both sides unless a second provider needs its own value | | No OpenIDConnect provider found in your account | AWS account | The identity provider was never created in the account holding the role | | DurationSeconds exceeds the MaxSessionDuration set for this role | Role | Lower role-duration-seconds on the step or raise the maximum session duration on the role | For the third row, print the subject the job actually presents before editing the policy again. The `github` context carries the same fields the token does, so `echo "${{ github.ref }}"` and `echo "${{ github.repository }}"` in a debug step reconstruct the string the policy has to match ([GitHub: contexts](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts)). ### [How this differs from an instance profile on a BYOC runner](#how-this-differs-from-an-instance-profile-on-a-byoc-runner)Copy Anchor Link An instance profile attaches an IAM role to the EC2 instance itself, so every job that lands on that runner class reads the same credentials from instance metadata. The exchange attaches an identity to the workflow run instead, so authority follows the repository and the ref. Both are available on BYOC, where BYOC runs on AWS, GCP, and Azure and the Instance Profile ARN is a field on the custom runner configuration ([instance profile setup](/docs/ci/byoc/aws/instance-profile)). | runs-on label | Where AWS credentials come from | What the grant is scoped to | | ----------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) | The OIDC exchange inside the job | One repository and one ref per role | | warp-custom- (BYOC on AWS) | An instance profile on the EC2 instance, the OIDC exchange, or both | The instance profile covers every job on that runner class; the exchange stays per repository and ref | | ubuntu-latest (GitHub-hosted) | The OIDC exchange inside the job | One repository and one ref per role | The two compose cleanly because of ordering in the AWS SDK credential chain. Environment variables are read before instance metadata, so a job that starts with instance profile credentials and then runs the exchange step uses the exchanged role for everything after that step ([AWS SDK credential provider chain](https://docs.aws.amazon.com/sdkref/latest/guide/standardized-credentials.html)). A common split is baseline pulls through the instance profile and deployment authority through the exchange, so a compromised test job never reaches the deploy role. Use the instance profile when the permission is a property of the machine class, such as reading a shared artifact bucket. Use the exchange when the permission is a property of the pipeline, such as pushing to production. The [BYOC runner IAM answer](/answers/can-i-attach-an-iam-role-to-a-byoc-runner) covers the `iam:PassRole` grant that the profile path needs first. ### [What a security review records](#what-a-security-review-records)Copy Anchor Link The exchange removes one class of secret from the repository, and reviewers usually ask what happens to the rest. WarpBuild does not access or store build secrets; they stay in your source code repository and reach only the runner environment, which is destroyed with its encrypted volume when the build ends ([runner security documentation](/docs/ci/security)). The SOC 2 Type 2 attestation covering Security, Availability, and Confidentiality is requested through [trust.warpbuild.com](https://trust.warpbuild.com/). The [secrets management guide](/guides/github-actions-secrets-management) covers what stays in repository, environment, and organization secrets after the AWS keys are gone. Pricing does not gate a trial of any of this. Once authentication is settled, the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions) covers the runner side of the same pipeline. ## [Related Questions](#related-questions)Copy Anchor Link ### [Why does the credential step fail with a missing ACTIONS\_ID\_TOKEN\_REQUEST\_URL variable?](#why-does-the-credential-step-fail-with-a-missing-actions%5Fid%5Ftoken%5Frequest%5Furl-variable)Copy Anchor Link The job does not hold the `id-token: write` grant. GitHub injects `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` only into jobs that request it, and any `permissions` block sets every scope it leaves out to `none`, so a workflow level block listing `contents: read` alone strips the grant from every job below it ([GitHub: workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)). Add `id-token: write` to the job that runs the exchange. The [OIDC token exchange definition](/glossary/oidc-token-exchange) walks through what the token carries once the grant is in place. ### [Can one IAM role serve every repository in the organization?](#can-one-iam-role-serve-every-repository-in-the-organization)Copy Anchor Link It can, and that is the configuration to avoid. A `StringLike` condition on a subject prefix such as `repo:example-org/*` hands the role to every current and future repository in the organization, including one a contributor creates tomorrow. Pin the full subject with `StringEquals` and create one role per repository and deployment target, which keeps the blast radius of a compromised workflow to the resources that one pipeline touches. The [secrets management guide](/guides/github-actions-secrets-management) covers the same blast radius question for the repository and organization secrets that sit next to this role. ### [Do I still need OIDC if my BYOC runner already carries an instance profile?](#do-i-still-need-oidc-if-my-byoc-runner-already-carries-an-instance-profile)Copy Anchor Link It depends on how the permission varies. An instance profile gives every job on that runner class the same AWS role, which fits baseline access such as pulling base images ([instance profile setup](/docs/ci/byoc/aws/instance-profile)). An OIDC exchange gives each workflow run an identity carrying the repository, branch, and environment, so authority can differ per repository and per branch on shared runners. The two compose, and credentials set by the exchange step take precedence over instance metadata for the rest of the job. --- # How Do I Build a Rust Binary for Multiple Targets? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-build-a-rust-binary-for-multiple-targets Description: Use one GitHub Actions matrix pairing each Rust target triple with a runner label, building natively where a runner exists and cross compiling where none does. # How Do I Build a Rust Binary for Multiple Targets? Use one GitHub Actions matrix pairing each Rust target triple with a runner label, building natively where a runner exists and cross compiling where none does. Last verified: 2026-08-13 Build one job per target in a GitHub Actions matrix, pairing every Rust target triple with a runner label of the same architecture and operating system, and pass that triple to `cargo build --target`. Build natively wherever a runner of that architecture exists, and reach for cross compilation only for the targets with no matching runner, such as the musl target on a glibc host or the x86\_64 Apple target on an Apple Silicon machine. ## [Answer](#answer)Copy Anchor Link The matrix is the whole mechanism. A [matrix strategy](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstrategymatrix) expands one job definition into one job per combination, and because `runs-on` accepts an expression, each expansion can land on a different machine. That turns "build for six targets" into "declare six pairs of triple and label". Four of the six common release targets have a native host. Rates and shapes below come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Rust target triple | Runner label | Shape | Per minute | Native or cross | | -------------------------- | --------------------------- | ------------- | ---------- | ------------------------ | | x86\_64-unknown-linux-gnu | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | Native | | x86\_64-unknown-linux-musl | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | Cross, same architecture | | aarch64-unknown-linux-gnu | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | $0.012 | Native | | aarch64-apple-darwin | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08 | Native | | x86\_64-apple-darwin | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08 | Cross, same SDK | | x86\_64-pc-windows-msvc | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016 | Native | The two cross rows are the cheap kind of cross compilation. `x86_64-unknown-linux-musl` runs on the same x86\_64 host and needs only `rustup target add` plus the `musl-tools` package for the linker. `x86_64-apple-darwin` links on the Apple Silicon runner because Apple's SDK carries both architectures, and WarpBuild macOS images carry the same tooling as the GitHub-hosted macOS image ([preinstalled software](/docs/ci/preinstalled-software)). The expensive kind, an aarch64 glibc binary built on an x86\_64 host, needs a separate cross linker and a per-target linker variable, which the native ARM64 runner removes entirely. Here is the workflow. It fires on tags, keeps `fail-fast: false` so one broken target does not cancel the rest of the release, and uploads a per-target artifact. Copy Text ``` name: release on: push: tags: ["v*"] jobs: build: strategy: fail-fast: false matrix: include: - target: x86_64-unknown-linux-gnu runner: warp-ubuntu-latest-x64-8x cache-provider: warpbuild - target: x86_64-unknown-linux-musl runner: warp-ubuntu-latest-x64-8x cache-provider: warpbuild apt: musl-tools - target: aarch64-unknown-linux-gnu runner: warp-ubuntu-latest-arm64-8x cache-provider: warpbuild - target: aarch64-apple-darwin runner: warp-macos-latest-arm64-6x cache-provider: github - target: x86_64-apple-darwin runner: warp-macos-latest-arm64-6x cache-provider: github - target: x86_64-pc-windows-msvc runner: warp-windows-latest-x64-4x cache-provider: github ext: .exe runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - run: rustup toolchain install stable --profile minimal - run: rustup target add ${{ matrix.target }} - if: matrix.apt run: | sudo apt-get update sudo apt-get install -y ${{ matrix.apt }} - uses: WarpBuilds/rust-cache@v2 with: cache-provider: ${{ matrix.cache-provider }} shared-key: release-${{ matrix.target }} - run: cargo build --release --locked --target ${{ matrix.target }} - uses: actions/upload-artifact@v4 with: name: mytool-${{ matrix.target }} path: target/${{ matrix.target }}/release/mytool${{ matrix.ext }} if-no-files-found: error ``` Pricing on WarpBuild is purely usage based. ## [Detail](#detail)Copy Anchor Link ### [Why the target triple has to travel with the runner label](#why-the-target-triple-has-to-travel-with-the-runner-label)Copy Anchor Link Passing `--target` changes the output path. Without it, `cargo build --release` writes to `target/release/`; with it, the artifacts land in `target//release/` ([cargo build reference](https://doc.rust-lang.org/cargo/commands/cargo-build.html)). That is why the upload step interpolates the triple into the path, and why `if-no-files-found: error` earns its place: a typo in the triple produces an empty upload rather than a failed job. The `${{ matrix.ext }}` suffix handles the one platform difference in the binary name. Undefined matrix keys evaluate to an empty string, so the five non-Windows legs read the same expression and get nothing appended. Artifact names have to be unique. `actions/upload-artifact@v4` refuses a second upload under a name that already exists in the run, so the triple belongs in the artifact name as well as the path. A downstream release job then downloads with `pattern: mytool-*` and `merge-multiple: true` and attaches every binary to one tag. ### [Where cross compiling still earns its keep](#where-cross-compiling-still-earns-its-keep)Copy Anchor Link Two targets in the table have no runner of their own, and both are cheap because the host architecture already matches or the SDK already ships the second slice. The musl target is a Tier 2 target with host tools in the [Rust platform support list](https://doc.rust-lang.org/rustc/platform-support.html), which is why the toolchain installs cleanly and only the C linker is missing. Install `musl-tools`, add the target, and the static binary comes out of the same 8 vCPU Linux runner that built the gnu binary. Running both on one runner in one job is a fair simplification once the workspace compiles quickly, since the second `cargo build` reuses the warm registry. The aarch64 glibc target is the one worth building natively. Cross compiling it from x86\_64 means installing `gcc-aarch64-linux-gnu`, setting `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER`, and repeating that work for every C dependency with a build script. The [cross compiling compared with native ARM64 builds guide](/guides/cross-compiling-vs-native-arm64-builds) walks through the tradeoff. One detail to carry into scripts: Ubuntu 24.04 ARM64 runners set the work directory to `/runner/_work`, where GitHub-hosted runners use `/home/runner/work/`, so absolute paths hard coded in release scripts need a `github.workspace` reference instead. Cache support differs by platform, which is why the matrix carries a `cache-provider` key. The WarpBuild cache is enabled by default on Linux runners, and the [caching documentation](/docs/ci/features/caching) records that Windows runners fall outside it, so the macOS and Windows legs keep the action's default `github` provider while the three Linux legs use `warpbuild`. ### [Pricing the release matrix](#pricing-the-release-matrix)Copy Anchor Link Take a six target release with the minutes a mid sized workspace tends to spend. Rates come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Target | Runner label | Rate | Minutes | Cost | | -------------------------- | --------------------------- | ------ | ------- | ------ | | x86\_64-unknown-linux-gnu | warp-ubuntu-latest-x64-8x | $0.016 | 9 | $0.144 | | x86\_64-unknown-linux-musl | warp-ubuntu-latest-x64-8x | $0.016 | 10 | $0.160 | | aarch64-unknown-linux-gnu | warp-ubuntu-latest-arm64-8x | $0.012 | 9 | $0.108 | | aarch64-apple-darwin | warp-macos-latest-arm64-6x | $0.08 | 8 | $0.640 | | x86\_64-apple-darwin | warp-macos-latest-arm64-6x | $0.08 | 7 | $0.560 | | x86\_64-pc-windows-msvc | warp-windows-latest-x64-4x | $0.016 | 12 | $0.192 | That is 55 minutes and $1.804 per release, or $36.08 a month at twenty tagged releases. The two macOS legs carry $1.20 of the $1.804, about two thirds of the bill from a quarter of the targets, which is the number that should drive the next decision. Merge the two Apple targets into one job and the checkout, toolchain install, and cargo registry warm up happen once instead of twice: at 13 minutes for the merged job the darwin work drops to $1.04, saving $0.16 per release and $3.20 a month, and the same job can run `lipo -create` to emit a universal binary. Two of the legs price directly against GitHub's published rates. `warp-ubuntu-latest-arm64-8x` (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB): 14 percent lower list price, so the 9 minute ARM64 leg is $0.108 against $0.126\. `warp-macos-latest-arm64-6x` (6 vCPU, 14 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price, so 15 minutes of darwin work is $1.20 against $1.53\. GitHub list prices come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and shapes from the [GitHub-hosted runner specs](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), both checked on 2026-08-13\. Run the same arithmetic against your own minutes on the [pricing page](/pricing). Which leg to shrink first is a measurement question rather than a guess. CI observability sits in the product surface alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger, and it reports per job system metrics so you can see whether the slowest target is starved of CPU or waiting on a linker. Sizing guidance for the compile heavy legs is in [fast Rust builds on GitHub Actions](/solutions/rust-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link **Do I need a separate job for each Rust target?** One job per target is the default shape, because `runs-on` is fixed for the life of a job and each target wants a machine of its architecture. Targets sharing a host can be merged: the two Apple targets fit in one macOS job, and the gnu and musl Linux x64 targets fit in one Linux job. The [release build workflow guide](/guides/release-builds-github-actions) covers how far to take that merging before the matrix stops being readable. **How do I build a universal macOS binary in GitHub Actions?** Build `aarch64-apple-darwin` and `x86_64-apple-darwin` in the same job on `warp-macos-latest-arm64-6x`, then run `lipo -create target/aarch64-apple-darwin/release/mytool target/x86_64-apple-darwin/release/mytool -output mytool-universal` and upload the merged file. Both targets link on that runner because the image carries the same Apple tooling as the GitHub-hosted macOS image ([preinstalled software](/docs/ci/preinstalled-software)). **Should I cross compile aarch64 Linux instead of using an ARM64 runner?** Native is the simpler path when a native runner exists, and `warp-ubuntu-latest-arm64-8x` at $0.012 per minute prices below the x64 runner of the same shape. Cross compiling adds a linker package, a per-target linker variable, and a second set of failure modes in every C dependency with a build script. The [cross compiling comparison](/guides/cross-compiling-vs-native-arm64-builds) has the full breakdown. **What does a six target Rust release matrix cost per run?** About $1.80 at catalog rates, for 55 minutes across two Linux x64 legs, one Linux ARM64 leg, two macOS legs, and one Windows leg. The macOS legs are $1.20 of it. --- # How Do I Build and Push in One GitHub Actions Step? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-build-and-push-in-one-github-actions-step Description: Set push to true on the build and push action and feed it tags computed earlier in the same job, so BuildKit exports each layer straight to the registry. # How Do I Build and Push in One GitHub Actions Step? Set push to true on the build and push action and feed it tags computed earlier in the same job, so BuildKit exports each layer straight to the registry. Use a build and push action with `push: true` and hand it the tags a metadata step computed earlier in the same job, so BuildKit exports each layer to the registry as the build finishes it and the image is never produced twice. Splitting the work into a build step and a later `docker push` step means the image has to land somewhere in between, and that somewhere is the runner's local image store, which costs a full export of every layer before a single byte moves toward the registry ([docker/build-push-action](https://github.com/docker/build-push-action), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link A one-step publish is three steps in the job, only one of which touches the image. | Step | Action | What it produces | | -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Compute tags | docker/metadata-action | steps.meta.outputs.tags and labels from the ref, the SHA, and the event ([docker/metadata-action](https://github.com/docker/metadata-action)) | | Authenticate | docker/login-action | a registry credential in the Docker config for the push destination ([docker/login-action](https://github.com/docker/login-action)) | | Build and push | docker/build-push-action with push: true | the built image, exported straight to the registry by the image exporter ([Docker image and registry exporters](https://docs.docker.com/build/exporters/image-registry/)) | The order matters because the build step is the one that uploads. Tags have to exist before it runs, and the credential has to exist before it runs, or the export fails at the end of a build you already paid for. Copy Text ``` name: publish-image on: push: branches: [main] tags: ["v*"] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: publish: runs-on: warp-ubuntu-latest-x64-8x permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=semver,pattern={{version}} type=sha,format=long - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/setup-buildx-action@v3 - id: build uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - name: Report what was published run: echo "published ${{ steps.build.outputs.digest }}" ``` `secrets.GITHUB_TOKEN` is minted per job and needs the `packages: write` permission declared above to push to GitHub Container Registry ([GitHub automatic token authentication](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication), checked on 2026-08-13). The `digest` output of the build step is the address of what was pushed, which is what a deploy job downstream should consume rather than a mutable tag. ## [Detail](#detail)Copy Anchor Link ### [What the two-step shape actually does](#what-the-two-step-shape-actually-does)Copy Anchor Link A build step that sets neither `push` nor `load` writes its result into the BuildKit cache and nowhere else, so a following `docker push app:latest` reports that no such image exists. The fix people reach for is `load: true`, and that is where the time goes: the image exporter serializes every layer of the finished image into the runner's Docker image store, and only then does `docker push` read those layers back and upload them. The same bytes are written to disk, read from disk, and sent over the network, against a build where `push: true` streams each layer to the registry as BuildKit finishes it. `load` carries a second constraint. It supports one platform, so a build that lists `linux/amd64,linux/arm64` and sets `load: true` fails outright, while `push: true` writes an index covering both platforms ([Docker image and registry exporters](https://docs.docker.com/build/exporters/image-registry/), checked on 2026-08-13). ### [What the extra minutes cost](#what-the-extra-minutes-cost)Copy Anchor Link The export and the re-upload are runner minutes at the runner's rate. Assume the pair adds two minutes to each build and the workflow runs 600 times a month; substitute your own numbers by reading the step durations in the job log. Rates come from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13. | Runner label | vCPU | Per minute | 1,200 extra minutes per month | | --------------------------- | ---- | ---------- | ----------------------------- | | warp-ubuntu-latest-x64-4x | 4 | $0.008 | $9.60 | | warp-ubuntu-latest-x64-8x | 8 | $0.016 | $19.20 | | warp-ubuntu-latest-x64-16x | 16 | $0.032 | $38.40 | | warp-ubuntu-latest-arm64-8x | 8 | $0.012 | $14.40 | The same arithmetic applies whichever platform publishes your images. There is a second meter when a remote builder is involved. Docker builders are billed per session, measured from when the builder action starts until the job completes, per the [Docker builders documentation](/docs/ci/docker-builders). Minutes the job spends loading and re-uploading an image are minutes the builder session stays open, so a two-step publish is billed on the runner and on the builder at the same time. Builder rates below come from the same documentation and the [pricing page](/pricing), checked on 2026-08-13. | Builder profile | Architectures | Per minute | 1,200 extra minutes per month | | ------------------------------ | ------------------- | ---------- | ----------------------------- | | 16 vCPU, 32GB RAM, 100GB disk | amd64, arm64, multi | $0.06 | $72.00 | | 32 vCPU, 64GB RAM, 200GB disk | amd64, arm64, multi | $0.12 | $144.00 | | 64 vCPU, 128GB RAM, 200GB disk | amd64, arm64, multi | $0.24 | $288.00 | ### [The remote builder variant](#the-remote-builder-variant)Copy Anchor Link On a remote Docker builder profile the build runs on a dedicated builder VM rather than on the runner, and `push: true` keeps the layers on that side of the wire: the builder holds the layers it just produced and exports them to the registry itself, so the runner sends the build context up and receives a digest back. Setting `load: true` in that arrangement reverses the flow and drags the finished image across the connection into the runner's image store before anything reaches the registry. The workflow change is the action name plus a profile: Copy Text ``` - id: build uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} profile-name: "app-images-x64" ``` `api-key` is required only when the job runs somewhere other than a WarpBuild runner. `cache-from` and `cache-to` come out at the same time, because a builder profile keeps its layer cache on local disk and reuses it on the next build with no export or import step ([Docker builders documentation](/docs/ci/docker-builders), and the [caching documentation](/docs/ci/features/caching) for the job-level cache action). The [Docker builds on GitHub Actions solution page](/solutions/docker-builds-github-actions) covers profile sizing and the full before and after workflow. ## [Related Questions](#related-questions)Copy Anchor Link ### [Why does a separate docker push step fail with an image not found?](#why-does-a-separate-docker-push-step-fail-with-an-image-not-found)Copy Anchor Link Because a build that sets neither `push` nor `load` leaves the result in the BuildKit cache and never writes it into the local image store, so the docker CLI has no such tag to push. The two ways out are `load: true`, which exports the whole image into the daemon first, and `push: true`, which sends the layers to the registry from the build itself. The [Docker builds on GitHub Actions solution page](/solutions/docker-builds-github-actions) walks through the exporter behavior in a full workflow. ### [Can I keep a local copy of the image after pushing it?](#can-i-keep-a-local-copy-of-the-image-after-pushing-it)Copy Anchor Link Yes, by setting both `push: true` and `load: true`, and the load half costs a full export of every layer into the runner's image store. When the local copy is only needed for a smoke test, pull the tag you just pushed or reference the `digest` output of the build step instead. On a builder profile the local copy is the expensive option, because the image has to travel back to the runner: see [remote Docker builder profiles and rates](/runners/docker-builders). ### [Does one step work for a multi-platform image?](#does-one-step-work-for-a-multi-platform-image)Copy Anchor Link Yes for `push` and no for `load`. The image exporter writes an index covering every platform you list, while `load` supports a single platform only, so a multi-platform build that also sets `load` will fail. Drop `load` and read the `digest` output when a later job needs to reference the image ([Docker image and registry exporters](https://docs.docker.com/build/exporters/image-registry/), checked on 2026-08-13). ### [How do I push the same image to two registries in one step?](#how-do-i-push-the-same-image-to-two-registries-in-one-step)Copy Anchor Link Log in to both registries in separate login steps, then pass both fully qualified tags to the same build and push step. BuildKit builds once and exports the same layers to each destination, so the second registry costs an upload rather than a rebuild. [Publishing one image to several registries](/guides/multi-registry-publishing-github-actions) covers the tag list and the credential handling, and [authenticating to a private registry](/answers/how-do-i-authenticate-to-a-private-registry-in-github-actions) covers the login step when the destination is not GitHub Container Registry. --- # How Do I Cache Bundler Gems? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-bundler-gems-in-github-actions Description: Point bundler at vendor/bundle, cache that directory keyed on Gemfile.lock plus the Ruby version, and install in deployment mode. Workflow YAML and costs. # How Do I Cache Bundler Gems? Point bundler at vendor/bundle, cache that directory keyed on Gemfile.lock plus the Ruby version, and install in deployment mode. Workflow YAML and costs. Cache `vendor/bundle` and key the entry on a hash of `Gemfile.lock` plus the Ruby version and the runner platform. Point bundler at that same directory first with `bundle config set --local path vendor/bundle`, so the cache path and the install path are one directory, and run `bundle install` in deployment mode so a `Gemfile.lock` that no longer matches the `Gemfile` fails the job instead of resolving new versions on the runner. ## [Answer](#answer)Copy Anchor Link Bundler installs into a system gem directory unless you tell it otherwise, and that directory sits outside the workspace. A cache step pointed at the workspace then stores nothing useful, and one pointed at the system path stores a directory the next run rebuilds anyway. The fix is one config command that moves the install target into the repository, where a cache action can name it. Three bundler settings carry this setup. Each is written to `.bundle/config` in the working directory by [bundle config](https://bundler.io/man/bundle-config.1.html) and read by every later `bundle` command in the job. | Setting | Command | What it does | | ---------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | path | bundle config set --local path vendor/bundle | Installs gems into vendor/bundle under the checkout, which is the directory you cache | | deployment | bundle config set --local deployment true | Requires a checked-in Gemfile.lock that matches the Gemfile, and defaults the install path to vendor/bundle | | without | bundle config set --local without "development" | Skips groups the job does not need, which keeps the cached directory smaller | Deployment mode is the setting that turns a lockfile drift into a failed job. [Bundler's deployment guide](https://bundler.io/guides/deploying.html) describes the mode as the one for rolling a bundle out to production or to a build machine: the lockfile has to exist in version control, and bundler refuses to update it. A pull request that edits the `Gemfile` and forgets to commit the regenerated `Gemfile.lock` stops with a message telling you to run `bundle install` elsewhere and commit the result. Without deployment mode the same pull request resolves whatever versions the runner finds that day, the tests pass against gems nobody reviewed, and the cache key still matches the stale lockfile. The cache key needs three inputs, because three separate things invalidate a `vendor/bundle` directory. Copy Text ``` ${{ runner.os }}-${{ runner.arch }}-gems-${{ steps.ruby.outputs.version }}-${{ hashFiles('**/Gemfile.lock') }} ``` | Key part | Invalidates when | Why the directory is dead without it | | ------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------- | | runner.os and runner.arch | The job moves between Linux, macOS, x64, or ARM64 | Compiled extensions are built for one platform triple | | Ruby version | The toolchain moves to a new Ruby | Bundler writes to a per-ABI subdirectory and recompiles extensions | | hashFiles('\*\*/Gemfile.lock') | Any gem version changes | The lockfile is the exact manifest of what belongs in the directory | [hashFiles](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#hashfiles) returns a single hash over every matching file, so a monorepo with several `Gemfile.lock` files gets one key that changes when any of them does. Pair the key with a `restore-keys` prefix that drops the lockfile hash, and a single gem bump restores the previous directory and installs the difference rather than downloading every gem again. [Cache key](/glossary/cache-key) covers prefix fallbacks in general. On WarpBuild runners the [WarpBuild cache](/docs/ci/features/caching) is a drop-in replacement for `actions/cache@v4` and is enabled by default on Linux runners. Caching is not supported on the Windows runners, so a Windows Ruby job keeps using GitHub's cache backend. Entries expire 7 days after their last use, and the cache version is derived from the compression tool and the cached paths, which is a second reason a macOS entry never restores onto a Linux runner. ## [Detail](#detail)Copy Anchor Link ### [A workflow that restores, installs, and saves on success](#a-workflow-that-restores-installs-and-saves-on-success)Copy Anchor Link Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: rspec: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - uses: ruby/setup-ruby@v1 with: ruby-version: "3.3.9" - name: Record the Ruby version id: ruby run: echo "version=$(ruby -e 'print RUBY_VERSION')" >> "$GITHUB_OUTPUT" - name: Point bundler at vendor/bundle run: | bundle config set --local path vendor/bundle bundle config set --local deployment true bundle config set --local without "development" - name: Restore gems id: gems uses: WarpBuilds/cache/restore@v1 with: path: vendor/bundle key: ${{ runner.os }}-${{ runner.arch }}-gems-${{ steps.ruby.outputs.version }}-${{ hashFiles('**/Gemfile.lock') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-gems-${{ steps.ruby.outputs.version }}- - run: bundle install --jobs 4 - run: bundle exec rspec - name: Drop gems the lockfile no longer names if: success() && github.ref == 'refs/heads/main' run: bundle clean --force - name: Save gems if: success() && github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: vendor/bundle key: ${{ steps.gems.outputs.cache-primary-key }} ``` Four details carry the shape. The config step runs before the restore step, so the restored directory is the one bundler is about to use. `bundle install` runs on every path through the workflow, including a full cache hit, because the install is what verifies the lockfile against deployment mode. [bundle clean --force](https://bundler.io/man/bundle-clean.1.html) removes gems that the current lockfile no longer names, which stops a year of version bumps from riding along in the entry. And the save step reuses `cache-primary-key` from the restore step, so the two keys cannot drift apart when someone edits one of them. Saving only from the default branch keeps five active branches from storing five near-identical copies, and every branch can still restore the default branch entry through GitHub's cache scoping. ### [Gems that compile native extensions](#gems-that-compile-native-extensions)Copy Anchor Link Pure Ruby gems unpack and are done. Gems with C extensions, such as database drivers, XML parsers, and image libraries, compile against the headers of the Ruby that is installed when `bundle install` runs. Bundler files the results by ABI and platform: | Path under vendor/bundle | Holds | | ------------------------------------------ | --------------------------------------------------- | | ruby/3.3.0/gems/ | Unpacked gem sources | | ruby/3.3.0/extensions/x86\_64-linux/3.3.0/ | Compiled native extensions for one platform and ABI | | ruby/3.3.0/cache/ | Downloaded .gem archives | | ruby/3.3.0/bin/ | Binstubs | The `3.3.0` in those paths is the Ruby ABI version, shared by every 3.3.x release, and the `x86_64-linux` segment is the platform triple. Move to Ruby 3.4 and bundler writes a sibling `ruby/3.4.0` tree and compiles every extension again, while the restored 3.3.0 tree sits there adding restore seconds and gigabytes. Move the same job from `warp-ubuntu-latest-x64-4x` to `warp-ubuntu-latest-arm64-4x` and the extensions directory is wrong in the platform segment instead. Both cases are why the Ruby version and `runner.arch` belong in the key rather than the lockfile hash alone. Precompiled platform gems add one more failure mode. Bundler records the platforms a lockfile supports, and in deployment mode a runner whose platform is missing from that list stops with an error rather than falling back to compiling from source. Run [bundle lock --add-platform](https://bundler.io/man/bundle-lock.1.html) for each platform your workflows use, commit the updated lockfile, and an ARM64 job stops being a surprise: Copy Text ``` bundle lock --add-platform x86_64-linux aarch64-linux ``` ### [The shorter path](#the-shorter-path)Copy Anchor Link [WarpBuilds/setup-ruby](https://github.com/WarpBuilds/setup-ruby) does this whole setup in one step. With `bundler-cache: true` it runs `bundle install` and caches the installed gems automatically, and a separate `bundle install` step can be removed. The [setup actions reference](/docs/ci/features/caching/setup-actions) lists its inputs, including `cache-version`, an arbitrary string that busts the gem cache when you need a clean entry. Copy Text ``` - uses: WarpBuilds/setup-ruby@v1.295.0 with: ruby-version: "3.3.9" bundler-cache: true ``` Take the managed version when the workflow is a normal `bundle install` and a test command. Take the explicit version above when you need the save step conditioned on branch or on success, a `bundle clean` pass before the save, or a `path` list that also covers assets or a compiled extension directory outside the bundle. ### [What the cache costs](#what-the-cache-costs)Copy Anchor Link Cache work is billed separately from runner minutes, at the rates on the [pricing page](/pricing), checked on 2026-08-13: | Line item | Hosted runners | BYOC | | ----------------------------- | --------------------- | ---- | | Cache storage | $0.20 per GB-month | Free | | Cache write, restore, or list | $0.0001 per operation | Free | A worked month for a Rails-sized bundle, with the assumptions stated so you can substitute your own. One `vendor/bundle` entry of 400 MB, with two superseded entries still inside the 7 day window, is 1.2 GB of storage at $0.24 per month. At 800 workflow runs in the month, each doing one restore, plus 200 saves from the default branch, that is 1,000 operations at $0.10 per month. The runner minutes are the rest of the bill: 800 runs at 6 minutes each on `warp-ubuntu-latest-x64-4x`, which bills $0.008 per minute, is 4,800 minutes at $38.40\. Cache spend lands at $0.34 against $38.40 in minutes, so trimming the entry pays off in the restore seconds every job spends rather than in the storage line. One ceiling is worth planning around on GitHub's own cache backend. [GitHub caps the combined size of all caches in a repository at 10 GB by default](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching) and evicts least recently used entries once the total passes it, checked on 2026-08-13\. A Ruby monorepo with a bundle per service reaches that number sooner than a single application does. The [guide to persistent caches for GitHub Actions](/guides/persistent-caches-github-actions) works through choosing between a cache action and a snapshot. ## [Related Questions](#related-questions)Copy Anchor Link ### [Which directory do I cache when I cache bundler gems?](#which-directory-do-i-cache-when-i-cache-bundler-gems)Copy Anchor Link Cache `vendor/bundle`, the directory bundler installs into once you run `bundle config set --local path vendor/bundle`. Run that config step before the restore step, so the cached directory is the install target. Caching a system gem directory instead leaves bundler installing into a path the next run does not restore, and the job pays the full install every time. [Cache key](/glossary/cache-key) covers the key that goes with the path. ### [Why does my gem cache miss after a Ruby version bump?](#why-does-my-gem-cache-miss-after-a-ruby-version-bump)Copy Anchor Link Bundler installs into `vendor/bundle/ruby/`, where `` is a directory such as `3.3.0`, and compiled native extensions live under an `extensions` subdirectory named for both the platform triple and that ABI. A new Ruby writes a sibling directory and compiles fresh extensions, so a key built from `Gemfile.lock` alone restores a tree the new Ruby ignores. Put the Ruby version in the key and let the bump start a clean entry. [Ruby builds on GitHub Actions](/solutions/ruby-github-actions) covers runner sizing for the same workloads. ### [Do I still need to run bundle install when the cache hits?](#do-i-still-need-to-run-bundle-install-when-the-cache-hits)Copy Anchor Link Yes. Keep `bundle install` on every run. On a full hit it confirms that every gem in `Gemfile.lock` is present and exits quickly, on a partial hit from a `restore-keys` prefix it installs the gems the previous lockfile did not have, and in deployment mode it fails the job when the lockfile no longer matches the `Gemfile`. [Rails builds on GitHub Actions](/solutions/rails-github-actions) applies the same pattern alongside asset and database setup, and the [persistent caches guide](/guides/persistent-caches-github-actions) covers what to do when the restore itself becomes the slow step. --- # How Do I Cache Cargo Dependencies? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-cargo-dependencies-in-github-actions Description: Cache the registry and git directories under Cargo home plus the target directory, keyed on Cargo.lock and the toolchain version. Paths, YAML, and costs. # How Do I Cache Cargo Dependencies? Cache the registry and git directories under Cargo home plus the target directory, keyed on Cargo.lock and the toolchain version. Paths, YAML, and costs. Cache the two directories under Cargo home that hold downloaded dependencies, `~/.cargo/registry` and `~/.cargo/git`, plus the workspace `target` directory, and key the entry on a hash of `Cargo.lock` and the Rust toolchain version. The registry and git paths make a lockfile restore cheap, the `target` directory makes the compile cheap, and the toolchain in the key stops a rustc upgrade from restoring artifacts that Cargo will rebuild anyway. ## [Answer](#answer)Copy Anchor Link Cargo splits its state across two places, and they behave differently, so they belong in one entry with one key but for different reasons. The first is Cargo home, `~/.cargo` unless `CARGO_HOME` says otherwise. The [Cargo book's guidance on caching Cargo home in CI](https://doc.rust-lang.org/cargo/guide/cargo-home.html) names the subdirectories worth storing: | Path | Holds | Cache it | | ------------------------ | ------------------------------------- | ------------------------------------- | | \~/.cargo/bin | Binaries installed with cargo install | Yes, when the workflow installs tools | | \~/.cargo/registry/index | The registry index | Yes | | \~/.cargo/registry/cache | Downloaded .crate archives | Yes | | \~/.cargo/git/db | Bare clones of git dependencies | Yes | | \~/.cargo/registry/src | Archives unpacked for compilation | No, re-extracted from registry/cache | | \~/.cargo/git/checkouts | Checkouts of git dependencies | No, re-created from git/db | Storing `registry/src` and `git/checkouts` adds a second, unpacked copy of every dependency to the entry, all of it regenerated from the two directories you already saved. The second is `target`, the [build cache](https://doc.rust-lang.org/cargo/reference/build-cache.html) that holds every compiled dependency, your own crates, test binaries, and incremental state. This is where the toolchain matters. Cargo fingerprints each artifact against the compiler that produced it, so a rustc upgrade invalidates the whole tree at once. A key built only from `Cargo.lock` hits after a toolchain bump, restores several gigabytes, and then rebuilds all of it, paying the download for nothing. Put the toolchain in the key and the bump starts a clean entry instead. A working key looks like this: Copy Text ``` ${{ runner.os }}-cargo-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('**/Cargo.lock') }} ``` With `restore-keys` falling back to `${{ runner.os }}-cargo-${{ hashFiles('rust-toolchain.toml') }}-`, a lockfile change starts from the previous mostly warm `target` rather than from nothing. [Cache key](/glossary/cache-key) covers the general shape of prefix fallbacks. On WarpBuild runners the [WarpBuild cache](/docs/ci/features/caching) is a drop-in replacement for `actions/cache@v4` and is enabled by default on Linux runners. The cache is not supported on the Windows runners, so a Windows Rust job keeps using GitHub's cache backend. Cache entries expire after 7 days without use, and the cache version is derived from the compression tool and the cached paths, so an entry written on a macOS runner does not restore on a Linux runner even under an identical key. ## [Detail](#detail)Copy Anchor Link ### [A workflow that restores, builds, and saves on success](#a-workflow-that-restores-builds-and-saves-on-success)Copy Anchor Link The split restore and save actions give the save step a condition, which the combined action does not. That matters here: a build that fails partway leaves a `target` directory full of half-finished work, and saving it poisons every later run. Copy Text ``` name: rust on: push: branches: [main] pull_request: env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - name: Install toolchain run: rustup toolchain install stable --profile minimal - name: Restore cargo cache id: cargo uses: WarpBuilds/cache/restore@v1 with: path: | ~/.cargo/bin ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db target key: ${{ runner.os }}-cargo-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo-${{ hashFiles('rust-toolchain.toml') }}- - run: cargo build --workspace --locked - run: cargo test --workspace --locked - name: Trim the target directory if: success() && github.ref == 'refs/heads/main' run: | rm -rf target/debug/incremental rm -rf target/debug/examples find target/debug -maxdepth 1 -type f -delete - name: Save cargo cache if: success() && github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: | ~/.cargo/bin ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db target key: ${{ steps.cargo.outputs.cache-primary-key }} ``` Three details carry the design. `success()` on the save step keeps a failed build from overwriting a good entry. The branch condition writes entries only from the default branch, which every pull request branch can restore through GitHub's branch scoping rules, so five active branches stop storing five near-identical copies. And `cache-primary-key` from the restore step is reused as the save key, so the two steps cannot drift apart when someone edits one of them. If you would rather not maintain the paths yourself, [WarpBuilds/rust-cache](https://github.com/WarpBuilds/rust-cache) does this job with the same defaults and adds `save-if` for the branch condition and `cache-on-failure`, which stays `false` so failed runs save nothing. Install the toolchain before it runs, because the `rustc` version is part of the key it builds. The [setup actions reference](/docs/ci/features/caching/setup-actions) lists its inputs. ### [When the target directory outgrows the restore](#when-the-target-directory-outgrows-the-restore)Copy Anchor Link `target` grows without bound. Every build writes new artifacts, superseded ones stay on disk, and incremental compilation adds a directory of per-module state that is large and worth nothing on a fresh machine. The entry grows with every merge, and past some size the restore step costs more wall-clock time than the compile it removes. GitHub's own backend hits a wall first: [GitHub caps the combined size of all caches in a repository at 10 GB by default](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows) and evicts least recently used entries once the total passes it, checked on 2026-08-13. Four measures keep the entry in useful territory: * Set `CARGO_INCREMENTAL: 0` for the workflow. Incremental state helps a developer machine that rebuilds the same tree repeatedly, and on a runner that sees each commit once it adds bulk with no reuse. * Delete `target/debug/incremental`, `target/debug/examples`, and the loose binaries at the top of `target/debug` before saving. Those are your own crates, which recompile in seconds from cached dependencies. * Save only from the default branch, so superseded entries stop accumulating one per branch per lockfile bump. * Read the restore step duration in your job log against the build step it replaced. When the two converge, drop `target` from the `path` list and keep caching Cargo home alone, which stays small and always pays. For a `target` directory that stays useful at a size no cache wants to move, snapshot runners restore the whole runner disk instead of named paths, and CI observability shows which step the minutes actually go to. The [persistent caches guide](/guides/persistent-caches-github-actions) works through choosing between the two mechanisms. ### [What the cache costs](#what-the-cache-costs)Copy Anchor Link Both cache line items are published on the [pricing page](/pricing), checked on 2026-08-13: | Line item | Hosted runners | BYOC | | ----------------------------- | --------------------- | ---- | | Cache storage | $0.20 per GB-month | Free | | Cache write, restore, or list | $0.0001 per operation | Free | A worked month, with the assumptions stated so you can substitute your own: a Rust workspace holding one 4 GB entry for the current lockfile and two superseded entries inside the 7 day window is 12 GB, which is $2.40 per month. At 40 workflow runs per weekday across 22 weekdays, 880 runs each performing one restore and, on the default branch only, one save is roughly 1,000 operations, which is $0.10 per month. The runner minutes dominate: `warp-ubuntu-latest-x64-8x` bills $0.016 per minute, so the same 880 runs at 6 minutes each is $84.48\. Cache spend is the rounding error, and the reason to trim `target` is the restore seconds it costs every job rather than the storage bill. [Rust builds on GitHub Actions](/solutions/rust-github-actions) covers runner sizing for the same workloads. ## [Related Questions](#related-questions)Copy Anchor Link ### [Which Cargo directories should I cache?](#which-cargo-directories-should-i-cache)Copy Anchor Link Cache `~/.cargo/bin`, `~/.cargo/registry/index`, `~/.cargo/registry/cache`, `~/.cargo/git/db`, and the workspace `target` directory. The [Cargo book](https://doc.rust-lang.org/cargo/guide/cargo-home.html) names the first four as the Cargo home paths worth caching, because `~/.cargo/registry/src` and `~/.cargo/git/checkouts` are re-extracted from them on demand and only inflate the entry. ### [Why does my cargo cache miss after a Rust toolchain upgrade?](#why-does-my-cargo-cache-miss-after-a-rust-toolchain-upgrade)Copy Anchor Link Artifacts in `target` are fingerprinted against the compiler that produced them, so a new rustc rebuilds everything the old one compiled. Put the toolchain version in the cache key with `hashFiles('rust-toolchain.toml')` or a hash of `rustc -vV` output, so a toolchain bump starts a new entry instead of restoring dead artifacts. [Rust incremental compilation on GitHub Actions](/guides/rust-incremental-compilation-github-actions) covers what survives a rebuild and what does not. ### [Should I cache the target directory at all?](#should-i-cache-the-target-directory-at-all)Copy Anchor Link Cache it while restoring it takes less time than the compile it removes. Compare the restore step duration in the job log against the build step it saved, set `CARGO_INCREMENTAL: 0`, delete `target/debug/incremental` before saving, and drop `target` from the entry once the numbers cross. Cargo home alone stays small and keeps paying, and the [persistent caches guide](/guides/persistent-caches-github-actions) covers this same tradeoff for other large, slow-to-rebuild directories. --- # How Do I Cache CocoaPods in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-cocoapods-in-github-actions Description: Cache the Pods directory and the CocoaPods download caches with a key from Podfile.lock, then run pod install --deployment so a stale lockfile fails the job. # How Do I Cache CocoaPods in GitHub Actions? Cache the Pods directory and the CocoaPods download caches with a key from Podfile.lock, then run pod install --deployment so a stale lockfile fails the job. Cache three paths and derive the key from `Podfile.lock`: the `Pods` directory in the checkout, `~/Library/Caches/CocoaPods`, and `~/.cocoapods/repos`. Then run `pod install --deployment` so a lockfile that no longer matches the Podfile stops the job instead of resolving new versions in the background. ## [Answer](#answer)Copy Anchor Link CocoaPods work in a job splits into two halves, and they want different cache policies. Downloading pod sources and podspec metadata is content addressed and safe to reuse across lockfile changes. Integrating those pods into `Pods/` and the generated `.xcworkspace` is exact: the result belongs to one `Podfile.lock` and to nothing else. | Path | What it holds | Restore policy | | --------------------------- | ------------------------------------------------------------- | ------------------------------------------ | | Pods | Pods integrated into the workspace, plus Pods/Manifest.lock | Exact key on Podfile.lock, no restore-keys | | \~/Library/Caches/CocoaPods | Downloaded pod source archives, addressed by name and version | Prefix restore with restore-keys is safe | | \~/.cocoapods/repos | Spec repo clones and CDN trunk metadata | Prefix restore with restore-keys is safe | `hashFiles('Podfile.lock')` returns a SHA-256 of the file, so the key changes only when the resolved dependency set changes ([GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)). Add `Gemfile.lock` to the key when you install CocoaPods through Bundler, because the pod layout and the generated support files differ across CocoaPods releases. This workflow keeps the two halves separate: Copy Text ``` name: ios on: pull_request: branches: [main] jobs: build: runs-on: warp-macos-26-arm64-6x steps: - uses: actions/checkout@v4 - name: Cache pod downloads uses: actions/cache@v4 with: path: | ~/Library/Caches/CocoaPods ~/.cocoapods/repos key: ${{ runner.os }}-pod-downloads-${{ hashFiles('Podfile.lock') }} restore-keys: | ${{ runner.os }}-pod-downloads- - name: Cache integrated Pods uses: actions/cache@v4 with: path: Pods key: ${{ runner.os }}-pods-${{ hashFiles('Gemfile.lock') }}-${{ hashFiles('Podfile.lock') }} - name: Install pods run: | bundle install bundle exec pod install --deployment - name: Build run: | xcodebuild -workspace App.xcworkspace -scheme App \ -destination 'generic/platform=iOS Simulator' build ``` `pod install --deployment` disallows any change to the Podfile or `Podfile.lock` during installation, so a job that would have rewritten the lockfile exits with an error instead ([CocoaPods command reference](https://guides.cocoapods.org/terminal/commands.html)). Run it on every job rather than gating it behind a cache hit. When the `Pods` entry restored on an exact key, the step re-verifies the integration and finishes without downloading anything; when it missed, the download cache still covers the network work. `runs-on: warp-macos-26-arm64-6x` puts that job on a 6 vCPU macOS runner with 14 GB of memory and a 120GB SSD at $0.08 per minute, and `warp-macos-26-arm64-12x` doubles the shape to 12 vCPU and 28 GB at $0.16 per minute (WarpBuild [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13). The macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. Every label and rate is on the [macOS runners page](/runners/macos). ## [Detail](#detail)Copy Anchor Link ### [Why the key comes from Podfile.lock and never from Podfile](#why-the-key-comes-from-podfilelock-and-never-from-podfile)Copy Anchor Link `Podfile` declares constraints. `Podfile.lock` records the exact versions the resolver picked, and it is the file `Pods/Manifest.lock` is copied from during installation. Keying on `Podfile` gives you a stable key across a dependency bump that changed every pod, which is the worst outcome: the key hits, the stale `Pods` directory restores, and the mismatch surfaces later inside `xcodebuild`. Two files are worth adding to the key beyond `Podfile.lock`. `Gemfile.lock` pins the CocoaPods version when you run through Bundler. A checksum of any local podspec in the repository belongs there too, since a change to a development pod alters the integrated output without touching `Podfile.lock`. ### [Failure mode one: key churn from an unpinned Podfile](#failure-mode-one-key-churn-from-an-unpinned-podfile)Copy Anchor Link A Podfile that declares `pod 'Alamofire'` with no version constraint lets the resolver pick a newer release on any run that is allowed to rewrite the lockfile. `Podfile.lock` changes, `hashFiles` returns a different digest, and the job writes a fresh cache entry that the next run will also miss. The visible symptom is a restore step logging a miss on a key that looks identical to yesterday's, with no dependency change in any pull request. The cost lands twice. Every run pays a cold install, and every run leaves a full-size entry behind. GitHub caps the combined size of all caches in a repository at 10 GB by default and evicts least recently used entries once the total passes that ceiling, so a week of churn from four active branches can push out the entries that would have hit ([GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13). Cache entries are also scoped to the branch that wrote them and to the base branch, so each feature branch stores its own copy. Two changes stop the churn. Pin versions in the Podfile, or at minimum commit `Podfile.lock` and treat it as the source of truth. Then run installs in deployment mode so any drift fails the job at the install step, where the error names the file, rather than at the `xcodebuild` step, where the error names a build phase. ### [Failure mode two: a partial restore that leaves the workspace inconsistent](#failure-mode-two-a-partial-restore-that-leaves-the-workspace-inconsistent)Copy Anchor Link `restore-keys` performs a prefix match and restores the most recent entry whose key starts with the prefix. On the download caches that is what you want. On the `Pods` directory it hands the job an integration built from a different `Podfile.lock`, and `cache-hit` is only `true` on an exact primary key match, so a step written as `if: steps.pods.outputs.cache-hit != 'true'` behaves correctly while a step written to skip on any restore does not. The failure appears during compilation. The `[CP] Check Pods Manifest.lock` build phase compares `Pods/Manifest.lock` against `Podfile.lock` and fails with "The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation." A quieter version of the same problem skips that phase and links against a pod version the lockfile no longer references. Three rules keep the workspace consistent: * Keep `restore-keys` off the `Pods` entry so it either hits exactly or misses. * Run `pod install --deployment` unconditionally instead of gating it on `cache-hit`. * Cache `Pods` and the download directories as separate steps, so a miss on the exact entry still reuses the downloads. When a job does fail this way, the fastest confirmation is to compare the two lockfiles on the runner itself. The Action Debugger opens a shell on the failed runner so you can `diff Podfile.lock Pods/Manifest.lock` in place, and CI observability reports the per-step durations that show whether the install step is running cold across consecutive runs. The caching behavior described here is documented in the [WarpBuild caching documentation](/docs/ci/features/caching). ### [Where the CocoaPods step sits in an iOS pipeline](#where-the-cocoapods-step-sits-in-an-ios-pipeline)Copy Anchor Link Dependency restore is one of three caches an Xcode job usually wants. The others are Swift Package Manager artifacts and derived data. Keep them in separate steps with separate keys so a change to one lockfile does not invalidate the others, and set up the pipeline shape described in [iOS builds and tests on GitHub Actions](/solutions/ios-github-actions). Signup includes [$10 free credits](/pricing), which covers a few hundred minutes of macOS runner time while you tune the keys. ## [Related Questions](#related-questions)Copy Anchor Link ### [Should I commit the Pods directory instead of caching it?](#should-i-commit-the-pods-directory-instead-of-caching-it)Copy Anchor Link Committing `Pods` removes the restore step and the cache key entirely, and it costs a large diff on every dependency bump plus a repository that grows with every vendored source tree. Caching keeps the checkout small and keeps `pod install` as the single integration step. Either way, run `pod install --deployment` in the job so a `Podfile.lock` that no longer matches the Podfile fails the build. The trade-off between checkout size and install time is covered alongside the other Xcode levers in [cutting Xcode build times on GitHub Actions](/guides/xcode-build-times-github-actions). ### [Why does my build fail with "The sandbox is not in sync with the Podfile.lock"?](#why-does-my-build-fail-with-the-sandbox-is-not-in-sync-with-the-podfilelock)Copy Anchor Link The `[CP] Check Pods Manifest.lock` build phase compares `Pods/Manifest.lock` against `Podfile.lock` and fails when they differ. In a cached job this almost always means a `restore-keys` prefix match handed you a `Pods` directory built from a different lockfile and the workflow then skipped `pod install`. Key the `Pods` directory on an exact hash of `Podfile.lock`, keep `restore-keys` off that entry, and run `pod install` on every job. ### [Can I use the WarpBuild cache action for CocoaPods?](#can-i-use-the-warpbuild-cache-action-for-cocoapods)Copy Anchor Link The WarpBuild cache is a Linux runner feature, and CocoaPods jobs run on macOS runners, so keep those steps on `actions/cache@v4` with a key derived from `Podfile.lock`. WarpBuild macOS runners are `warp-macos-26-arm64-6x` at 6 vCPU, 14 GB and $0.08 per minute and `warp-macos-26-arm64-12x` at 12 vCPU, 28 GB and $0.16 per minute, checked on 2026-08-13 against the [cloud runners documentation](/docs/ci/cloud-runners). The same key discipline applies to Swift dependencies, which is the subject of [caching Swift Package Manager dependencies](/answers/how-do-i-cache-swift-package-manager-dependencies). --- # How Do I Cache Composer Dependencies? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-composer-dependencies-in-github-actions Description: Cache the Composer files directory, keyed on composer.lock, so composer install copies archives off local disk instead of downloading them. Paths, YAML, costs. # How Do I Cache Composer Dependencies? Cache the Composer files directory, keyed on composer.lock, so composer install copies archives off local disk instead of downloading them. Paths, YAML, costs. Cache the directory that `composer config cache-files-dir` prints, and key the entry on a hash of `composer.lock`. With that entry restored, `composer install --prefer-dist --no-interaction` copies distribution archives off the runner disk instead of fetching one archive per package from Packagist and the git hosts behind it, which turns the install step into a local copy rather than a set of downloads. ## [Answer](#answer)Copy Anchor Link Composer keeps several directories under its cache root, and they earn their place in a workflow for different reasons. The [Composer configuration reference](https://getcomposer.org/doc/06-config.md) documents each one: | Directory | Config key | Holds | Cache it | | ---------------- | --------------- | ------------------------------------------------------ | ------------------------------------------------- | | Files cache | cache-files-dir | Distribution archives downloaded per package version | Yes, this is the one that matters | | VCS cache | cache-vcs-dir | Bare git clones for packages installed from source | Yes, when composer.json declares vcs repositories | | Repository cache | cache-repo-dir | Package metadata from Packagist and other repositories | Optional, small | | Cache root | cache-dir | Parent of the three above | Cache this when you want all three in one entry | | vendor/ | none | Installed tree plus the generated autoloader | No | The files cache is the part that removes network work. `composer install` reads `composer.lock`, resolves nothing, and installs the exact versions the lock file names ([Composer basic usage](https://getcomposer.org/doc/01-basic-usage.md)). Each of those versions is a distribution archive, and a warm files cache means every archive is already on disk, so the step unpacks instead of downloading. Read the path from Composer rather than hardcoding it. On an Ubuntu runner it resolves under the user home cache directory, and the default differs on macOS and Windows, so a step that captures the value keeps the same workflow correct across a platform matrix. Copy Text ``` name: php on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: php: ["8.2", "8.3", "8.4"] steps: - uses: actions/checkout@v5 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: mbstring, intl, pdo_mysql coverage: none - name: Read the Composer files cache directory id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" - name: Restore Composer downloads uses: WarpBuilds/cache@v1 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-composer- - name: Install dependencies run: composer install --prefer-dist --no-interaction --no-progress - run: vendor/bin/phpunit --colors=always ``` Three flags carry the install step. `--prefer-dist` keeps Composer on distribution archives, which are exactly what the files cache stores, so a package that would otherwise resolve to a source checkout stays on the cached path ([Composer CLI reference](https://getcomposer.org/doc/03-cli.md)). `--no-interaction` stops any prompt from waiting on a terminal that does not exist. `--no-progress` keeps the log readable, since progress output on a non-interactive runner is a wall of repainted lines. The [setup-php](https://github.com/shivammathur/setup-php) action installs the PHP version and the extensions your suite needs, and it ships Composer, so no separate install step is required. On WarpBuild runners the [WarpBuild cache](/docs/ci/features/caching) is a drop-in replacement for `actions/cache@v4` and is enabled by default on Linux runners, so `WarpBuilds/cache@v1` takes the same `path`, `key`, and `restore-keys` inputs as the workflow above. The cache is unsupported on the Windows runners, so a Windows PHP leg stays on GitHub's cache backend. The [setup actions reference](/docs/ci/features/caching/setup-actions) lists the cache-enabled setup actions WarpBuild maintains, which cover Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise. PHP is absent from that list, so a Composer workflow wires the cache action directly, as shown above. ## [Detail](#detail)Copy Anchor Link ### [The key, and what a partial hit buys you](#the-key-and-what-a-partial-hit-buys-you)Copy Anchor Link `composer.lock` pins every package version, so a hash of that file is a precise fingerprint of the archive set the install needs. Pair the exact key with a prefix in `restore-keys`: Copy Text ``` key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer- ``` An exact hit means every archive is present and `composer install` performs no downloads. A prefix hit restores the cache written for an older lock file, which still holds every package that did not move, so Composer downloads only the versions that changed and the save step writes a fresh entry under the new key. [Cache key](/glossary/cache-key) covers how prefix fallbacks are matched and ordered. The PHP version stays out of the key on purpose. The archives are downloads from Packagist and are byte-identical whatever PHP unpacks them, so all three legs of the matrix above share one entry. The first leg to finish writes it and the others report that the key already exists, which is the expected behavior rather than an error. Repositories that publish a library and deliberately omit `composer.lock` have nothing to hash. Key those on `composer.json` instead and accept that the entry drifts from what the resolver picks, since the fallback there is a slower install rather than a wrong one. ### [Why caching the vendor directory is fragile across PHP versions](#why-caching-the-vendor-directory-is-fragile-across-php-versions)Copy Anchor Link Caching `vendor/` looks like the bigger win, because it appears to remove the install step rather than the downloads. Four things break. **The autoloader is generated, and the generation is per install.** `composer install` writes `vendor/composer/autoload_*.php` and `vendor/autoload.php` at the end of the run. A restored tree carries the map from whichever run produced it, so a package added on a branch resolves against a class map that predates it. **The platform check encodes the PHP version.** Composer writes `vendor/composer/platform_check.php` from the platform requirements resolved at install time. Restore a tree produced under PHP 8.2 into the 8.4 leg of a matrix and that leg runs a check written for a runtime it is not on, which either fails loudly or, worse, passes while the tree around it was assembled under different assumptions. Fixing this means putting the PHP version in the cache key, which turns one shared entry into one entry per leg. **Install scripts stop running.** Most teams cache `vendor/` so they can skip `composer install`, and skipping it skips the `post-install-cmd` and `post-autoload-dump` scripts declared in `composer.json` ([Composer scripts reference](https://getcomposer.org/doc/articles/scripts.md)). Laravel wires package discovery into `post-autoload-dump`, so a skipped install leaves a stale `bootstrap/cache/packages.php` and a newly added package that the framework never registers. [Laravel builds on GitHub Actions](/solutions/laravel-github-actions) covers the framework-specific steps around that. **The entry is the wrong shape.** A vendor tree is tens of thousands of small files. The files cache is a few thousand archives that are already compressed. Packing and unpacking the first shape costs more per megabyte than the second, and it is the shape you rebuild most often, since any dependency change invalidates the whole tree. The pattern that survives all four is the one at the top: cache the downloads, run `composer install` every time, and let it do the work it is designed for. When a repository genuinely needs a warm working tree rather than warm downloads, that is a different mechanism. Snapshot runners restore a whole runner disk instead of named paths, and the [persistent caches guide](/guides/persistent-caches-github-actions) works through when a disk snapshot beats a path cache. ### [Plugins, scripts, and the non-interactive rule](#plugins-scripts-and-the-non-interactive-rule)Copy Anchor Link Composer 2.2 added the `allow-plugins` setting. A plugin that is not listed there prompts for confirmation on an interactive terminal and is refused without one, so a workflow that was fine on a laptop can install a partial tree on a runner. Pin the list in `composer.json` and keep `--no-interaction` on the command so the outcome is the same in both places ([Composer configuration reference](https://getcomposer.org/doc/06-config.md)). For deployment jobs, add `--no-dev` and `--optimize-autoloader`. Both change what lands in `vendor/`, and neither changes the files cache, since the archive set is the same and Composer decides afterwards which packages to install from it. ### [What the cache costs](#what-the-cache-costs)Copy Anchor Link [Cache entries expire](/docs/ci/features/caching#expiry) after 7 days without use, and the [cache version](/docs/ci/features/caching#cache-version) is derived from the compression tool and the cached paths, so an entry written on a macOS runner does not restore on a Linux runner even under an identical key. Both cache line items come from the [pricing page](/pricing), checked on 2026-08-13: | Line item | Hosted runners | BYOC | | ----------------------------- | --------------------- | ---- | | Cache storage | $0.20 per GB-month | Free | | Cache write, restore, or list | $0.0001 per operation | Free | A worked month, with the assumptions written out so you can substitute your own. A Laravel application with roughly 300 packages holds a 0.5 GB files cache. Counting the current entry plus two superseded ones still inside the 7 day window, that is 1.5 GB, which is $0.30 per month. The repository runs 500 workflow runs a month across a three-leg PHP matrix, so around 1,600 restore and save operations, which is $0.16 per month. The runner minutes dominate: `warp-ubuntu-latest-x64-4x` bills $0.008 per minute, so 500 runs of three 4 minute legs is 6,000 minutes and $48.00\. The cache line items are $0.46 of a $48.46 bill. Sizing guidance for PHP test suites is in [PHP builds on GitHub Actions](/solutions/php-github-actions). GitHub's own cache backend works differently: it caps the combined size of all caches in a repository at 10 GB by default and evicts least recently used entries once the total passes it ([GitHub caching documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows), checked on 2026-08-13). That ceiling arrives early in a monorepo holding Composer, npm, and Docker entries at once, and a 0.5 GB Composer entry is often the one evicted. ## [Related Questions](#related-questions)Copy Anchor Link ### [Which Composer directory should I cache?](#which-composer-directory-should-i-cache)Copy Anchor Link Cache the path that `composer config cache-files-dir` prints, which holds the downloaded distribution archives. Read it in a step and pass it to the cache action rather than hardcoding it, because the default differs across Linux, macOS, and Windows. Add the `cache-vcs-dir` path when `composer.json` installs packages from git repositories, since those clones are fetched over the network too. The inputs for both paths are in the [WarpBuild cache documentation](/docs/ci/features/caching). ### [Should I cache the vendor directory instead?](#should-i-cache-the-vendor-directory-instead)Copy Anchor Link No. The tree carries a generated autoloader and a `platform_check.php` written for the PHP version that produced it, so restoring it into another matrix leg hands that leg files built for a different runtime. Caching `vendor/` to skip `composer install` also skips the `post-install-cmd` and `post-autoload-dump` scripts that frameworks rely on, which is how a Laravel repository ends up with a package the container never registers. [Laravel builds on GitHub Actions](/solutions/laravel-github-actions) has the framework-specific workflow. ### [Do I need a separate cache key per PHP version?](#do-i-need-a-separate-cache-key-per-php-version)Copy Anchor Link No for the files cache. The archives are Packagist downloads and are identical whatever PHP version unpacks them, so one key built from `runner.os` and a hash of `composer.lock` serves every leg of a matrix, and all legs read the same entry. A `vendor/` cache does need the PHP version in the key, which multiplies storage by the number of legs and is one more reason to cache the downloads. [Cache key](/glossary/cache-key) covers what belongs in a key and what belongs in `restore-keys`. --- # How Do I Cache Docker Layers in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-docker-layers-in-github-actions Description: Point the build at a remote Docker builder whose layer cache persists on its own disk, or export layers to a registry cache backend if you stay on the runner. # How Do I Cache Docker Layers in GitHub Actions? Point the build at a remote Docker builder whose layer cache persists on its own disk, or export layers to a registry cache backend if you stay on the runner. Last verified: 2026-08-13 To cache Docker layers in GitHub Actions, either point the build at a remote Docker builder whose layer cache lives on the builder's own disk and outlives the job, or, if the build stays on the runner, configure a Buildx cache backend that exports layers to a registry or cache service and imports them on the next run. The first option removes the `cache-to` and `cache-from` flags entirely; the second keeps them and pays export and import minutes on every build. ## [Answer](#answer)Copy Anchor Link Every GitHub Actions job gets a clean machine. The Buildx instance created inside that job starts with an empty layer store, and the store is destroyed with the machine when the job ends. That is why a Dockerfile with perfectly ordered layers still rebuilds every stage on every pull request: there is nothing on disk to reuse. Two mechanisms fix that, and they differ in where the layers sit between runs. | Approach | Where layers live between runs | What you configure | Per-build cache transfer | | ---------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ | | Remote Docker builder | On the builder profile's own disk, outside the job | Warpbuilds/build-push-action@v6 with a profile-name | None. Nothing is exported or imported. | | Registry cache backend | In a cache tag in a registry you control | docker/setup-buildx-action plus cache-to and cache-from of type=registry | Export and import on every build | | GitHub Actions cache backend | In the Actions cache service, scoped to the repository | The same two flags with type=gha | Export and import on every build, against the repository cache allowance | The remote builder shape is the shorter workflow file. The build runs over TLS on a persistent builder VM, the layers it produces stay on that VM, and the next build reuses them because it lands on the same machine. Copy Text ``` name: docker on: push: branches: [main] pull_request: jobs: image: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v5 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} profile-name: "app-images" timeout: 600000 ``` Three things about that file are worth reading twice. `Warpbuilds/build-push-action@v6` is a drop-in replacement for `docker/build-push-action@v6`, so the `context`, `push`, and `tags` inputs keep their meaning. `profile-name` names the builder profile that carries the cache, and it is the only input the swap adds. There is no `cache-to` and no `cache-from`, because a cached builder holds the layers itself and reuses them on the next build, per the [Docker builders documentation](/docs/ci/docker-builders). Drop the `docker/setup-buildx-action` step too, when its only purpose was creating a builder. The WarpBuild action provisions the remote builder and wires Buildx to it. If you want to keep your own build commands, `Warpbuilds/docker-configure@v1` sets up the builder and hands back its details, and `Warpbuilds/bake-action@v6` covers Bake files. The rates that arithmetic runs on are on the [pricing page](/pricing) and in the tables below. Builders attach to any runner, which matters when your fleet is mixed. The builder actions also work from a GitHub-hosted runner or a laptop with an `api-key` input. ## [Detail](#detail)Copy Anchor Link ### [The limits worth knowing before you adopt it](#the-limits-worth-knowing-before-you-adopt-it)Copy Anchor Link Three behaviors decide whether the cache is there when a build asks for it. **The profile cache resets after 10 unused days.** The builder cache has a TTL of 10 days, so a builder profile that goes unused for longer is reset automatically and the next build on it runs cold. Active repositories refresh the profile on every merge and never reach the window. A profile attached to a workflow that runs monthly reaches it every time, which makes the persistent cache worth less than the session it bills. **Concurrent builds share an eventually consistent cache.** Multiple jobs can run on the same profile in parallel, and they read one shared cache, but layers written by one in-flight build may not be visible to another in-flight build. Synchronization catches later builds up. A matrix of five jobs that all build the same base stage from cold will therefore each build that stage, and the sixth job benefits. Stagger the first warm-up build if that duplication matters, or accept it as a one-time cost per profile. **Builder sessions bill separately from the runner.** The runner and the builder are two independent resources and both appear on the invoice. A builder session is measured from when the builder action starts until the job completes, and concurrent jobs sharing a profile are billed as one session from the first job's start to the last job's completion. Multi-arch builds are the exception worth planning for: each architecture runs on its own builder instance, so an amd64 plus arm64 build produces two parallel sessions on the same profile, billed independently. Two smaller facts follow from those. Builder size is chosen independently of runner size, so a 2 vCPU job runner can drive a 192 vCPU builder profile. And when you need to invalidate deliberately rather than wait for the TTL, the API exposes a cache reset per builder profile, documented in the [Docker builders reference](/docs/ci/docker-builders) and covered in the [Docker cache invalidation guide](/guides/docker-cache-invalidation-github-actions). ### [Builder sizes and rates](#builder-sizes-and-rates)Copy Anchor Link Sizes and per-minute rates come from the [Docker builders documentation](/docs/ci/docker-builders) and the [pricing page](/pricing), checked on 2026-08-13. | Builder size | Disk | Price per minute | Architectures | | ---------------- | ----- | ---------------- | ------------------- | | 16 vCPU, 32 GB | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU, 64 GB | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU, 128 GB | 200GB | $0.24 | amd64, arm64, multi | | 96 vCPU, 192 GB | 600GB | $0.36 | amd64 | | 96 vCPU, 192 GB | 2TB | $0.52 | amd64 | | 192 vCPU, 384 GB | 600GB | $0.72 | amd64 | | 192 vCPU, 384 GB | 2TB | $0.88 | amd64 | arm64 and multi-arch profiles stop at 64 vCPU. The 96 vCPU and 192 vCPU sizes are available for amd64-only profiles. Two numbers guide the pick: roughly 8 vCPU and 16 GB of builder capacity per concurrent build job, per the concurrency guidance in the documentation, and enough disk to hold the layers of every image the profile serves, since eviction is what quietly turns a warm cache cold. The full catalog with per-size notes is on the [remote Docker builder page](/runners/docker-builders). ### [What one warm build costs](#what-one-warm-build-costs)Copy Anchor Link The model below is arithmetic on stated assumptions rather than a measurement. It reuses the step durations from the worked model in the [Docker layer caching guide](/guides/docker-layer-caching-github-actions): a 1.8 GB Node.js service image that occupies its job runner for 8.5 minutes with cold layers and 3.2 minutes with warm ones. Four builds share a builder session, with starts staggered by half a minute, so one session of 4.7 minutes carries four builds and each build is attributed 1.175 builder minutes. GitHub rate from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. WarpBuild rates from the [pricing page](/pricing), checked on 2026-08-13. | Line | GitHub-hosted 4-core Linux larger runner, cold layers | warp-ubuntu-latest-x64-2x plus a 16 vCPU builder profile, warm layers | | ------------------------------------ | ----------------------------------------------------- | --------------------------------------------------------------------- | | Job runner rate per minute | $0.012 | $0.004 | | Job minutes per build | 8.5 | 3.2 | | Job cost per build | $0.1020 | $0.0128 | | Builder rate per minute | none | $0.06 | | Builder minutes attributed per build | none | 1.175 | | Builder cost per build | none | $0.0705 | | Total per build | $0.1020 | $0.0833 | Reading it straight: on these assumptions the cached path costs $0.0187 less per build and returns the pull request 5.3 minutes sooner. Session sharing is the lever. At one build per session the builder line rises to $0.192 per build and the cached path costs more than the baseline, so the profile earns its rate when builds arrive in bursts and share it. For a same-shape list price reference without any cache in the picture, `warp-ubuntu-latest-x64-4x` at 4 vCPU and 16 GB costs $0.008 per minute against $0.012 per minute for GitHub's 4-core Linux larger runner: 33 percent lower list price (GitHub pricing, checked 2026-08-13). ### [If the build stays on the runner](#if-the-build-stays-on-the-runner)Copy Anchor Link Some jobs cannot move the build off the runner: a Dockerfile that mounts paths produced earlier in the job, a compliance rule that pins the build to one machine, or a workflow you are not ready to change. Those keep in-job Buildx and ship the cache in and out. Copy Text ``` - name: Set up Buildx uses: docker/setup-buildx-action@v3 - name: Build and push uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max ``` `mode=max` exports intermediate stages as well as the final image, which is what makes a multi-stage Dockerfile reusable. The cost is transfer time on both ends of every build, and that number is the one to measure first, because it decides whether the backend pays for itself. WarpBuild Cache can serve as that backend through a local proxy, described in the [caching documentation](/docs/ci/features/caching); the same page recommends the builder route for new work. The [Buildx cache backend guide](/guides/buildx-cache-backends-github-actions) compares registry, GitHub Actions, and local backends on transfer cost and eviction. ## [Related Questions](#related-questions)Copy Anchor Link ### [Do I still need cache-from and cache-to with a remote Docker builder?](#do-i-still-need-cache-from-and-cache-to-with-a-remote-docker-builder)Copy Anchor Link No. When the build runs on a builder profile, the layer cache sits on the builder's own disk and is reused automatically, so the `cache-to` and `cache-from` options are not required. Remove the `docker/setup-buildx-action` step as well when its only job was to create a builder, because the WarpBuild action configures the remote builder for you. The [Docker layer caching guide](/guides/docker-layer-caching-github-actions) shows the before and after of that swap on a full workflow file. ### [How long does a builder profile keep its layer cache?](#how-long-does-a-builder-profile-keep-its-layer-cache)Copy Anchor Link The builder cache has a TTL of 10 days. A builder profile that goes unused for more than 10 days is reset automatically, and the next build on that profile runs cold and repopulates the cache. A profile that serves an active repository refreshes itself on every merge and never reaches the window. For deliberate resets and for the Dockerfile changes that invalidate layers early, see the [Docker cache invalidation guide](/guides/docker-cache-invalidation-github-actions). ### [Do concurrent builds share the layer cache?](#do-concurrent-builds-share-the-layer-cache)Copy Anchor Link Yes, and the shared cache is eventually consistent. Layers written by one build may not be visible to another build running at the same moment, and they become available to later builds once synchronization completes. Two jobs racing on the same Dockerfile can therefore each pay for the same layer once. Sizing for that fan-out is covered on the [remote Docker builder page](/runners/docker-builders), which lists the per-size capacity alongside the rates. ### [Do I pay for the GitHub Actions runner and the Docker builder separately?](#do-i-pay-for-the-github-actions-runner-and-the-docker-builder-separately)Copy Anchor Link Yes. The runner and the builder are two separate resources on two lines. Builders bill per session, measured from when the builder action starts until the job completes, and concurrent jobs on one profile share a single session. Per-minute rates for both halves are on the [pricing page](/pricing), and [what is a remote Docker builder](/answers/what-is-a-remote-docker-builder) defines the underlying terms. --- # How Do I Cache Go Modules in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-go-modules-in-github-actions Description: Cache ~/go/pkg/mod under a key hashed from go.sum, and cache ~/.cache/go-build under a key that rolls every commit with restore-keys, as two steps. # How Do I Cache Go Modules in GitHub Actions? Cache \~/go/pkg/mod under a key hashed from go.sum, and cache \~/.cache/go-build under a key that rolls every commit with restore-keys, as two steps. Cache two directories under two different keys: the module cache (`GOMODCACHE`, `~/go/pkg/mod` by default) keyed on `hashFiles('**/go.sum')`, and the build cache (`GOCACHE`, `~/.cache/go-build` on Linux) keyed on something that changes every commit, with `restore-keys` so a miss falls back to the nearest previous entry. One key cannot serve both, because the module cache only changes when a dependency version changes while the build cache changes whenever you edit source. On WarpBuild runners, [WarpBuilds/cache](https://github.com/WarpBuilds/cache) is a drop-in replacement for `actions/cache@v4` and is enabled by default on Linux runners ([caching documentation](/docs/ci/features/caching)). ## [Answer](#answer)Copy Anchor Link The Go toolchain keeps two caches, and `go env GOMODCACHE GOCACHE` prints where both live on any machine. | Directory | Default path | What it holds | What changes it | Key shape | | ---------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | GOMODCACHE | \~/go/pkg/mod | Module zips and extracted dependency source, verified against go.sum | A dependency version change in the module graph | ${{ runner.os }}-gomod-${{ hashFiles('\*\*/go.sum') }} | | GOCACHE | \~/.cache/go-build on Linux, \~/Library/Caches/go-build on macOS | Compiled package artifacts and test results, including the standard library packages your code imports | Any source edit, a Go version bump, a build tag change, or a flag change such as \-race | ${{ runner.os }}-gobuild-${{ hashFiles('\*\*/go.sum') }}-${{ github.sha }} plus restore-keys | The two rows want opposite behavior from a cache action. An exact hit on the module cache is the goal, because an exact hit means the post-run save is skipped and nothing is re-uploaded. An exact hit on the build cache is close to impossible once source is moving, so that entry is designed around a prefix restore: the key carries `github.sha`, which never repeats, and `restore-keys` walks back to the most recent entry for the same `go.sum`. Separate steps, separate keys: Copy Text ``` name: go-ci on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.24" cache: false - name: Restore module cache id: gomod uses: WarpBuilds/cache@v1 with: path: ~/go/pkg/mod key: ${{ runner.os }}-gomod-${{ hashFiles('**/go.sum') }} - name: Restore build cache uses: WarpBuilds/cache@v1 with: path: ~/.cache/go-build key: ${{ runner.os }}-gobuild-${{ hashFiles('**/go.sum') }}-${{ github.sha }} restore-keys: | ${{ runner.os }}-gobuild-${{ hashFiles('**/go.sum') }}- ${{ runner.os }}-gobuild- - name: Download modules if: steps.gomod.outputs.cache-hit != 'true' run: go mod download - run: go build ./... - run: go test ./... ``` `cache: false` on the setup step is the important line. Without it the setup action writes its own entry covering the same two directories, and the job pays for both sets of transfers. ## [Detail](#detail)Copy Anchor Link ### [Why the module cache and the build cache need different keys](#why-the-module-cache-and-the-build-cache-need-different-keys)Copy Anchor Link `go.sum` lists the version and checksum of every module in the build graph, direct and indirect ([Go modules reference](https://go.dev/ref/mod#go-sum-files)), so hashing it gives the module cache a key that rolls at exactly the moments its contents change. Files in the module cache are read-only by design ([module cache reference](https://go.dev/ref/mod#module-cache)), so a step that wants to delete or rewrite that tree needs `chmod -R u+w` first, or `go clean -modcache`. The build cache is keyed by the content of everything that went into each compiled package: source, compiler version, build flags, and the inputs of every dependency ([build and test caching](https://pkg.go.dev/cmd/go#hdr-Build%5Fand%5Ftest%5Fcaching)). One character of source change invalidates that package and everything downstream of it, and leaves the rest of the entries usable. That is why the prefix restore matters more than the exact key: yesterday's build cache still answers for every package the commit did not touch. Since Go 1.20 the distribution stops shipping precompiled archives for the standard library ([Go 1.20 release notes](https://go.dev/doc/go1.20#go-command)), so a cold `GOCACHE` also means recompiling the standard library packages your code imports. Give the build cache a key hashed only from `go.sum` and it freezes: the first run after a dependency bump writes compiled artifacts, every later run gets an exact hit, and the save step never runs again until the next bump. The cache stops tracking your code within a day. ### [How the setup action's built-in cache overlaps a manual step](#how-the-setup-actions-built-in-cache-overlaps-a-manual-step)Copy Anchor Link [actions/setup-go](https://github.com/actions/setup-go) enables caching by default and stores both `GOMODCACHE` and `GOCACHE` in one entry, keyed from the Go version and the hash of `go.sum`. That is a reasonable default for a small service, and it inherits the freeze described above for the build cache half. [WarpBuilds/setup-go](https://github.com/WarpBuilds/setup-go) is the drop-in that routes the same caching through WarpBuild Cache. Caching is on by default and keyed on the hash of `go.mod`, and `cache-dependency-path` points it at `**/go.sum` for a tighter key ([setup actions documentation](/docs/ci/features/caching/setup-actions)): Copy Text ``` - uses: WarpBuilds/setup-go@v6 with: go-version: "1.24" cache-dependency-path: "**/go.sum" ``` Which one to keep comes down to where the job time goes. Keep the setup action alone when a cold build cache costs seconds, because one step and one entry is less to maintain. Take manual control with two steps when compile time dominates the job, when the repository is a monorepo whose `go.sum` sits somewhere other than the root, or when a matrix leg needs its own key. Running both at once is the configuration to avoid. ### [Cache scope, version, and expiry decide whether a restore matches](#cache-scope-version-and-expiry-decide-whether-a-restore-matches)Copy Anchor Link Three rules from the [caching documentation](/docs/ci/features/caching) determine whether the entry you saved is the entry you get back. Entries are scoped to key, version, and branch, so a cache written on a feature branch does not serve `main`. Seed on `main` and let branch jobs restore through `restore-keys`. Cache version is a hash of the compression tool and the list of paths in the step, so a cache saved by a step listing two paths cannot be restored by a step listing one. Splitting a combined Go cache into two steps starts both entries cold on the first run, and the old combined entry ages out on its own. Entries expire 7 days after last use. A repository with daily merges stays warm; a branch nobody touches stops occupying storage without any cleanup step. ### [What the two entries cost](#what-the-two-entries-cost)Copy Anchor Link Cache metering rates come from the [pricing page](/pricing) and the [caching documentation](/docs/ci/features/caching), checked on 2026-08-13: storage is $0.20 per GB-month, and each write, restore, or list operation is $0.0001\. Worked example for a mid-sized Go service: | Line item | Quantity | Rate | Monthly | | ------------------------------------------------- | -------- | ------------------ | ------- | | Module cache storage | 1.2 GB | $0.20 per GB-month | $0.24 | | Build cache storage | 2.5 GB | $0.20 per GB-month | $0.50 | | Cache operations, 2,000 jobs at 4 operations each | 8,000 | $0.0001 each | $0.80 | | Total | $1.54 | | | On BYOC runners, cache storage and cache operations are free, because the storage sits in your own cloud account. Pricing is otherwise purely usage based. ### [Where this configuration stops applying](#where-this-configuration-stops-applying)Copy Anchor Link WarpBuild Cache is not supported on Windows runners ([caching documentation](/docs/ci/features/caching)), so a Windows Go job keeps `actions/cache` and GitHub Actions Cache with the same two-key layout. When the restore transfer itself becomes the cost, the file-level cache is the wrong tool. A snapshot runner boots a job from a machine image that already has both Go caches populated. The [guide to persistent caches for GitHub Actions runs](/guides/persistent-caches-github-actions) works through the arithmetic that picks between the two mechanisms. ## [Related Questions](#related-questions)Copy Anchor Link ### [Should a Go cache key hash go.mod or go.sum?](#should-a-go-cache-key-hash-gomod-or-gosum)Copy Anchor Link Hash `go.sum`. It records the exact version and checksum of every module in the build graph, including indirect dependencies, so the key rolls when the downloaded bytes change and stays put when they do not. `go.mod` can stay identical while a transitive dependency resolves to a new version. The [cache key glossary entry](/glossary/cache-key) covers key composition and prefix matching in more depth. ### [Does actions/setup-go already cache Go modules for me?](#does-actionssetup-go-already-cache-go-modules-for-me)Copy Anchor Link Yes, and it caches the build cache in the same entry under one key derived from the Go version and the hash of `go.sum`. On an exact key hit the post-run save is skipped, so the compiled artifacts in that entry stay frozen at the state they had when `go.sum` last changed. The [setup actions documentation](/docs/ci/features/caching/setup-actions) lists the cache inputs for the WarpBuild fork of each setup action. ### [Can I keep the setup action cache and a manual cache step together?](#can-i-keep-the-setup-action-cache-and-a-manual-cache-step-together)Copy Anchor Link Pick one owner per directory. Running both means two entries covering the same paths, two uploads, and two downloads per job. Set `cache: false` on the setup action when you want manual control of the keys, or drop the manual steps and let the setup action own both directories. ### [Do warm caches change which runner size I should pick?](#do-warm-caches-change-which-runner-size-i-should-pick)Copy Anchor Link Yes. A cold job spends its minutes on downloads and recompiles, which barely use extra cores, so a large runner mostly idles. Once both caches restore, compile and test parallelism becomes the limit and core count starts paying for itself. The [runner right-sizing guide](/guides/runner-right-sizing-github-actions) walks the measurement, and the [Go on GitHub Actions page](/solutions/go-github-actions) covers the 4, 8, and 16 vCPU decision for `go build` and `go test -race` specifically. --- # How Do I Cache Gradle Builds in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-gradle-builds-in-github-actions Description: Run the Gradle setup action so Gradle User Home, wrapper distributions and build caches restore on every job. Configuration, cache keys, limits and costs. # How Do I Cache Gradle Builds in GitHub Actions? Run the Gradle setup action so Gradle User Home, wrapper distributions and build caches restore on every job. Configuration, cache keys, limits and costs. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Cache Gradle by adding `WarpBuilds/gradle-actions/setup-gradle@v5` after your JDK step, which restores the entire Gradle User Home: wrapper distributions, downloaded dependencies and the Gradle build cache. A workflow that calls only `WarpBuilds/setup-java@v5` leaves caching off by default, so every job re-downloads the wrapper and the full dependency graph until you pass a `cache` input. That default trips up most Gradle workflows. The Java setup action installs a JDK and stops there. Caching is opt-in through `cache: gradle`, and even then it covers the package-manager dependency directories rather than the full Gradle User Home. The Gradle setup action is the one that configures Gradle itself and manages the wrapper, dependency and build-cache directories as a unit. Here is the minimal working configuration on a WarpBuild runner: Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: "21" - uses: WarpBuilds/gradle-actions/setup-gradle@v5 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - run: ./gradlew build ``` No cache backend configuration appears in that file on purpose. The `WarpBuilds/*` setup actions bundle the WarpBuild cache client and use [WarpBuild Cache](/docs/ci/features/caching) automatically when the job runs on a WarpBuild runner, so the only edit against an upstream workflow is the action reference. The full list of forks and their cache inputs is in the [setup actions documentation](/docs/ci/features/caching/setup-actions). For a simpler project that only needs the dependency cache and never touches the Gradle build cache, one action is enough: Copy Text ``` - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: "21" cache: gradle cache-dependency-path: | **/build.gradle.kts **/gradle/libs.versions.toml ``` Platform scope matters before you commit to either shape. WarpBuild Cache is unavailable on Windows runners, so a Gradle job that depends on cache restores belongs on a Linux or macOS label. The [Gradle solution page](/solutions/gradle-github-actions) covers sizing for the JVM heap and worker counts that go with those labels. ## [Detail](#detail)Copy Anchor Link ### [What each action actually caches](#what-each-action-actually-caches)Copy Anchor Link The two actions cover different directory sets, and mixing them up is the usual reason a build still spends minutes resolving dependencies after caching is "on". | Action | Directories covered | Caching default | | ------------------------------------------- | ------------------------------------------------------------------------------ | ---------------------- | | WarpBuilds/setup-java@v5 with cache: gradle | Gradle dependency directories keyed on the build files you name | Off until cache is set | | WarpBuilds/gradle-actions/setup-gradle@v5 | Gradle User Home: wrapper distributions, downloaded dependencies, build caches | On | | Neither | Project-local .gradle/ and build/ output directories | Never cached | The third row is the one that surprises people. The Gradle User Home lives at `~/.gradle` and holds everything shared across projects. Project-local state sits inside the repository checkout, and no setup action carries it between jobs. Incremental build state, therefore, resets on every run even with a perfect cache hit. ### [The pull request configuration](#the-pull-request-configuration)Copy Anchor Link `cache-read-only` is the input that keeps the cache useful in a busy repository. With it set, a job restores existing entries and writes nothing back. Copy Text ``` - uses: WarpBuilds/gradle-actions/setup-gradle@v5 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} gradle-home-cache-excludes: | caches/build-cache-1 ``` Leaving writes on for every branch produces one cache entry per open pull request, and each of those entries carries a copy of the same dependency set. Restricting writes to the default branch gives one authoritative entry that every pull request restores from, which keeps storage flat as the number of open branches grows. `gradle-home-cache-excludes` is the release valve for the opposite problem. When the local build cache under `caches/build-cache-1` grows faster than it earns back, excluding it keeps the dependency and wrapper caches while dropping the largest and least stable directory. Two related inputs are worth knowing. `cache-write-only` seeds an entry without restoring one, which is how you rebuild a poisoned cache from a scheduled job. `cache-disabled` turns the whole thing off, which is the fastest way to prove whether a failing build is a cache problem. ### [How the cache key is scoped](#how-the-cache-key-is-scoped)Copy Anchor Link WarpBuild Cache is a drop-in replacement for `actions/cache@v4`, and its scoping rules follow the same model. An entry is addressed by three things: the key, the version and the branch. The version is the part that catches people. It is a hash over the compression tool used on that runner and the list of cached paths, so entries created under different runner operating systems never match each other. A Gradle User Home cache saved on `warp-macos-latest-arm64-6x` cannot restore on `warp-ubuntu-latest-x64-8x`, even though the key string is identical and the dependency set is the same. A matrix build that spans macOS and Linux therefore maintains two entries rather than one, and that is expected behavior rather than a miss to debug. Branch scoping is the second rule. A branch reads its own entries and the entries of the default branch. A feature branch that has never run cannot read a sibling feature branch's cache, which is another argument for publishing from the default branch only. Both rules are documented on the [caching page](/docs/ci/features/caching), along with the `cache-hit` output that lets a workflow skip install steps when the restore was exact. ### [Where Gradle caching still misses](#where-gradle-caching-still-misses)Copy Anchor Link Three costs survive a perfect cache hit, and it is worth knowing them before you go hunting for more cache configuration. Configuration cache invalidation is the first. Gradle invalidates the configuration cache when build logic inputs change, and that set includes environment variables, system properties, and the files the build script reads at configuration time. A workflow that injects a fresh value on every run, such as a build number or a timestamp, invalidates the configuration cache every time and pays for full configuration on every job. Keeping volatile values out of configuration-time inputs is a build-script fix rather than a caching fix. Daemon cold starts are the second. Every GitHub Actions job gets a fresh machine, so the Gradle daemon starts cold, and the JVM starts with no JIT profile. A restored Gradle User Home removes downloads and gives back task outputs, and it does nothing for daemon startup or JVM warmup. Repositories where that cost dominates usually get further by shortening the number of separate Gradle invocations per job than by caching more. Project-local state without a build scan is the third. Because `.gradle/` and `build/` are never restored, incremental task state resets each run, and without a build scan there is no per-task report telling you which tasks were cache misses. `./gradlew build --info` names the tasks it re-ran, while CI observability adds job-level CPU, memory, and duration data. [Snapshot runners](/docs/ci/features/snapshot-runners) are the tool for the state a cache leaves behind, since they boot a job from a saved image of an earlier run's disk rather than restoring named paths into a clean one. ### [What Gradle caching costs](#what-gradle-caching-costs)Copy Anchor Link Cache line items on WarpBuild are published on the [pricing page](/pricing) and in the caching documentation, checked on 2026-08-13: | Cache line item | Hosted runners | BYOC | | -------------------------------- | --------------------- | ---- | | Storage | $0.20 per GB-month | Free | | Write, restore or list operation | $0.0001 per operation | Free | Runner minutes are the other half of the arithmetic. These are the Ubuntu x64 labels a JVM build usually lands on, with per-minute rates from the [pricing page](/pricing), checked on 2026-08-13: | Runner label | vCPU | RAM | Price per minute | | -------------------------- | ---- | ------ | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 | Now the worked model, with assumptions stated so you can substitute your own. Take a repository with 900 workflow runs per month, a Gradle User Home entry of 2.5 GB, and one restore plus one save per run. * Storage: 2.5 GB at $0.20 per GB-month is $0.50 per month. * Operations: 900 runs at 2 operations each is 1,800 operations, at $0.0001 each, or $0.18 per month. * Cache total: $0.68 per month. The break-even is how much runner time that $0.68 has to buy back. Divide it by the per-minute rate, then spread the result across the 900 runs: | Runner label | Price per minute | Monthly minutes the cache must save | Seconds per run | | -------------------------- | ---------------- | ----------------------------------- | --------------- | | warp-ubuntu-latest-x64-2x | $0.004 | 170 | 11.3 | | warp-ubuntu-latest-x64-4x | $0.008 | 85 | 5.7 | | warp-ubuntu-latest-x64-8x | $0.016 | 42.5 | 2.8 | | warp-ubuntu-latest-x64-16x | $0.032 | 21.3 | 1.4 | | warp-ubuntu-latest-x64-32x | $0.064 | 10.6 | 0.7 | A Gradle dependency resolution that clears a few seconds per run covers the cache bill on any of these sizes, and the threshold falls as the runner gets larger because a minute costs more there. Measure the gap on your own repository by running the same commit with `cache-disabled: true` and comparing the wall clock of the resolution step. ### [Expiry](#expiry)Copy Anchor Link Cache entries expire after 7 days of last use. An entry that every build restores refreshes its last-use timestamp and stays available indefinitely, so the expiry only bites on branches or matrix legs that go quiet. Entries can also be deleted at any time from the job or the console, which is the fix for a cache that captured a broken dependency state. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does the Java setup action cache Gradle by default?](#does-the-java-setup-action-cache-gradle-by-default)Copy Anchor Link No. Caching is off by default in `WarpBuilds/setup-java@v5` and turns on only when you pass the `cache` input, for example `cache: gradle`. For wrapper distributions, downloaded dependencies and the Gradle build cache together, run `WarpBuilds/gradle-actions/setup-gradle@v5` after the JDK step instead. The [Gradle solution page](/solutions/gradle-github-actions) walks through the full workflow and the runner sizes that suit a JVM build. ### [Should pull request builds write to the Gradle cache?](#should-pull-request-builds-write-to-the-gradle-cache)Copy Anchor Link Usually not. Set `cache-read-only` to `true` on pull request builds so they restore the entry the default branch published and write nothing back, which keeps one authoritative entry per branch instead of one per open pull request. The [setup actions documentation](/docs/ci/features/caching/setup-actions) lists the rest of the cache inputs, including `cache-write-only` for seeding. ### [How long does a Gradle cache entry last on WarpBuild?](#how-long-does-a-gradle-cache-entry-last-on-warpbuild)Copy Anchor Link Cache entries expire after 7 days of last use, and any entry can be deleted manually from the job or the console at any time. An entry that every build restores keeps refreshing its last-use timestamp and stays available. [How long GitHub Actions caches last](/answers/how-long-do-github-actions-caches-last) covers the expiry rules and how they differ from the eviction behavior teams expect. ### [Does the same setup work for Maven and Android builds?](#does-the-same-setup-work-for-maven-and-android-builds)Copy Anchor Link Yes. `WarpBuilds/setup-java@v5` takes `maven`, `gradle` or `sbt` in its `cache` input, so a Maven build changes one value. Android builds are Gradle builds and use the same `setup-gradle` configuration on Linux runners. See [Maven builds on WarpBuild runners](/solutions/maven-github-actions) and [Android builds on WarpBuild runners](/solutions/android-github-actions) for the per-stack details, and the [pricing page](/pricing) for the per-minute rate of each label. --- # How Do I Cache Maven Dependencies? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-maven-dependencies-in-github-actions Description: Cache ~/.m2/repository with a key from hashFiles over every pom.xml, then pin resolution with -nsu and -C so a partial restore cannot change versions. # How Do I Cache Maven Dependencies? Cache \~/.m2/repository with a key from hashFiles over every pom.xml, then pin resolution with -nsu and -C so a partial restore cannot change versions. Cache the Maven local repository at `~/.m2/repository`, key the entry on `hashFiles('**/pom.xml')`, and add one `restore-keys` prefix so a dependency bump still starts from yesterday's artifacts. Then pin resolution behavior with `-nsu` and `-C` and run the build offline on an exact hit, so a partially restored repository cannot quietly resolve different versions than the run before it. ## [Answer](#answer)Copy Anchor Link Maven writes every resolved artifact into a single local repository, which defaults to `${user.home}/.m2/repository` and is configurable through `localRepository` in `settings.xml` ([Maven settings reference](https://maven.apache.org/settings.html)). One cache entry covers it. | Path | What it holds | Cache it | | ------------------- | ------------------------------------------------ | --------------------------------------- | | \~/.m2/repository | Downloaded dependencies, plugins, and their POMs | Yes, exact key with one prefix fallback | | \~/.m2/wrapper | Maven distributions unpacked by mvnw | Yes, in the same entry | | \~/.m2/settings.xml | Repository credentials and server definitions | No | | target/ | Compiled classes and packaged jars | No, rebuild it | `hashFiles('**/pom.xml')` returns a single SHA-256 over every matching file in the workspace, so the key already covers the parent POM and every module POM ([GitHub expressions reference](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#hashfiles)). Copy Text ``` name: verify on: pull_request: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" - name: Restore the Maven local repository id: m2 uses: WarpBuilds/cache@v1 with: path: | ~/.m2/repository ~/.m2/wrapper key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: | ${{ runner.os }}-maven- - name: Resolve what the restore missed if: steps.m2.outputs.cache-hit != 'true' run: ./mvnw -B -ntp -nsu -C dependency:go-offline - name: Build and test run: | offline="" if [ "${{ steps.m2.outputs.cache-hit }}" = "true" ]; then offline="-o" fi ./mvnw -B -ntp -nsu -C $offline verify - name: Trim resolver metadata before the entry is saved run: | find ~/.m2/repository -name '*.lastUpdated' -delete find ~/.m2/repository -name 'resolver-status.properties' -delete find ~/.m2/repository -type d -name '*-SNAPSHOT' -prune -exec rm -rf {} + ``` Four flags carry the resolution policy ([Maven CLI reference](https://maven.apache.org/ref/current/maven-embedder/cli.html)). `-B` runs in batch mode and `-ntp` drops per-artifact transfer progress from the log. `-nsu` stops Maven from checking remote repositories for newer snapshot builds. `-C` turns a checksum mismatch into a build failure instead of a warning. `-o` runs the build with no network access at all, so an exact cache hit means zero downloads. `WarpBuilds/cache@v1` accepts the same `path`, `key`, and `restore-keys` inputs as `actions/cache@v4` and exposes the same `cache-hit` output, so the swap above is the only change ([WarpBuild caching documentation](/docs/ci/features/caching)). For one less step, replace `actions/setup-java` with `WarpBuilds/setup-java@v5` and set `cache: maven`, which routes the same directory through the WarpBuild cache without an explicit cache step ([setup actions documentation](/docs/ci/features/caching/setup-actions)). ## [Detail](#detail)Copy Anchor Link ### [Why an unpinned POM breaks the key](#why-an-unpinned-pom-breaks-the-key)Copy Anchor Link The key promises that identical POMs mean an identical dependency set. Two things break that promise. A version range such as `[1.2,2.0)` lets the resolver pick a newer release without any POM edit, and a `SNAPSHOT` dependency changes content under a fixed coordinate. In both cases `hashFiles` returns the same digest while the artifacts behind it move, and the job restores an entry whose contents no longer match what the build should resolve. Pin plugin versions in `pluginManagement` and dependency versions in `dependencyManagement`, then enforce the rule for release builds with the Enforcer plugin's `requireReleaseDeps` ([Maven Enforcer rule reference](https://maven.apache.org/enforcer/enforcer-rules/requireReleaseDeps.html)). Where snapshots are unavoidable, `-nsu` keeps a single run consistent by resolving against the copy already on disk instead of checking the remote repository mid-build. ### [What the offline flag buys, and where it bites](#what-the-offline-flag-buys-and-where-it-bites)Copy Anchor Link `dependency:go-offline` resolves declared dependencies and plugin artifacts ahead of the build ([Maven Dependency Plugin reference](https://maven.apache.org/plugins/maven-dependency-plugin/go-offline-mojo.html)), and it does not catch plugins that resolve their own dependencies lazily at execution time. That is why the workflow above runs offline only after an exact hit, when the previous run of the same POM set already wrote every artifact the build touched. On a prefix restore or a miss, the build stays online and repopulates the entry. Offline mode is also the fastest way to prove the cache is doing its job. A build that passes with `-o` resolved everything from disk. A build that fails with `-o` names the missing coordinate in the error, which is a precise list of what the entry is missing. ### [Keep one entry for the whole repository](#keep-one-entry-for-the-whole-repository)Copy Anchor Link A reactor build resolves every module into the same local repository, and module dependency sets overlap heavily: Jackson, SLF4J, JUnit, and the plugin classpath land in almost every module. Per-module cache entries store those artifacts once per module, multiply the operation count, and still leave the reactor build resolving from one directory on disk. Matrix jobs need the same care. Parallel legs that share a key each try to save at the end, and only the first save wins while the rest log that the entry already exists. Warm the entry from one push-triggered job on the default branch, and let pull request branches reach it through `restore-keys`, since a run can restore entries from its own branch and its base branch only ([GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)). ### [What the entry costs](#what-the-entry-costs)Copy Anchor Link Cache storage on hosted runners is $0.20 per GB-month and each write or restore operation is $0.0001, both free on BYOC ([caching documentation](/docs/ci/features/caching), checked on 2026-08-13). Take a Maven monorepo with a 4 GB local repository, five live entries across branches, and 900 jobs a month on `warp-ubuntu-latest-x64-8x` at $0.016 per minute ([pricing page](/pricing), checked on 2026-08-13): * Storage: 5 x 4 GB x $0.20 = $4.00 per month. * Operations: 900 jobs x 2 operations x $0.0001 = $0.18 per month. * Resolution time avoided at 2 minutes per job: 900 x 2 x $0.016 = $28.80 per month of runner time that is no longer spent downloading. When the numbers above do not match your runs, CI observability reports per-step durations so the restore step and the resolve step can be read separately, and the Action Debugger opens a shell on a failed runner so you can inspect `~/.m2/repository` in place. The tradeoff between a cache action and a snapshot runner for build state is covered in [persistent caches for GitHub Actions runs](/guides/persistent-caches-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link ### [Should I cache \~/.m2 or \~/.m2/repository?](#should-i-cache-m2-or-m2repository)Copy Anchor Link Cache `~/.m2/repository`, and add `~/.m2/wrapper` when the project uses the Maven Wrapper, since `mvnw` unpacks its distribution there. The parent directory also holds `settings.xml` and `settings-security.xml`, which carry repository credentials and master passwords, so caching `~/.m2` wholesale writes secrets into an entry every job on the branch can restore. The rest of the pipeline shape is on the [Maven builds on GitHub Actions](/solutions/maven-github-actions) page. ### [Do I still need restore-keys if the key already covers every pom.xml?](#do-i-still-need-restore-keys-if-the-key-already-covers-every-pomxml)Copy Anchor Link Yes, because one dependency bump changes the hash and turns an otherwise warm run cold. A single prefix such as `${{ runner.os }}-maven-` restores the most recent entry and leaves Maven to download only what changed, the fallback behavior GitHub documents for `restore-keys` ([GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)). Pair it with `-nsu` and `-C` so the partial repository cannot resolve a different snapshot build or accept a corrupted artifact. ### [Should each module in a multi-module build get its own cache entry?](#should-each-module-in-a-multi-module-build-get-its-own-cache-entry)Copy Anchor Link No. Maven resolves the whole reactor into one local repository, and module dependency sets overlap heavily, so per-module entries store the same artifacts many times and multiply the operation count. Keep one entry keyed on `hashFiles('**/pom.xml')`. Sizing the runner for a large reactor is covered in [faster Java builds on GitHub Actions](/solutions/java-github-actions). ### [Does the WarpBuild cache work for Maven jobs on Windows runners?](#does-the-warpbuild-cache-work-for-maven-jobs-on-windows-runners)Copy Anchor Link No. WarpBuild caching is not supported on Windows runners, so a Windows Maven job stays on `actions/cache@v4` with the same key and path shape, while Linux jobs use `WarpBuilds/cache@v1` as a drop-in replacement. The same repository can split its Maven matrix across platforms and use the cache backend each one supports. Gradle projects cache a different directory and answer to different flags, which is the subject of [caching Gradle builds in GitHub Actions](/answers/how-do-i-cache-gradle-builds-in-github-actions). --- # How Do I Cache node_modules in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-node-modules-in-github-actions Description: Cache the package manager store instead of node_modules: point WarpBuilds/setup-node@v6 at your lockfile with the cache input and let the action key it. # How Do I Cache node\_modules in GitHub Actions? Cache the package manager store instead of node\_modules: point WarpBuilds/setup-node@v6 at your lockfile with the cache input and let the action key it. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Cache the package manager store instead of the `node_modules` directory, and let the setup action do the keying for you. On WarpBuild runners that means `WarpBuilds/setup-node@v6` with the `cache` input set to `npm`, `yarn`, or `pnpm`, and `cache-dependency-path` pointed at the lockfile that drives the install. Here is the whole configuration for an npm repository: Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm cache-dependency-path: package-lock.json - run: npm ci - run: npm test ``` `WarpBuilds/setup-node` is a drop-in replacement for the upstream setup action. It takes the same inputs and produces the same outputs, and on WarpBuild runners it routes the cache through WarpBuild Cache with no extra configuration. Swapping the action reference is the only workflow edit required, as described in the [setup actions documentation](/docs/ci/features/caching/setup-actions). From v6 the action turns npm caching on by itself when `package.json` declares the package manager. If the `packageManager` or `devEngines.packageManager` field is set to `npm`, you get the cache without writing the `cache` input at all. Set `package-manager-cache: false` to turn that automatic behavior off. What each value of `cache` actually stores: | cache value | What the action caches | Key file it hashes by default | | ----------- | --------------------------------------------- | ----------------------------- | | npm | The npm cache directory, \~/.npm | package-lock.json | | yarn | The yarn cache folder reported by yarn config | yarn.lock | | pnpm | The pnpm content addressable store | pnpm-lock.yaml | None of those paths is `node_modules`. The action restores the packed artifacts that `npm ci`, `yarn install`, or `pnpm install` would otherwise download, and the install command still runs and still writes a `node_modules` tree that matches the lockfile exactly. ## [Detail](#detail)Copy Anchor Link ### [Why the store beats the directory](#why-the-store-beats-the-directory)Copy Anchor Link Three properties separate a store cache from a `node_modules` cache. The first is keying. The store maps cleanly onto the lockfile: one lockfile state produces one set of packed tarballs, so `hashFiles` over the lockfile is an exact description of the cache contents. A `node_modules` tree is the output of resolving that lockfile against a specific Node version, a specific platform, and whatever postinstall scripts ran, so the lockfile hash under-describes it. Two runs with the same key can legitimately need different trees. The second is restore shape. The store holds a few thousand compressed tarballs. A `node_modules` tree in a mid-sized repository holds hundreds of thousands of small files plus symlinks and platform binaries. Cache restore work scales with file count, and unpacking a tree of that shape is the expensive half of the operation. The store trades that for one decompression pass plus a local install that never touches the network. The third is portability. Cache entries carry a version hash covering the compression tool used on the runner OS and the list of cached paths, so an entry written on one platform will refuse to match on another. A cache saved on `warp-macos-latest-arm64-6x` cannot restore on `warp-ubuntu-latest-x64-4x` because the versions differ. The store is content addressed and survives that split more predictably than a tree full of compiled native modules. Windows runners are the one gap: WarpBuild caching is not supported there, per the [caching documentation](/docs/ci/features/caching). ### [The key, and what invalidates it](#the-key-and-what-invalidates-it)Copy Anchor Link The cache is scoped to the key, the version, and the branch. That third scope is the one that surprises people. A feature branch does not read the entry written by another feature branch; it reads main's entry through the normal fallback path and writes its own. So the useful mental model is one warm entry per lockfile state per long-lived branch, plus short-lived entries that expire. Entries expire after 7 days of last use. A branch that builds every day keeps its entry alive indefinitely. A release branch that builds monthly pays a cold install every time, and no amount of key tuning changes that. A key change means a full cold install. With a lockfile-derived key that is the correct behavior: a dependency bump should not restore stale tarballs. It also means the first run after every dependency update costs full price, which is worth remembering when you read a single slow run as a cache failure. If your entries miss on runs where nothing changed, the cause is elsewhere, and [why a GitHub Actions cache misses on every run](/answers/why-does-my-github-actions-cache-miss-every-run) walks the usual suspects. ### [Monorepos with more than one lockfile](#monorepos-with-more-than-one-lockfile)Copy Anchor Link `cache-dependency-path` accepts wildcards and multiple paths, which gives you two strategies. A glob hashes everything into one key: Copy Text ``` - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm cache-dependency-path: '**/package-lock.json' ``` An explicit list scopes each job to the lockfiles it installs from: Copy Text ``` jobs: web: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm cache-dependency-path: | apps/web/package-lock.json packages/ui/package-lock.json - run: npm ci --prefix apps/web ``` The glob is right for a workspace with a single root install, where every job resolves the same dependency graph anyway. The explicit list is right when jobs install independently, because it stops a change in the api workspace from invalidating the web job's entry. Teams running pnpm workspaces usually have one root `pnpm-lock.yaml` and should use the glob; the per-job list matters most for repositories that grew several independent installs. [pnpm workflows on WarpBuild runners](/solutions/pnpm-github-actions) covers the store layout in more detail, and [Node.js builds on WarpBuild runners](/solutions/nodejs-github-actions) covers the rest of the pipeline. ### [When to reach for the generic cache action](#when-to-reach-for-the-generic-cache-action)Copy Anchor Link The setup action covers the package manager store. Everything else in a Node repository that is worth caching needs the general purpose action. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` and takes the same inputs: Copy Text ``` - name: Restore browser binaries uses: WarpBuilds/cache@v1 with: path: ~/.cache/Cypress key: ${{ runner.os }}-cypress-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-cypress- ``` Test runner binaries, build output caches, and generated type artifacts all belong here. Keep them in separate entries from the dependency store so that a dependency bump does not throw away a build cache that is still valid. When the install cost is dominated by native module compilation that no store can replay, the cache layer stops being the right lever. Snapshot runners boot a job from a runner VM captured mid workflow, so the compiled state is already on disk when the job starts. The wider product surface around that includes remote Docker builders, CI observability, an MCP server, and the Action Debugger; the observability reports are where you check whether the install step is actually the thing costing you minutes before changing anything. ### [What it costs](#what-it-costs)Copy Anchor Link Cache is billed separately from runner minutes. Rates from the [caching documentation](/docs/ci/features/caching) and the [WarpBuild pricing page](/pricing): | Item | Hosted runners | BYOC | | ----------------------------- | --------------------- | ---- | | Cache storage | $0.20 per GB-month | Free | | Cache write, restore, or list | $0.0001 per operation | Free | Worked model for one repository. Suppose the npm store cache is 1.5 GB, three distinct lockfile states stay live across branches in a month, and the repository runs 1,500 jobs with 1,500 restores and 200 saves. * Storage: 3 x 1.5 GB = 4.5 GB x $0.20 = $0.90 per month. * Operations: 1,700 x $0.0001 = $0.17 per month. * Cache total: $1.07 per month. Now the minutes side, with illustrative install durations you should replace with your own numbers from the jobs report. Suppose `npm ci` takes 95 seconds cold and 25 seconds warm. That is 70 seconds per job, 1,500 jobs, or 1,750 billed minutes. On `warp-ubuntu-latest-x64-4x` at $0.008 per minute those minutes cost $14.00, against $1.07 of cache bill. The arithmetic is the point rather than the assumed durations: caching pays whenever the minutes it removes are worth more than the storage and operations it adds, and at these rates the crossover sits at roughly 134 minutes of install time per month. The same minutes at GitHub-hosted list prices, from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13: | Shape | WarpBuild label | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | | ------------- | ------------------------- | -------------------- | ------------------------------------- | ----------------- | | 2 vCPU, 8 GB | warp-ubuntu-latest-x64-2x | $0.004 | ubuntu-latest on private repositories | $0.006 | | 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | $0.008 | 4-core Linux larger runner | $0.012 | | 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | $0.016 | 8-core Linux larger runner | $0.022 | At 4 vCPU that is $0.008 against $0.012, a 33 percent lower list price on the same shape, and at 8 vCPU $0.016 against $0.022, 27 percent lower (GitHub pricing, checked on 2026-08-13). Every cost claim on this page carries a number, a source link, and a checked-on date, and the same numbers appear on every WarpBuild surface. Full rates by runner type are on the [WarpBuild pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [How do I set cache-dependency-path in a monorepo with several lockfiles?](#how-do-i-set-cache-dependency-path-in-a-monorepo-with-several-lockfiles)Copy Anchor Link Pass a multi line list or a glob. A glob such as `**/package-lock.json` hashes every lockfile in the repository into one key, so any package bump invalidates the entry for every job. An explicit list per job, for example `apps/web/package-lock.json` on the web job and `apps/api/package-lock.json` on the api job, keeps the two entries independent so a change in one workspace leaves the other warm. Pick the glob when workspaces share a single install, and the explicit list when each job installs on its own. ### [How long does a GitHub Actions dependency cache last on WarpBuild?](#how-long-does-a-github-actions-dependency-cache-last-on-warpbuild)Copy Anchor Link Cache entries expire after 7 days of last use, and you can delete an entry at any time from the action or the console. Active branches keep their entries alive by touching them on every run, so the entries that expire are the ones nobody restored for a week. A branch that builds once a month always pays the cold install cost, which is why the first run after a quiet period looks slow. [How long GitHub Actions caches last](/answers/how-long-do-github-actions-caches-last) covers the retention rules in full. ### [What does dependency caching cost on WarpBuild?](#what-does-dependency-caching-cost-on-warpbuild)Copy Anchor Link Cache storage bills at $0.20 per GB-month and every write, restore, or list operation bills at $0.0001 on hosted runners. Cache is free on BYOC, where the storage sits in your own cloud account. A repository holding 4.5 GB of store cache and running 1,500 jobs a month pays about $1.07\. Full rates are on [the WarpBuild pricing page](/pricing). ### [Should I ever cache the node\_modules directory itself?](#should-i-ever-cache-the-node%5Fmodules-directory-itself)Copy Anchor Link Only when the install step is dominated by native module compilation that the store cannot replay, and only with a key that includes the runner OS, the architecture, and the Node major version. `node_modules` holds absolute paths, platform specific binaries, and symlinks, so an entry saved on one runner shape breaks on another. Cache the store first and measure the install step before reaching for the directory. --- # How Do I Cache pip Dependencies in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-pip-dependencies-in-github-actions Description: Caching is off by default on the Python setup action. Set cache to pip, pipenv, or poetry and point cache-dependency-path at the file that changes. # How Do I Cache pip Dependencies in GitHub Actions? Caching is off by default on the Python setup action. Set cache to pip, pipenv, or poetry and point cache-dependency-path at the file that changes. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Caching pip dependencies in GitHub Actions takes one input, because caching is off by default on the Python setup action and a workflow that never sets it re-downloads every package on every run. Set `cache` to `pip`, `pipenv`, or `poetry`, and point `cache-dependency-path` at the requirements or lock file whose contents should invalidate the entry. On WarpBuild runners the action to use is `WarpBuilds/setup-python@v6`, a drop-in replacement for the upstream Python setup action that accepts the same inputs and sends the cache traffic to WarpBuild Cache instead of GitHub Actions Cache. Copy Text ``` - uses: WarpBuilds/setup-python@v6 with: python-version: '3.12' cache: pip cache-dependency-path: requirements.txt ``` Two inputs carry the whole behavior: * `cache` names the package manager whose artifact directory gets stored and restored. Valid values are `pip`, `pipenv`, and `poetry`. Leave it unset and nothing is cached. * `cache-dependency-path` names the file whose hash goes into the cache key. Leave it unset and the action looks for the default dependency file for the package manager you named, which is correct only when that file sits at the repository root and there is exactly one of them. | Package manager | cache value | File to hash | What gets stored | | --------------- | ----------- | ---------------- | ------------------------------------------ | | pip | pip | requirements.txt | pip download and wheel cache directory | | Pipenv | pipenv | Pipfile.lock | Pipenv virtualenv and download directories | | Poetry | poetry | poetry.lock | Poetry cache directory | The default-off behavior is specific to Python. The Go setup action caches by default and keys on the hash of `go.mod`, so a Go workflow that never mentions caching still gets it, and a Python workflow copied from the same repository does not. That mismatch is the single most common reason a Python job keeps installing the same packages from scratch on every run while a neighboring Go job does not. The per-language table of defaults and inputs is in the [WarpBuild setup actions documentation](/docs/ci/features/caching/setup-actions). One thing the input does not do: it does not cache installed packages. The restore removes the download and, on a hit, the wheel build. The `pip install` step still runs on every job. Language-level sizing and matrix layout are covered on the [Python on GitHub Actions solution page](/solutions/python-github-actions). ## [Detail](#detail)Copy Anchor Link ### [Point cache-dependency-path at the file that changes](#point-cache-dependency-path-at-the-file-that-changes)Copy Anchor Link The cache key is only as good as the file behind it. Three layouts break the default: 1. **Requirements outside the root.** A service in `services/api/requirements.txt` with nothing at the root means the action finds no file to hash. Set `cache-dependency-path: services/api/requirements.txt`. 2. **Split requirements files.** A repository with `requirements.txt` and `requirements-dev.txt` hashes only what you name. If the job installs both, name both, one per line, so a change to either one produces a new key. 3. **Constraints and extras.** A `pyproject.toml` that pins nothing and a `poetry.lock` that pins everything are different keys. Hash the lock file rather than the manifest whenever a lock file exists. The path input accepts globs and multiple lines: Copy Text ``` - uses: WarpBuilds/setup-python@v6 with: python-version: '3.12' cache: pip cache-dependency-path: | requirements.txt requirements-dev.txt services/*/requirements.txt ``` The failure mode of an over-broad glob is the opposite of the failure mode of a missing one. Hash every `requirements.txt` in a monorepo and any team's dependency bump busts every other team's cache. Hash too few and a real dependency change restores a stale entry, which pip then has to reconcile at install time. Start narrow, at the files the job actually installs from. ### [Follow-on cost 1: wheels that build from source on every miss](#follow-on-cost-1-wheels-that-build-from-source-on-every-miss)Copy Anchor Link A cache miss on a pure-Python project costs a download. A cache miss on a project with compiled dependencies costs a compile. Packages that ship prebuilt wheels for your interpreter version and platform install in seconds; packages that do not ship a matching wheel are downloaded as source distributions and built on the runner, which pulls in a compiler toolchain and turns a 20 second install into a multi-minute one. This shows up most sharply on two axes. New interpreter releases arrive before every project has published wheels for them, so a matrix leg on the newest Python builds from source while the others do not. And a Linux ARM64 leg can hit source builds for a package whose x64 wheel exists, because the maintainer publishes only one architecture. The pip cache directory covers this, which is why it is worth enabling even on projects that install quickly. pip stores the wheel it built locally, so the compile is paid once per key rather than once per job. That makes the cost of a busted key higher than it looks, because the run that misses pays for the compile as well as for the bytes. If the compile is unavoidable and lands on every run anyway, the next lever is the machine image rather than the package cache. Snapshot runners capture a runner VM mid-workflow and boot later jobs from that snapshot, which moves toolchain installs and long source builds out of the job entirely. Snapshot runners sit alongside remote Docker builders, GitHub Actions observability, an MCP server, and the Action Debugger in the WarpBuild product surface. The arithmetic for that trade is at the end of this page. ### [Follow-on cost 2: virtualenv layouts the default paths do not cover](#follow-on-cost-2-virtualenv-layouts-the-default-paths-do-not-cover)Copy Anchor Link The second cost is quieter. The setup action knows where pip, Pipenv, and Poetry keep their own directories. It does not know where your workflow put a virtualenv. A job that runs `python -m venv .venv` and installs into it gets a correct pip cache and an uncached `.venv`. Every run restores the downloads and then rebuilds the environment from them. That is still a win over no cache, and it is roughly half the win available. Two shapes fix it: * **Let the tool own the environment.** Poetry and Pipenv keep environments in directories the setup action already handles. Removing a hand-rolled `venv` step is usually less work than caching around it. * **Cache the environment explicitly.** Add a `WarpBuilds/cache@v1` step keyed on the same lock file hash. That action is a drop-in replacement for `actions/cache@v4` and takes the same inputs, documented in [WarpBuild caching](/docs/ci/features/caching). A virtualenv cache carries one hazard the download cache does not. A virtualenv holds absolute paths and compiled artifacts tied to the interpreter that created it. Put the interpreter version in the key, and never restore one across runner images. ### [The full workflow](#the-full-workflow)Copy Anchor Link Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: pytest: runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: python-version: ['3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: pip cache-dependency-path: | requirements.txt requirements-dev.txt - name: Restore project virtualenv id: venv-cache uses: WarpBuilds/cache@v1 with: path: .venv key: venv-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('requirements.txt', 'requirements-dev.txt') }} - name: Create virtualenv if: steps.venv-cache.outputs.cache-hit != 'true' run: | python -m venv .venv .venv/bin/pip install -r requirements.txt -r requirements-dev.txt - run: .venv/bin/pytest -q ``` The `cache-hit` output is what makes the second layer pay. That output reads `true` only on an exact key match, so the install step is skipped on a hit and runs on a partial match, which is the behavior you want when a `restore-keys` prefix pulled in a near miss. ### [What the key covers, and why a macOS entry never restores on Linux](#what-the-key-covers-and-why-a-macos-entry-never-restores-on-linux)Copy Anchor Link A WarpBuild cache entry is scoped to three things: the key, the version, and the branch. The key is what you write. The version is a hash covering the compression tool and the list of cached paths, so an entry written on `warp-macos-latest-arm64-6x` cannot restore on `warp-ubuntu-latest-x64-4x` even when both jobs compute the same key string. The branch scope means a feature branch reads entries written on its base branch but not the reverse. For a Python matrix this has one practical consequence. A matrix over `['3.11', '3.12', '3.13']` on one runner image produces three entries, one per interpreter, and each one warms independently. The first run after adding a version is a full cold install for that leg only. One platform note. WarpBuild caching is not supported on Windows runners. A cross-platform Python matrix should expect the Windows leg to install cold each time and should size that leg on that basis. The caching limitation is stated in the [WarpBuild caching documentation](/docs/ci/features/caching). ### [What the cache costs](#what-the-cache-costs)Copy Anchor Link Cache work bills separately from runner minutes on hosted runners and is free on BYOC: | Item | Hosted rate | Unit | BYOC | | ----------------------------- | ----------- | ----------------- | ---- | | Cache storage | $0.20 | per GB-month | Free | | Cache write, restore, or list | $0.0001 | per operation | Free | | Snapshot restore | $0.04 | per job | Free | | Snapshot storage | $0.025 | per snapshot-hour | Free | Worked model for a mid-sized Python repository. Take a 600 MB pip cache, 3,000 jobs per month, one restore per job, and 40 writes per month from dependency bumps. * Storage: 0.6 GB x $0.20 = $0.12 per month. * Operations: (3,000 restores + 40 writes) x $0.0001 = $0.30 per month. * Cache bill: $0.42 per month. Now the minute side. Plug in your own step durations from the job log; the numbers below are placeholders for the arithmetic. Say the cold install takes 95 seconds and the warm restore plus install takes 20 seconds. That is 75 seconds per job, or 3,750 minutes across 3,000 jobs. | Line | Rate per minute | 3,750 minutes | Source | | -------------------------------------------------------- | --------------- | ------------- | ---------------------------------------- | | warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) | $0.008 | $30.00 | WarpBuild pricing page | | GitHub-hosted 4-core Linux larger runner (4 vCPU, 16 GB) | $0.012 | $45.00 | GitHub list price, checked on 2026-08-13 | GitHub list prices come from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. On the WarpBuild line, $30.00 of runner minutes comes off the bill against a $0.42 cache bill, which is the shape of the trade at almost any repository size: cache storage and operations are small next to the minutes they remove. Run the same arithmetic against your own durations before deciding anything. Every rate above is on the [WarpBuild pricing page](/pricing). ### [When to reach for a snapshot instead](#when-to-reach-for-a-snapshot-instead)Copy Anchor Link If the install stays slow after the cache is warm, because a source build lands on every run or the toolchain itself takes minutes to install, price a snapshot against it. At 3,000 jobs per month, snapshot restores cost 3,000 x $0.04 = $120.00, and one snapshot held for a 730 hour month costs 730 x $0.025 = $18.25\. That is $138.25 per month, or about $0.046 per job. At $0.008 per minute on `warp-ubuntu-latest-x64-4x`, a snapshot pays for itself when it removes more than about 5.8 minutes of setup per job. Below that threshold, the pip cache alone is the cheaper instrument. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does the Python setup action cache pip by default?](#does-the-python-setup-action-cache-pip-by-default)Copy Anchor Link No. Caching is off by default on the Python setup action and you turn it on with the `cache` input, set to `pip`, `pipenv`, or `poetry`. This differs from the Go setup action, where caching is on by default and keyed on the hash of `go.mod`. On WarpBuild runners, `WarpBuilds/setup-python@v6` routes those reads and writes to WarpBuild Cache with no other workflow change. The full input list is in the [setup actions documentation](/docs/ci/features/caching/setup-actions). ### [How long does a cached pip entry last?](#how-long-does-a-cached-pip-entry-last)Copy Anchor Link A cache entry expires after 7 days of last use, and you can delete it earlier from the action run or the WarpBuild console. An entry that is restored daily keeps resetting that window, so an active branch holds its pip cache indefinitely while a merged branch drops out about a week later. [The answer on how long GitHub Actions caches last](/answers/how-long-do-github-actions-caches-last) covers the eviction path in more detail. ### [What does caching pip dependencies cost?](#what-does-caching-pip-dependencies-cost)Copy Anchor Link On WarpBuild hosted runners, cache storage bills at $0.20 per GB-month and each write, restore, or list operation bills at $0.0001\. Both are free on BYOC. A 600 MB pip cache restored on 3,000 jobs per month with 40 writes bills $0.12 in storage and $0.30 in operations, or $0.42 per month. Full rates are on the [WarpBuild pricing page](/pricing). ### [Should I cache the pip download cache or the installed virtualenv?](#should-i-cache-the-pip-download-cache-or-the-installed-virtualenv)Copy Anchor Link Cache the pip download cache first, because that is what the setup action handles for you and it removes the network and wheel-build work. Add a second cache step for a virtualenv only when the install step itself is the slow part and the environment lives at a path you control. [The Python on GitHub Actions solution page](/solutions/python-github-actions) walks through both layouts. --- # How Do I Cache Playwright Browsers? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-playwright-browsers-in-github-actions Description: Cache the Playwright browser download directory with a key built from the installed Playwright version, and install the system libraries in a separate step. # How Do I Cache Playwright Browsers? Cache the Playwright browser download directory with a key built from the installed Playwright version, and install the system libraries in a separate step. Cache the directory Playwright downloads browsers into, and build the key from the installed Playwright version rather than from a lockfile hash, because every browser build is pinned to the release that downloaded it. Keep the system library install in its own step, so a cache hit skips the browser download without skipping the apt packages those browsers link against. ## [Answer](#answer)Copy Anchor Link Playwright writes browser builds to a shared download directory outside the checkout, and the path depends on the operating system ([Playwright browsers reference](https://playwright.dev/docs/browsers#managing-browser-binaries)). | Runner platform | Browser download directory | Cache action to use | | --------------- | -------------------------------------------- | --------------------------------------------------------------------------- | | Linux | \~/.cache/ms-playwright | [WarpBuilds/cache](https://github.com/WarpBuilds/cache) or actions/cache@v4 | | macOS | \~/Library/Caches/ms-playwright | actions/cache@v4 | | Windows | %USERPROFILE%\\AppData\\Local\\ms-playwright | actions/cache@v4 | The WarpBuild cache is a Linux runner feature covering Linux x64 and Linux ARM64 ([caching documentation](/docs/ci/features/caching) and the [cloud runners reference](/docs/ci/cloud-runners), checked on 2026-08-13). Browser suites belong on Linux x64 anyway, which is where the browser builds and their system packages are best supported, so the Linux row is the one most workflows configure. The key carries the Playwright version, read out of the installed package rather than guessed: Copy Text ``` name: e2e on: pull_request: branches: [main] jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci - name: Resolve the installed Playwright version id: pw run: | echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT" - name: Restore browser binaries id: browsers uses: WarpBuilds/cache@v1 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.pw.outputs.version }} - name: Install browsers and system libraries if: steps.browsers.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium firefox webkit - name: Install system libraries only if: steps.browsers.outputs.cache-hit == 'true' run: npx playwright install-deps chromium firefox webkit - run: npx playwright test ``` Three details in that file do the work. The version comes from `@playwright/test/package.json` after `npm ci`, so it reflects what the job actually installed. The browser entry carries no `restore-keys`, so it either hits exactly or misses. And the browser download and the system library install are separate steps with opposite conditions, which is what lets a hit skip the download and still leave the runner able to launch a browser. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` and takes the same `path`, `key`, `restore-keys`, and `cache-hit` inputs and outputs, so the swap is the `uses` line ([caching documentation](/docs/ci/features/caching)). `WarpBuilds/setup-node` is the matching drop-in for `actions/setup-node` and routes the npm dependency cache through the same backend with no extra configuration ([setup actions documentation](/docs/ci/features/caching/setup-actions)). That job runs on `warp-ubuntu-latest-x64-4x` at 4 vCPU, 16 GB and $0.008 per minute ([cloud runners reference](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Detail](#detail)Copy Anchor Link ### [Why the key hashes the version and not the lockfile](#why-the-key-hashes-the-version-and-not-the-lockfile)Copy Anchor Link Playwright downloads a specific browser revision per release and stores it under a revision-numbered directory such as `chromium-1148`. The mapping from Playwright version to browser revision is fixed by the release, which makes the version the only input that decides whether a stored browser set is still the right one. A lockfile hash gets both directions wrong. It changes when an unrelated dependency moves, which rolls the key and discards browsers that were still correct, so the next job downloads a full set for nothing. It can also stay unchanged across a Playwright bump applied outside the lockfile you hashed, in a monorepo workspace or a container image, which hands the job browsers that the new release will not use. Reading the version at run time also survives version ranges. A `package.json` that declares `^1.56.0` resolves to whatever the lockfile pinned, and the step output reports the resolved value rather than the range. The general shape of a key, and what each segment is for, is in the [cache key definition](/glossary/cache-key). Two behaviors of the cache itself matter once the key is right. Entries are scoped to the key, the cache version, and the branch, so seeding the entry on your default branch is what makes pull request jobs hit. The cache version is a hash over the compression tool and the cached paths, which means an entry written on a macOS runner cannot restore on a Linux runner even with an identical key ([caching documentation](/docs/ci/features/caching)). Entries expire 7 days after last use, so a Playwright version you stopped installing stops costing storage on its own. ### [System libraries live outside the cached path](#system-libraries-live-outside-the-cached-path)Copy Anchor Link `npx playwright install --with-deps` does two jobs: it downloads browser builds into the download directory, and it installs the operating system packages those builds link against through the platform package manager. Only the first job writes inside `~/.cache/ms-playwright`. Combining both into one step behind `if: cache-hit != 'true'` is the most common way this configuration breaks. The restore succeeds, the combined step is skipped, and the first `browser.launch()` fails on a missing shared library rather than on a missing executable. Splitting the step, as in the workflow above, keeps the download conditional and the library install unconditional. An alternative is to move the download directory into the workspace with `PLAYWRIGHT_BROWSERS_PATH`, which makes the cached path identical across operating systems ([Playwright browsers reference](https://playwright.dev/docs/browsers#managing-browser-binaries)). It does not change the library problem, and it does not let a Linux entry restore into a macOS job, because the cache version still differs by platform. ### [The mismatch failure, and how it reads in a log](#the-mismatch-failure-and-how-it-reads-in-a-log)Copy Anchor Link The failure to plan for is a restored browser set that does not match the installed Playwright version. It has one visible symptom: Copy Text ``` Executable doesn't exist at /home/runner/.cache/ms-playwright/chromium-1148/chrome-linux/chrome Looks like Playwright Test or Playwright was just installed or updated. Please run the following command to download new browsers: npx playwright install ``` Three configurations produce it. A `restore-keys` prefix on the browser entry restores the newest entry with a matching prefix, which after an upgrade is the previous version's browser set. A key built from the lockfile hash stays stable across a Playwright bump that changed the required revision. And a version read from a checked-in file rather than from the installed package reports whatever that file declares, which drifts from the release `npm ci` actually resolved. The fix in all three cases is the same pair: a key that changes exactly when the Playwright version changes, and no `restore-keys` on that entry. A miss costs one download. A wrong hit costs a failed run plus the rerun. When a restore looks correct in the log and the launch still fails, the Action Debugger opens an SSH session on the runner so you can list the revision directories under `~/.cache/ms-playwright` and compare them against the installed release. CI observability reports the per-step durations that show whether the install step is running cold across consecutive runs. ### [What the cache is worth on a sharded suite](#what-the-cache-is-worth-on-a-sharded-suite)Copy Anchor Link The saving scales with the shard count, because every shard in the matrix downloads its own copy without a cache. Measure your own install step first, then run the arithmetic with that number as the input. Take an eight-shard matrix on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, and use one minute of browser download per cold shard as the input. That is 8 x 1 x $0.008 = $0.064 of download time per run, or $25.60 across 400 runs a month. The cache side of the ledger is $0.20 per GB-month of storage plus $0.0001 per write or restore operation, so a 1 GB browser entry restored by eight shards on 400 runs costs $0.20 in storage and $0.32 in operations ([pricing page](/pricing), checked on 2026-08-13). Substitute your own measured download time and entry size before deciding. Caching browsers is one of several mechanisms for carrying state between runs, and the trade-offs against snapshot runners for state that does not fit a cacheable path are covered in [persistent caches for GitHub Actions runs](/guides/persistent-caches-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link ### [Should the Playwright browser cache key hash the lockfile?](#should-the-playwright-browser-cache-key-hash-the-lockfile)Copy Anchor Link No. Browser builds are pinned to the Playwright release, so the key should carry the installed Playwright version. A lockfile hash rolls the entry on every unrelated dependency bump and throws away browsers that were still correct, while leaving the key unchanged on a Playwright upgrade that does need new binaries. The segment-by-segment reasoning behind a key like this is in [cache key, defined](/glossary/cache-key). ### [Do I still need install-deps when the browser cache hits?](#do-i-still-need-install-deps-when-the-browser-cache-hits)Copy Anchor Link Yes. The cache covers the browser binaries under the download directory. The apt packages the browsers link against are installed into system directories outside that path, so a restored entry gives you browsers with no libraries. Run `npx playwright install-deps` on the hit branch and `npx playwright install --with-deps` on the miss branch. ### [Why does my job fail with "Executable doesn't exist" after a Playwright upgrade?](#why-does-my-job-fail-with-executable-doesnt-exist-after-a-playwright-upgrade)Copy Anchor Link Playwright stores each browser under a revision-numbered directory, and every release expects its own revision. A restored entry from the previous version has the old revision directory and not the new one, so the launch fails. The fix is a key that changes with the Playwright version and no `restore-keys` on that entry. ### [Where does the browser cache fit in a full Playwright pipeline?](#where-does-the-browser-cache-fit-in-a-full-playwright-pipeline)Copy Anchor Link It sits between dependency install and the test run, and it pairs with sharding, blob report merging, and runner sizing. [Playwright test suites on GitHub Actions](/solutions/playwright-github-actions) covers the full pipeline shape, and [running browser tests headless in GitHub Actions](/answers/how-do-i-run-browser-tests-headless-in-github-actions) covers the display and launch flags those jobs need. For state that no cache action can carry, compare the mechanisms in [persistent caches for GitHub Actions runs](/guides/persistent-caches-github-actions). --- # How Do I Cache Swift Package Manager Dependencies? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-swift-package-manager-dependencies Description: Cache .build and the shared SwiftPM directory with a key from hashFiles on Package.resolved, on the same macOS job that runs swift build. YAML and costs. # How Do I Cache Swift Package Manager Dependencies? Cache .build and the shared SwiftPM directory with a key from hashFiles on Package.resolved, on the same macOS job that runs swift build. YAML and costs. Cache the resolved package directory, `.build`, together with the shared SwiftPM cache at `~/Library/Caches/org.swift.swiftpm`, and key both on `hashFiles('Package.resolved')` so the entry is invalidated exactly when the pinned dependency set changes. Keep that cache step on the same macOS job that runs `swift build`, because a cache entry written on macOS cannot be restored on a Linux runner. ## [Answer](#answer)Copy Anchor Link Every GitHub Actions runner is ephemeral, so `.build` starts empty on each run and Swift Package Manager re-clones every dependency before the first line compiles. A cache step puts the resolved graph back. Three paths carry the state worth restoring, and the [WarpBuild caching documentation](/docs/ci/features/caching) covers the action inputs that move them: | Path | What it holds | Cache it | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------ | | .build/checkouts | Working copies of each source dependency at the revision pinned in Package.resolved | Yes | | .build/artifacts | Binary target archives, such as xcframeworks pulled during resolution | Yes | | .build/debug, .build/release | Compiled objects, module files, and the SwiftPM build database | Optional | | \~/Library/Caches/org.swift.swiftpm | SwiftPM's shared repository clones and parsed manifests, reused across packages on the machine | Yes | | \~/Library/Developer/Xcode/DerivedData/\*/SourcePackages | Package checkouts when xcodebuild drives resolution rather than swift build | Yes for xcodebuild | Caching `.build` wholesale is the simplest correct answer for a package built with `swift build`. The compiled subdirectories are the part to treat with suspicion: SwiftPM records absolute paths in its build database and invalidates incremental state when the compiler version changes, so a restored `.build` behaves as a dependency cache first and an incremental build second. The key is built from the lockfile: Copy Text ``` key: spm-macos15-${{ steps.toolchain.outputs.id }}-${{ hashFiles('Package.resolved') }} restore-keys: | spm-macos15-${{ steps.toolchain.outputs.id }}- ``` `hashFiles` is a GitHub Actions expression function that hashes the matched files and returns a single digest ([GitHub expressions reference](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#hashfiles)). `Package.resolved` changes when a dependency is added, removed, or moved to a new revision, which is the moment the cache should turn over. The `restore-keys` prefix keeps the previous entry useful when one dependency changes and the other forty do not. One boundary decides which action goes in the workflow. The WarpBuild cache is enabled by default on the Linux runners. A Swift job on a macOS label therefore uses `actions/cache@v4` for this path, and the Linux half of a cross-platform matrix can use `WarpBuilds/cache@v1`, which accepts the same `path`, `key`, and `restore-keys` inputs. Toolchain-level caching on Linux is also covered by the cache-enabled forks listed in the [setup actions documentation](/docs/ci/features/caching/setup-actions). ## [Detail](#detail)Copy Anchor Link ### [Where Package.resolved lives](#where-packageresolved-lives)Copy Anchor Link For a standalone package the file sits at the package root, next to `Package.swift`. When an Xcode project or workspace owns the dependency graph, the resolved file moves inside the container: `MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` for a project, and `MyApp.xcworkspace/xcshareddata/swiftpm/Package.resolved` for a workspace. Point `hashFiles` at the path that actually exists in the repository, because a glob that matches nothing returns an empty hash and produces one key that never turns over. ### [A workflow with restore, build, and save](#a-workflow-with-restore-build-and-save)Copy Anchor Link Copy Text ``` name: swift-package on: push: branches: [main] pull_request: jobs: build: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v5 - name: Record toolchain id: toolchain run: | swift --version echo "id=$(swift --version | head -n 1 | shasum | cut -c1-8)" >> "$GITHUB_OUTPUT" - name: Restore SwiftPM cache id: spm uses: actions/cache/restore@v4 with: path: | .build ~/Library/Caches/org.swift.swiftpm key: spm-macos15-${{ steps.toolchain.outputs.id }}-${{ hashFiles('Package.resolved') }} restore-keys: | spm-macos15-${{ steps.toolchain.outputs.id }}- - name: Resolve dependencies run: swift package resolve - name: Fail on lockfile drift run: git diff --exit-code Package.resolved - name: Build run: swift build --build-tests - name: Save SwiftPM cache if: steps.spm.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | .build ~/Library/Caches/org.swift.swiftpm key: ${{ steps.spm.outputs.cache-primary-key }} - name: Test run: swift test --skip-build --parallel ``` The order matters in three places. The restore runs before `swift package resolve`, so resolution finds the checkouts already on disk and exits in seconds instead of cloning. The save runs after the build and before the tests, so a red test suite still leaves a warm entry for the next run. And the save is guarded on `cache-hit != 'true'`, because cache entries are immutable: writing to a key that already exists fails with a warning rather than replacing the entry. `swift package resolve` is a separate step so resolution time appears as its own line in the timing view, which is how you tell a slow restore from a slow clone. The `git diff --exit-code Package.resolved` check fails the job when resolution moved a dependency the committed lockfile did not expect. ### [What the key should carry](#what-the-key-should-carry)Copy Anchor Link Three components belong in the key, and each prevents a specific wrong restore. The `Package.resolved` hash handles dependency changes. The runner label or macOS version handles machine shape: SwiftPM records absolute paths, and the path layout differs between images. The toolchain fingerprint handles Xcode upgrades, since the Swift compiler on a macOS runner is whichever compiler the selected Xcode carries, and artifacts built by one compiler are rejected by the next. Without the third component, the first run after an image update restores objects the new compiler discards, and you pay the download for nothing. ### [What the cache costs](#what-the-cache-costs)Copy Anchor Link Cache is billed separately from runner minutes, at $0.20 per GB-month for storage and $0.0001 per operation, both from the [WarpBuild pricing page](/pricing), checked on 2026-08-13\. A worked month for a package with 600 runs, a 3 GB entry, and two cache operations per run: | Line | Amount | | --------------------------------------------------------------------- | ------- | | Cache storage, 3 GB at $0.20 per GB-month | $0.60 | | Cache operations, 1,200 at $0.0001 | $0.12 | | Monthly cache bill | $0.72 | | Minutes of resolution and dependency compilation skipped per warm run | 3 | | Runner minutes those 600 runs no longer bill at $0.08 per minute | $144.00 | The $0.08 rate is the `warp-macos-15-arm64-6x` label. The 12 vCPU label is $0.16 per minute, where the same 1,800 minutes bill $288.00\. Restoring a 3 GB entry takes wall clock time of its own, so measure the restore step against the resolution step it replaces before caching the compiled subdirectories alongside the checkouts. The full macOS label list with sizes and rates is on the [WarpBuild macOS runners page](/runners/macos). ### [Limits inherited from GitHub cache](#limits-inherited-from-github-cache)Copy Anchor Link On the GitHub cache backend, three rules shape the hit rate. All caches in a repository share a 10 GB ceiling by default and entries are evicted least recently used first; any entry unused for 7 days is deleted regardless of the total; and a run can restore entries from its own branch or its base branch, so sibling feature branches cannot read each other's copies ([GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)). A 3 GB SwiftPM entry per active branch reaches the ceiling quickly, which is why the pattern above writes from the default branch and lets pull requests reach it through `restore-keys`. Snapshot runners cover the Ubuntu part of the catalog, so on the macOS side of a Swift pipeline the cache step is the mechanism that carries state between runs. ## [Related Questions](#related-questions)Copy Anchor Link ### [Which paths should I cache for Swift Package Manager on a macOS runner?](#which-paths-should-i-cache-for-swift-package-manager-on-a-macos-runner)Copy Anchor Link Cache the package's `.build` directory, which holds the dependency checkouts, downloaded binary artifacts, and the build database, together with the shared SwiftPM directory at `~/Library/Caches/org.swift.swiftpm`. When `xcodebuild` drives resolution instead of `swift build`, add the `SourcePackages` directory under DerivedData or pin it with `-clonedSourcePackagesDirPath`, which is covered in the [Xcode DerivedData cache guide](/guides/xcode-derived-data-cache-github-actions). ### [What should the SwiftPM cache key contain?](#what-should-the-swiftpm-cache-key-contain)Copy Anchor Link A hash of `Package.resolved`, the runner label or macOS version, and a fingerprint of the Swift toolchain. `Package.resolved` changes exactly when the pinned dependency set changes, and the other two components stop a cache built by one toolchain or one machine shape from being restored into another. The [Swift package builds page](/solutions/swift-github-actions) shows the same key shape inside a full pipeline with sizing and cost models. ### [Can a macOS job restore a SwiftPM cache saved by a Linux job?](#can-a-macos-job-restore-a-swiftpm-cache-saved-by-a-linux-job)Copy Anchor Link No. Cache entries carry a version derived from the compression tool and the cached paths, so an entry written on a macOS runner does not match on an Ubuntu runner, as the [WarpBuild caching documentation](/docs/ci/features/caching) records for the same pair of labels. Keep the SwiftPM cache on the job that consumes it and give the Linux half of a matrix its own key and its own paths. ### [What about CocoaPods in the same repository?](#what-about-cocoapods-in-the-same-repository)Copy Anchor Link Pods are resolved and cached on a different pair of paths and keyed on `Podfile.lock` rather than `Package.resolved`, so the two caches stay separate even inside one job. The [CocoaPods caching answer](/answers/how-do-i-cache-cocoapods-in-github-actions) covers the paths and keys, and the [macOS runner catalog](/runners/macos) lists the labels both workflows run on. --- # How Do I Cache uv or Poetry Environments? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-uv-or-poetry-environments Description: Cache the uv or Poetry cache directory keyed on the lockfile and let the tool rebuild the virtual environment from it. A cached .venv breaks on upgrades. # How Do I Cache uv or Poetry Environments? Cache the uv or Poetry cache directory keyed on the lockfile and let the tool rebuild the virtual environment from it. A cached .venv breaks on upgrades. ## [Answer](#answer)Copy Anchor Link Cache the tool's own cache directory, key it on the lockfile, and let `uv sync` or `poetry install` rebuild the virtual environment from that cache on every job. The virtual environment itself stays out of the cache, because a `.venv` records the absolute path of the interpreter that created it and stops working the moment the runner image ships a new Python patch build. That gives two paths on WarpBuild runners. Poetry has a supported input on the Python setup action; uv gets an explicit cache step, because the cache traffic has to be pointed at WarpBuild Cache rather than GitHub Actions Cache. Copy Text ``` - uses: WarpBuilds/setup-python@v6 with: python-version: '3.12' cache: poetry cache-dependency-path: poetry.lock ``` Copy Text ``` - uses: astral-sh/setup-uv@v5 with: enable-cache: false - uses: WarpBuilds/cache@v1 with: path: ~/.cache/uv key: uv-${{ runner.os }}-${{ runner.arch }}-py3.12-${{ hashFiles('uv.lock') }} ``` `WarpBuilds/setup-python` and `WarpBuilds/cache@v1` are drop-in replacements for their upstream counterparts and take the same inputs, documented in the [WarpBuild setup actions reference](/docs/ci/features/caching/setup-actions) and the [WarpBuild caching documentation](/docs/ci/features/caching). The `enable-cache: false` line on the uv setup action matters: left on, it stores the same directory through GitHub Actions Cache, and you end up paying for two copies of the same bytes. | Tool | Default cache directory | Variable that pins it | File that keys it | Sync command that fails on drift | | ------ | ----------------------- | --------------------- | ----------------- | --------------------------------------- | | uv | \~/.cache/uv | UV\_CACHE\_DIR | uv.lock | uv sync --locked | | Poetry | \~/.cache/pypoetry | POETRY\_CACHE\_DIR | poetry.lock | poetry check --lock then poetry install | Set the environment variable rather than relying on the default. Both tools resolve the default differently on macOS and on Windows, and a cache step whose `path` does not match the directory the tool actually used restores nothing and reports success. Pinning the directory makes the `path` input correct on every platform in the matrix. ## [Detail](#detail)Copy Anchor Link ### [Key on the lockfile, and fail the job when the lockfile has drifted](#key-on-the-lockfile-and-fail-the-job-when-the-lockfile-has-drifted)Copy Anchor Link Hash `uv.lock` or `poetry.lock`, never `pyproject.toml`. The manifest carries version ranges, so two runs with identical manifests can resolve to different package sets, and the cache key would call them the same. The lockfile carries the resolved versions that determine what the cache holds. The matching install command is the one that refuses to re-resolve. `uv sync --locked` errors when `uv.lock` is stale against `pyproject.toml` instead of quietly writing a new lockfile mid-job, which is documented in the [uv repository](https://github.com/astral-sh/uv). `poetry check --lock` reports the same drift for Poetry before `poetry install` runs, per the [Poetry repository](https://github.com/python-poetry/poetry). Without one of those, a developer who edits `pyproject.toml` and forgets to relock gets a green build that installed something no lockfile describes, and a cache entry keyed on a lockfile the job did not honor. Put the interpreter version in the key as well. A cache written under Python 3.12 holds wheels tagged `cp312`, and a 3.13 job that restores it downloads everything again while carrying the dead weight of the 3.12 wheels. ### [Why a cached virtual environment breaks on a Python patch bump](#why-a-cached-virtual-environment-breaks-on-a-python-patch-bump)Copy Anchor Link A virtual environment is a directory of absolute paths. `pyvenv.cfg` records the `home` directory of the interpreter that created it, `bin/python` is a symlink into that directory, and console scripts under `bin/` carry a shebang line with the full path baked in. Runner images and setup actions move that path on a patch upgrade. An environment created against a 3.12.7 install and restored onto a runner carrying 3.12.9 finds a `bin/python` symlink pointing at a directory that no longer exists, and every subsequent step fails with a missing-file error rather than a clean cache miss. That failure mode is worse than a cold install, because the cache appears to have hit. The tool caches do not have that problem. uv stores unpacked distributions and links them into a fresh `.venv` on each sync, so a warm cache makes environment creation local filesystem work with no network. Poetry stores downloaded artifacts and repository metadata in its cache directory and installs from there. In both cases the environment is disposable and the cache is durable, which is the split described for other ecosystems in [the guide to persistent caches on GitHub Actions](/guides/persistent-caches-github-actions). One Poetry-specific trap follows from this. Poetry's default virtual environment location sits inside its own cache directory, at `{cache-dir}/virtualenvs`. A hand-rolled cache step on `~/.cache/pypoetry` therefore drags the environment back into the cache and reintroduces the exact failure above. Two fixes work: * Run `poetry config virtualenvs.in-project true` so the environment lands in `.venv` in the workspace and is discarded with the job. * Or restrict the cached paths to `~/.cache/pypoetry/artifacts` and `~/.cache/pypoetry/cache`. ### [The full workflow for both tools](#the-full-workflow-for-both-tools)Copy Anchor Link Copy Text ``` name: test on: push: branches: [main] pull_request: env: UV_CACHE_DIR: /home/runner/.cache/uv POETRY_CACHE_DIR: /home/runner/.cache/pypoetry jobs: uv: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 with: enable-cache: false - uses: WarpBuilds/cache@v1 with: path: ${{ env.UV_CACHE_DIR }} key: uv-${{ runner.os }}-${{ runner.arch }}-py3.12-${{ hashFiles('uv.lock') }} restore-keys: | uv-${{ runner.os }}-${{ runner.arch }}-py3.12- - run: uv sync --locked - run: uv run pytest -q - run: uv cache prune --ci poetry: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-python@v6 with: python-version: '3.12' cache: poetry cache-dependency-path: poetry.lock - run: pipx install poetry - run: poetry config virtualenvs.in-project true - run: poetry check --lock - run: poetry install --no-interaction - run: poetry run pytest -q ``` Two details carry most of the value. The `restore-keys` prefix lets a job whose lockfile just changed start from the previous entry and fetch only the changed packages, which is where a prefix earns its place on a lockfile that moves weekly. And `uv cache prune --ci` drops pre-built wheels that are cheap to fetch again while keeping wheels that were compiled from source on the runner, which is the expensive half; the [setup-uv action](https://github.com/astral-sh/setup-uv) runs the same command in its own post step for that reason. One platform note. WarpBuild caching is not supported on Windows runners, as stated in the [caching documentation](/docs/ci/features/caching). A cross-platform Python matrix should expect the Windows leg to resolve and install cold on every run and should be sized on that basis. Sizing and matrix layout for the rest of the legs are covered on the [Python on GitHub Actions solution page](/solutions/python-github-actions). ### [What the cache costs against what it removes](#what-the-cache-costs-against-what-it-removes)Copy Anchor Link Cache work bills separately from runner minutes on hosted runners and is free on BYOC: | Item | Hosted rate | Unit | BYOC | | ----------------------------- | ----------- | ----------------- | ---- | | Cache storage | $0.20 | per GB-month | Free | | Cache write, restore, or list | $0.0001 | per operation | Free | | Snapshot restore | $0.04 | per job | Free | | Snapshot storage | $0.025 | per snapshot-hour | Free | Worked model for a service repository on uv. Take a 500 MB cache after `uv cache prune --ci`, 2,000 jobs per month, one restore per job, and 60 writes per month from lockfile bumps. * Storage: 0.5 GB x $0.20 = $0.10 per month. * Operations: (2,000 restores + 60 writes) x $0.0001 = $0.21 per month. * Cache bill: $0.31 per month. Now the minute side. Read your own step durations out of the job log; the durations below are placeholders for the arithmetic. Say a cold `uv sync` takes 55 seconds and a warm one takes 8 seconds. That is 47 seconds per job, or about 1,567 minutes across 2,000 jobs. | Line | Rate per minute | 1,567 minutes | Source | | -------------------------------------------------------- | --------------- | ------------- | ---------------------------------------- | | warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) | $0.008 | $12.54 | WarpBuild pricing page | | GitHub-hosted 4-core Linux larger runner (4 vCPU, 16 GB) | $0.012 | $18.80 | GitHub list price, checked on 2026-08-13 | GitHub list prices come from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. Both shapes are 4 vCPU and 16 GB, so `warp-ubuntu-latest-x64-4x` at $0.008 per minute against $0.012 per minute is 33 percent lower list price. Either way, a $0.31 cache bill removes more than $12 of runner minutes, which is the shape of the trade at most repository sizes. Every rate above is on the [WarpBuild pricing page](/pricing). ### [When a snapshot beats a package cache](#when-a-snapshot-beats-a-package-cache)Copy Anchor Link If the job stays slow with a warm cache, because the toolchain install or a source build lands on every run, price a snapshot against it. Snapshot runners capture a runner VM mid-workflow and boot later jobs from that snapshot, and they sit alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface. At 2,000 jobs per month, snapshot restores cost 2,000 x $0.04 = $80.00 and one snapshot held for a 730 hour month costs 730 x $0.025 = $18.25, or $98.25 in total and about $0.049 per job. At $0.008 per minute on `warp-ubuntu-latest-x64-4x`, that pays for itself once the snapshot removes more than about 6.1 minutes of setup per job. Below that line, the uv or Poetry cache alone is the cheaper instrument. ## [Related Questions](#related-questions)Copy Anchor Link ### [Should I cache the virtual environment or the tool cache?](#should-i-cache-the-virtual-environment-or-the-tool-cache)Copy Anchor Link Cache the tool cache. uv keeps wheels and unpacked distributions in its cache directory and links them into a fresh `.venv` on every sync, and Poetry keeps downloaded artifacts in its own cache directory, so a warm cache turns installation into local filesystem work. A cached `.venv` records the absolute path of the interpreter that created it, which is what breaks after a Python patch bump. [The guide on persistent caches on GitHub Actions](/guides/persistent-caches-github-actions) covers the same split for other languages. ### [Which file should key the cache for uv and Poetry?](#which-file-should-key-the-cache-for-uv-and-poetry)Copy Anchor Link `uv.lock` for uv and `poetry.lock` for Poetry. Hash the lockfile rather than `pyproject.toml`, because `pyproject.toml` carries ranges and the lockfile carries the exact resolved versions that determine what lands in the cache. [The answer on caching pip dependencies](/answers/how-do-i-cache-pip-dependencies-in-github-actions) covers the equivalent choice for requirements files and the `cache-dependency-path` input. ### [Does the same approach work for conda environments?](#does-the-same-approach-work-for-conda-environments)Copy Anchor Link No. conda resolves and links packages into a named environment directory rather than installing from a per-project lockfile by default, so the cached unit is the package cache under the conda install plus the environment file hash. [The conda on GitHub Actions solution page](/solutions/conda-github-actions) covers that layout, including the `environment.yml` key and the solver step that dominates a cold run. --- # How Do I Cache Yarn Berry Installs? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cache-yarn-berry-in-github-actions Description: Cache the folder yarn config get cacheFolder prints, key it on yarn.lock, and run yarn install --immutable. Zero-install repos ship the cache in the checkout. # How Do I Cache Yarn Berry Installs? Cache the folder yarn config get cacheFolder prints, key it on yarn.lock, and run yarn install --immutable. Zero-install repos ship the cache in the checkout. Cache the folder that `yarn config get cacheFolder` prints, key it on the hash of `yarn.lock`, and run [yarn install --immutable](https://yarnpkg.com/cli/install) so a lockfile that drifts fails the job rather than rewriting itself. A [zero-install repository](https://yarnpkg.com/features/zero-installs) needs no cache step at all, because its package archives are committed to `.yarn/cache` and arrive with `actions/checkout`. On WarpBuild runners, [WarpBuilds/cache@v1](https://github.com/WarpBuilds/cache) is a drop-in replacement for `actions/cache@v4` and is enabled by default on Linux runners ([caching documentation](/docs/ci/features/caching)). ## [Answer](#answer)Copy Anchor Link Yarn Berry stores every dependency as one zip archive per package. Four settings in `.yarnrc.yml` decide where those archives live and how the install reads them ([Yarn configuration reference](https://yarnpkg.com/configuration/yarnrc)): | Setting | Documented default | What it controls | | ----------------- | ------------------ | ----------------------------------------------------------------------------------- | | nodeLinker | pnp | Whether the install materializes node\_modules or resolves imports through .pnp.cjs | | enableGlobalCache | true | Whether archives land in the global folder or in the project's .yarn/cache | | cacheFolder | ./.yarn/cache | The project cache path, used when the global cache is off | | globalFolder | \~/.yarn/berry | Root of the global folder; the global cache sits in its cache subdirectory | Rather than hardcoding either path, ask Yarn. `yarn config get cacheFolder` prints the active location under both modes, on every platform, for every Yarn version, which keeps the workflow correct when someone flips `enableGlobalCache` in a pull request. Copy Text ``` name: yarn-berry-ci on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 - name: Enable Corepack run: corepack enable - name: Resolve the Yarn cache folder id: yarn run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" - name: Restore the Yarn cache uses: WarpBuilds/cache@v1 with: path: | ${{ steps.yarn.outputs.dir }} .yarn/unplugged .yarn/build-state.yml key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock', '.yarnrc.yml') }} restore-keys: | ${{ runner.os }}-yarn- - run: yarn install --immutable - run: yarn test ``` `--immutable` is the line that turns the cache into a correctness check. Yarn refuses to write a new `yarn.lock`, so a dependency someone added without committing the lockfile fails the job instead of resolving quietly on the runner ([yarn install reference](https://yarnpkg.com/cli/install)). Yarn already enables immutable installs when it detects a CI environment ([enableImmutableInstalls reference](https://yarnpkg.com/configuration/yarnrc#enableImmutableInstalls)); passing the flag makes the behavior independent of that detection, which matters when the same workflow runs in a container or through a self-hosted path where the environment variables differ. The key hashes `.yarnrc.yml` alongside `yarn.lock` because the linker and the cache mode change what the restored directory contains. Change `nodeLinker` and the old entry stops describing the install. If you want fewer moving parts, [WarpBuilds/setup-node](https://github.com/WarpBuilds/setup-node) resolves the Yarn cache folder itself and keys the entry on the lockfile ([setup actions documentation](/docs/ci/features/caching/setup-actions)): Copy Text ``` - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: yarn cache-dependency-path: yarn.lock ``` That covers the archives. It does not cover `.yarn/unplugged`, which is why repositories with native dependencies usually keep the explicit cache step above. ## [Detail](#detail)Copy Anchor Link ### [A zero-install repository and a cached global folder hold the same archives in different places](#a-zero-install-repository-and-a-cached-global-folder-hold-the-same-archives-in-different-places)Copy Anchor Link Both models exist so that a fresh machine can install without reaching the network. The difference is who stores the bytes. | Zero-install | Cached global folder | | | ----------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------- | | Where the archives live | .yarn/cache inside the repository, committed to git | \~/.yarn/berry/cache by default, outside the working tree | | Required settings | enableGlobalCache: false | enableGlobalCache: true, the Yarn 4 default | | How a runner gets them | actions/checkout | A cache restore step keyed on yarn.lock | | What grows | Repository size and clone time on every runner and every developer machine | Cache storage, billed per GB-month | | Failure mode | A missing archive means a commit that nobody validated | A cold entry means one slow install | For a zero-install repository the job drops the cache step and tightens the install instead: Copy Text ``` - uses: actions/checkout@v4 - run: corepack enable - run: yarn install --immutable --immutable-cache ``` [\--immutable-cache](https://yarnpkg.com/cli/install) aborts when the install would add to or modify the cache folder, which is the check that keeps a zero-install repository honest. Without it, a package missing from the committed archives is fetched from the registry and the workflow passes, which defeats the reason the archives were committed. Yarn's own guidance on which of these files belong in git is on the [gitignore question in the Yarn docs](https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored): `.yarn/cache` and `.pnp.cjs` are committed under the zero-install model, while `.yarn/unplugged`, `.yarn/build-state.yml`, and `.yarn/install-state.gz` stay out of the repository. Those last three are exactly the paths a cache step is useful for. ### [The node-modules linker and the PnP linker need different paths](#the-node-modules-linker-and-the-pnp-linker-need-different-paths)Copy Anchor Link Under `nodeLinker: node-modules`, the archives are an input to the install and nothing more. Yarn expands them into a `node_modules` tree, the tree is what Node reads at run time, and the tree is disposable. Caching the archive folder is enough, and caching `node_modules` itself trades one decompression pass for hundreds of thousands of small files plus platform-specific binaries. The [answer on caching node\_modules](/answers/how-do-i-cache-node-modules-in-github-actions) works through that trade. Under `nodeLinker: pnp`, the archives are the installation. `.pnp.cjs` maps every dependency to a path inside the cache folder and Node reads modules out of the zips through the PnP resolver ([Plug'n'Play documentation](https://yarnpkg.com/features/pnp)), so the cache directory has to stay on disk for the whole job, through `yarn test` and any step that imports a dependency. Two extra paths join it. Packages that ship build scripts, or that need real files on disk, are unplugged into `.yarn/unplugged`, and `.yarn/build-state.yml` records which of them have already been built. Leave those out of the cached paths and every run recompiles the native dependencies even though the archive restore reported a hit. So the path list differs by linker: one folder for node-modules, three for PnP. Keep them in one entry per repository, because the [cache version](/docs/ci/features/caching) is a hash of the compression tool and the list of paths in the step, and a step listing three paths cannot restore an entry written by a step listing one. ### [Scope, version, and expiry decide whether the restore matches](#scope-version-and-expiry-decide-whether-the-restore-matches)Copy Anchor Link Three rules from the [caching documentation](/docs/ci/features/caching) explain most misses. Entries are scoped to key, version, and branch, so a pull request does not read another pull request's entry. It falls back through `restore-keys` to the entry `main` wrote, then writes its own. Entries expire 7 days after last use. A repository merging daily stays warm; a release branch that builds monthly pays a cold install every time, whatever the key looks like. A lockfile change is a full cold install by design. That is the correct behavior for a dependency bump, and it means the first run after every upgrade costs full price. ### [What it costs](#what-it-costs)Copy Anchor Link Cache metering is separate from runner minutes. Rates from the [caching documentation](/docs/ci/features/caching) and the [pricing page](/pricing), checked on 2026-08-13: storage is $0.20 per GB-month, and each write, restore, or list operation is $0.0001\. Both lines are free on BYOC runners, where the storage sits in your own cloud account. Worked model for one repository. Replace the durations with your own numbers from the jobs report; the arithmetic is the part worth copying. Assume 900 jobs a month, a 1.1 GB global cache, and two lockfile states live at once. | Line item | Quantity | Rate | Monthly | | ------------- | -------- | ------------------ | ------- | | Cache storage | 2.2 GB | $0.20 per GB-month | $0.44 | | Restores | 900 | $0.0001 each | $0.09 | | Saves | 100 | $0.0001 each | $0.01 | | Total | $0.54 | | | If `yarn install` runs 70 seconds cold and 18 seconds warm, the cache removes 52 seconds from 900 jobs, or 780 minutes. On `warp-ubuntu-latest-x64-4x` at $0.008 per minute those minutes are worth $6.24, against a $0.54 cache bill. The crossover sits at 67.5 minutes of install time a month at that rate. Runner rates, with GitHub list prices from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13: | Shape | WarpBuild label | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | | ------------- | ------------------------- | -------------------- | ------------------------------------- | ----------------- | | 2 vCPU, 8 GB | warp-ubuntu-latest-x64-2x | $0.004 | ubuntu-latest on private repositories | $0.006 | | 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | $0.008 | 4-core Linux larger runner | $0.012 | | 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | $0.016 | 8-core Linux larger runner | $0.022 | At 4 vCPU that is $0.008 against $0.012, a 33 percent lower list price on the same shape (GitHub pricing, checked on 2026-08-13). ### [Where this configuration stops applying](#where-this-configuration-stops-applying)Copy Anchor Link WarpBuild Cache is not supported on Windows runners ([caching documentation](/docs/ci/features/caching)), so a Windows Yarn job keeps `actions/cache` and GitHub Actions Cache with the same path list. When the restore transfer becomes the cost, a file-level cache is the wrong lever. A snapshot runner boots a job from a machine image with the cache folder and the unplugged tree already populated. The [guide to persistent caches for GitHub Actions runs](/guides/persistent-caches-github-actions) works through the arithmetic that picks between the two mechanisms. ## [Related Questions](#related-questions)Copy Anchor Link **Does a zero-install repository still need a cache step?** No. The archives arrive with `actions/checkout` because `.yarn/cache` is committed, so there is nothing to restore. Run `yarn install --immutable --immutable-cache` to make the job fail when a package is missing from the committed cache instead of silently downloading it. The [persistent caches guide](/guides/persistent-caches-github-actions) compares that model with a restored cache over a month of jobs. **Should I cache node\_modules when nodeLinker is node-modules?** No. Cache the folder that `yarn config get cacheFolder` prints and let the install materialize `node_modules` from the archives on every run. A `node_modules` tree holds platform-specific binaries and hundreds of thousands of small files, so it restores slower and matches fewer runs than the archives it came from. The [node\_modules answer](/answers/how-do-i-cache-node-modules-in-github-actions) covers the keying difference in detail. **The cache restores but yarn install still takes a minute. Why?** Packages with build scripts are unplugged to `.yarn/unplugged` and rebuilt when that directory is empty. Add `.yarn/unplugged` and `.yarn/build-state.yml` to the cached paths so the compiled output survives between jobs, and remember that changing the path list gives the entry a new cache version, so the first run after the change starts cold. **Does this differ from caching a pnpm store?** The keying does not. Both hash a lockfile and both cache a content-addressed store rather than an installed tree. The store layouts and the linker options differ, and [pnpm workflows on WarpBuild runners](/solutions/pnpm-github-actions) covers that side. For the rest of a JavaScript pipeline, including runner sizing for test shards, see [Node.js builds on WarpBuild runners](/solutions/nodejs-github-actions). --- # How Do I Cancel In-Progress Runs on a New Push? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-cancel-in-progress-runs-on-a-new-push Description: Declare a concurrency group keyed on the branch ref and set cancel-in-progress to true, so each new push replaces the run already going instead of adding to it. # How Do I Cancel In-Progress Runs on a New Push? Declare a concurrency group keyed on the branch ref and set cancel-in-progress to true, so each new push replaces the run already going instead of adding to it. ## [Answer](#answer)Copy Anchor Link Declare a `concurrency` group keyed on the ref and set `cancel-in-progress: true`, so the newest push takes the lane and the run already going is cancelled. The two keys live at the top level of the workflow file and are documented in GitHub's [concurrency syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency), checked on 2026-08-13. Copy Text ``` name: ci on: push: branches-ignore: [main] pull_request: branches: [main] concurrency: group: ci-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: test: runs-on: warp-ubuntu-latest-x64-8x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/4 ``` Two things follow from that block. A [concurrency group](/glossary/concurrency-group) holds one running item and one waiting item, so the second push parks and the third cancels whatever was parked. And the cancellation reaches the runner as a message rather than as an instant kill, which is why a cancelled job still bills the minutes it ran up to that point, covered in full in [how a cancelled GitHub Actions job is billed](/answers/how-is-a-cancelled-github-actions-job-billed). What you get back depends on where the jobs run. On GitHub-hosted pools, cancelling superseded runs returns concurrent job slots to the per-account ceiling as well as minutes ([GitHub usage limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). On `warp-` labels there is nothing to unblock. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts as workflows fan out ([cloud runners documentation](/docs/ci/cloud-runners)). What the group returns there is billed minutes. ## [Detail](#detail)Copy Anchor Link ### [Pick the group expression that matches your triggers](#pick-the-group-expression-that-matches-your-triggers)Copy Anchor Link The group is a string, and the lane is the string. Any expression that resolves differently across two events puts those events in different lanes. The three context values people key on resolve like this, per GitHub's [context reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#github-context), checked on 2026-08-13. | Event | github.ref | github.ref\_name | github.head\_ref | | ---------------------------------- | ------------------------ | ---------------- | ---------------- | | Push to branch feature-login | refs/heads/feature-login | feature-login | empty | | Pull request 42 from feature-login | refs/pull/42/merge | 42/merge | feature-login | | Push of tag v1.4.0 | refs/tags/v1.4.0 | v1.4.0 | empty | A workflow that fires on both `push` and `pull_request` produces two runs for one commit. Keyed on `github.ref`, those runs sit in separate lanes and neither cancels the other, so the push storm you were trying to stop keeps costing double. `github.head_ref || github.ref_name` resolves to `feature-login` on both events and collapses them into one lane. Prefix the group with something stable when several workflows share the repository. `ci-` in the example above works, and `${{ github.workflow }}` works as well. Skipping the prefix is what causes a lint workflow and a test workflow on the same branch to cancel each other, since group membership is scoped to the repository rather than to a file. ### [Keep protected refs out of the cancel path](#keep-protected-refs-out-of-the-cancel-path)Copy Anchor Link `cancel-in-progress` accepts an expression, so one workflow can cancel feature branch runs and leave the default branch alone: Copy Text ``` concurrency: group: ci-${{ github.head_ref || github.ref_name }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} ``` That shape keeps the post-merge run on `main` intact, which matters when a merge queue, a release tag, or a deploy gate reads its result. ### [What one push storm costs with and without the group](#what-one-push-storm-costs-with-and-without-the-group)Copy Anchor Link Assumptions, stated up front: one pull request branch, four matrix jobs per run, ten minutes per job when a run completes, all four on `warp-ubuntu-latest-x64-8x` at $0.016 per minute ([cloud runners documentation](/docs/ci/cloud-runners), checked 2026-08-13). Four commits land at minutes 0, 4, 9, and 15\. Cancellation is treated as immediate and per-minute rounding is ignored, both of which make the second column slightly optimistic. | Push at minute | Without a concurrency group | Job-minutes | With cancel-in-progress | Job-minutes | | -------------- | --------------------------- | ----------- | ------------------------ | ----------- | | 0 | Runs 0 to 10, completes | 40 | Cancelled at minute 4 | 16 | | 4 | Runs 4 to 14, completes | 40 | Cancelled at minute 9 | 20 | | 9 | Runs 9 to 19, completes | 40 | Cancelled at minute 15 | 24 | | 15 | Runs 15 to 25, completes | 40 | Runs 15 to 25, completes | 40 | | Total | 160 | 100 | | | The storm bills 160 job-minutes at $2.56 without the group and 100 job-minutes at $1.60 with it. A repository that sees 60 storms of this shape in a month keeps 3,600 job-minutes, which is $57.60 on that label. Label choice moves the same 60 saved job-minutes a long way. All four bill per minute for the time a job holds the machine. Rates below come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Label for the same four-job storm | Rate per minute | 160 job-minutes | 100 job-minutes | Kept per storm | | --------------------------------- | --------------- | --------------- | --------------- | -------------- | | warp-ubuntu-latest-arm64-8x | $0.012 | $1.92 | $1.20 | $0.72 | | warp-ubuntu-latest-x64-8x | $0.016 | $2.56 | $1.60 | $0.96 | | warp-windows-latest-x64-8x | $0.032 | $5.12 | $3.20 | $1.92 | | warp-macos-latest-arm64-6x | $0.08 | $12.80 | $8.00 | $4.80 | An iOS branch reaches the same monthly figure in a fifth of the pushes a Linux branch needs. The full billing rules for a job that stops early, including the cleanup steps that keep billing after the cancel signal, are covered in [how a cancelled GitHub Actions job is billed](/answers/how-is-a-cancelled-github-actions-job-billed). ### [Where not to put cancel-in-progress](#where-not-to-put-cancel-in-progress)Copy Anchor Link Cancellation is safe for work that only writes to the runner. It is unsafe for jobs that mutate state elsewhere: deploys, database migrations, `terraform apply`, package publishes, and release tagging. GitHub gives a cancelled job up to five minutes before forcing termination ([workflow cancellation reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-cancellation), checked 2026-08-13), and a migration interrupted anywhere in that window costs more engineering time than the minutes ever saved. Give that work its own lane: Copy Text ``` concurrency: group: deploy-production cancel-in-progress: false ``` One behavior comes with it. Under the default `queue: single`, one run waits and a newer arrival cancels the waiting run, so the deploy that eventually runs is the newest commit rather than every commit in order. `queue: max` lets up to 100 runs wait in order, and it cannot be combined with `cancel-in-progress: true`, since one setting preserves work in flight while the other ends it ([concurrency syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency), checked on 2026-08-13). ### [Confirm the change in the reports](#confirm-the-change-in-the-reports)Copy Anchor Link The CI tab of the billing report has one row per job execution with execution time, billed time, and cost, so cancelled runs appear as short rows rather than missing ones ([reports documentation](/docs/ci/features/reports)). Filter by runner label, set the range to a week before and a week after the change, and compare total billed minutes on the branch label. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does cancel-in-progress cancel runs on other branches?](#does-cancel-in-progress-cancel-runs-on-other-branches)Copy Anchor Link Only when they land in the same group. Cancellation is scoped to the group string, so a group keyed on the branch gives one lane per branch and leaves every other branch alone. Group names are case insensitive and scoped to the repository, so two workflow files whose expressions build the same string share one lane and cancel each other. The guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) separates this behavior from the plan ceiling and from runner capacity, which produce different symptoms. ### [Why do the push run and the pull request run for one commit not cancel each other?](#why-do-the-push-run-and-the-pull-request-run-for-one-commit-not-cancel-each-other)Copy Anchor Link Because `github.ref` differs between the two events: `refs/heads/feature-login` on the push and `refs/pull/42/merge` on the pull request. A group keyed on `github.ref` therefore creates two lanes for the same commit. Key on `github.head_ref` with `github.ref_name` as the fallback and both events resolve to the branch name, which puts them in one lane. The [concurrency group definition](/glossary/concurrency-group) covers the expression contexts each level accepts. ### [How do I keep deploy and release runs from being cancelled?](#how-do-i-keep-deploy-and-release-runs-from-being-cancelled)Copy Anchor Link Give them their own group and set `cancel-in-progress: false`, so a new run waits instead of interrupting the one holding the lane. Under the default `queue: single`, only one run waits, and a newer arrival cancels the waiting run, so the deploy that eventually runs is the newest commit rather than every commit in order. Repeated manual re-runs create the same pile-up on the wrong side of that gate, and the [guide to re-run storms](/guides/rerun-storms-github-actions) covers the patterns that cause them. --- # How Do I Change the Runner in a GitHub Actions Workflow? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-change-the-runner-in-a-github-actions-workflow Description: Change the label on the runs-on line. Swapping ubuntu-latest for warp-ubuntu-latest-x64-8x is the only edit a managed runner needs in a workflow file. # How Do I Change the Runner in a GitHub Actions Workflow? Change the label on the runs-on line. Swapping ubuntu-latest for warp-ubuntu-latest-x64-8x is the only edit a managed runner needs in a workflow file. Last verified: 2026-08-13 Change the label on the `runs-on` line, and change nothing else. A managed GitHub Actions runner is selected entirely by label, so replacing `ubuntu-latest` with `warp-ubuntu-latest-x64-8x` moves the job to an 8 vCPU machine while every step, action pin, secret reference, and service container in the file stays where it is. ## [Answer](#answer)Copy Anchor Link `runs-on` is the only field in a job definition that decides which machine executes the job. GitHub reads the value, looks for a runner advertising that label, and hands the job to the first one that matches. Steps never see the label, so nothing downstream of `runs-on` needs an edit. Here is the swap on a normal Node.js workflow. Before: Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run build - run: npm test - uses: actions/upload-artifact@v4 with: name: dist path: dist/ ``` After: Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run build - run: npm test - uses: actions/upload-artifact@v4 with: name: dist path: dist/ ``` One line differs. The Linux x64 images carry the same tooling as GitHub-hosted runners, so `actions/setup-node`, `actions/cache`, service containers, and artifact upload behave the same way ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). Before that label resolves, the account has to exist and the GitHub app has to be installed. The WarpBuild GitHub bot cannot be installed from the GitHub Marketplace: sign up first, then install it from the WarpBuild dashboard and grant it the repositories you want to run on ([quick start documentation](/docs/ci/quick-start)). Until the bot has access to a repository, a `warp-` label in that repository finds no runner and the job queues. The same one-line change works whichever platform the job needs. Per-minute rates for every label are on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [The four segments of a runner label](#the-four-segments-of-a-runner-label)Copy Anchor Link Every cloud runner label follows the same shape: `warp----`. | Segment | Values | In warp-ubuntu-latest-x64-8x | | ------------ | --------------------------------------------------------------------------------------- | ---------------------------- | | Prefix | warp- routes the job to WarpBuild | warp- | | Platform | ubuntu, macos, windows | ubuntu | | Image | latest or a dated release such as 2404, 2604, 2204, 26, 15, 14, 2022, 2025, 2025-vs2026 | latest | | Architecture | x64 or arm64 | x64 | | Size | 2x through 32x on Linux and Windows, 6x or 12x on macOS | 8x | The size digit is the vCPU count. A `2x` Linux runner is 2 vCPU with 8 GB of RAM, an `8x` is 8 vCPU with 32 GB, and a `32x` is 32 vCPU with 128 GB. Per-minute price scales with the size, so the arithmetic below stays predictable across the catalog. Two segments are constrained by the platform. macOS runners are ARM64 only, so a macOS label always reads `arm64`. Windows runners are x86-64 only, so a Windows label always reads `x64`. Linux carries both architectures. ### [What the latest aliases point at](#what-the-latest-aliases-point-at)Copy Anchor Link `latest` is an alias that tracks the current default image for its platform, and each one has a dated twin you can pin instead. Every row comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Alias label | Resolves to | Underlying image | Per minute | | --------------------------- | ------------------------- | ------------------- | ---------- | | warp-ubuntu-latest-x64-8x | warp-ubuntu-2404-x64-8x | Ubuntu 24.04 | $0.016 | | warp-ubuntu-latest-arm64-8x | warp-ubuntu-2404-arm64-8x | Ubuntu 24.04 | $0.012 | | warp-macos-latest-arm64-6x | warp-macos-15-arm64-6x | macOS 15 | $0.08 | | warp-windows-latest-x64-8x | warp-windows-2022-x64-8x | Windows Server 2022 | $0.032 | The macOS `latest` tag was switched to macOS 15 to stay in sync with GitHub's `macos-latest` tag. Newer images ship under their own dated labels rather than moving the alias, which is why Ubuntu 26.04 is `warp-ubuntu-2604-x64-8x` and macOS 26 is `warp-macos-26-arm64-6x`. The macOS 26 image also ships the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. Use `latest` while you are testing the swap. Pin the dated label on workflows that must not move when the alias points at a new release, the same way you would pin `ubuntu-24.04` instead of `ubuntu-latest`. ### [Modifiers append to the label with a semicolon](#modifiers-append-to-the-label-with-a-semicolon)Copy Anchor Link Features that change how the machine boots or what it can reach are requested in the same string, separated by semicolons and written as `key=value`. No spaces. | Modifier | What it does | Where it works | | ---------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------- | | snapshot.enabled=true | Boots from the base image and lets WarpBuilds/snapshot-save capture the machine mid-workflow | Cloud Ubuntu runners only | | snapshot.key= | Boots from the snapshot stored under that alias, falling back to the base image when none exists yet | Cloud Ubuntu runners only | | nested-virtualization.enabled=true | Provisions the runner on hardware that exposes /dev/kvm to the guest | Cloud Linux x86-64 only | | network.name= | Joins the runner to your Tailscale tailnet using a Network Addon Configuration | Ubuntu x64, Ubuntu ARM64, macOS ARM64, Windows x64 | The platform limits matter because an unsupported modifier is silently ignored rather than failing the job. Snapshot labels on BYOC, Windows, or macOS runners do nothing and the job runs normally without snapshots ([snapshot runners documentation](/docs/ci/features/snapshot-runners)). The nested virtualization label on ARM64, Windows, or macOS behaves the same way, and on BYOC runner sets nested virtualization is enabled automatically when every selected instance type supports it, with no label required ([nested virtualization documentation](/docs/ci/features/nested-virtualization)). A workflow that quietly loses hardware acceleration is harder to spot than one that fails, so check the platform column before you add a modifier. Modifiers chain: Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-8x;network.name=production-tailnet;snapshot.key=api-deps steps: - uses: actions/checkout@v5 - run: npm ci - run: npm run build instrumentation-tests: runs-on: warp-ubuntu-latest-x64-4x;nested-virtualization.enabled=true steps: - uses: actions/checkout@v5 - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - run: ./gradlew connectedCheck ``` Two operational notes on snapshots. Snapshots are deleted after 15 days, and `/tmp` does not persist because the directory is cleaned on reboot. ### [What the swap costs](#what-the-swap-costs)Copy Anchor Link Hold the minutes constant and the label change is pure list-price arithmetic. Take a repository that burns 20,000 runner minutes a month. WarpBuild rates come from the [cloud runners documentation](/docs/ci/cloud-runners). GitHub rates come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Label in runs-on | Shape | Per minute | 20,000 minutes | | ---------------------------------------------------- | ------------- | ---------- | -------------- | | ubuntu-latest, GitHub-hosted on a private repository | 2 vCPU, 8 GB | $0.006 | $120.00 | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | $80.00 | | the 8-core Linux larger runner, GitHub-hosted | 8 vCPU, 32 GB | $0.022 | $440.00 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $320.00 | At the same shape, the swap moves the monthly bill from $120.00 to $80.00\. At 8 vCPU against the GitHub-hosted 8-core larger runner, it moves from $440.00 to $320.00 for the same minutes. The interesting number is the one that connects size to bill. Moving from `ubuntu-latest` to `warp-ubuntu-latest-x64-8x` multiplies the per-minute rate from $0.006 to $0.016, so the monthly bill is unchanged when the job finishes in 37.5 percent of its original wall clock and falls below the old bill anywhere under that mark. Time your longest job on both labels for a week before you decide the size, because the answer depends on how much of the job actually parallelizes. ### [Rolling the change out without touching every file](#rolling-the-change-out-without-touching-every-file)Copy Anchor Link A repository with thirty workflow files does not want thirty commits and thirty reverts. Read the label from a repository variable instead: Copy Text ``` jobs: build: runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-latest' }} ``` Set `LINUX_RUNNER` to `warp-ubuntu-latest-x64-8x` under repository or organization variables and every workflow that reads it moves at once. Clear the variable and every workflow falls back to `ubuntu-latest` on the next run. The fallback also keeps forks working, since forks do not inherit the variable. The same expression form handles a branch-conditional label, which is how a snapshot workflow separates the run that creates a snapshot from the runs that consume it: Copy Text ``` jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=api-deps' }} ``` Quote the label when it comes from an expression. A bare `runs-on` value with semicolons needs no quotes. ## [Related Questions](#related-questions)Copy Anchor Link **Can one matrix mix GitHub-hosted labels and warp- labels?** Yes. Put the label string in the matrix and point `runs-on` at the matrix value. Each leg resolves its own label independently, so a rollout can keep one leg on `ubuntu-latest` while another runs on `warp-ubuntu-latest-x64-8x` and you compare the two on the same commit. Copy Text ``` jobs: test: strategy: fail-fast: false matrix: include: - name: github-hosted runner: ubuntu-latest - name: warpbuild runner: warp-ubuntu-latest-x64-8x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v5 - run: npm ci - run: npm test ``` `fail-fast: false` keeps both legs reporting. More on running two runner pools side by side is in [mixing GitHub-hosted and self-hosted runners](/answers/can-i-mix-github-hosted-and-self-hosted-runners). **What label do BYOC runners use?** BYOC runners use their Runner ID, which is the runner name prefixed with `warp-custom-`, for example `warp-custom-ci-stack-runner`. The exact string is in the Runner ID column on the custom runners page in the dashboard, and it goes into `runs-on` the same way a cloud label does ([BYOC documentation](/docs/ci/byoc)). Snapshot modifiers are ignored on BYOC runners; nested virtualization needs no modifier there. **How do I roll back to GitHub-hosted runners?** Change the label back. Nothing else in the workflow file depends on the runner, so a revert commit restores the previous behavior on the next run. Teams that expect to move back and forth read the label from a repository variable as shown above, which turns a rollback into one variable edit. The [runner label glossary entry](/glossary/runner-label) covers how GitHub resolves a label to a machine. **Should I use the latest labels or the dated ones?** Use `latest` while you are testing so you pick up the current default image, then pin the dated label such as `warp-ubuntu-2404-x64-8x` on workflows that must not move when the alias points at a new release. The full label list with sizes and prices is in the [Linux x64 runner catalog](/runners/linux-x64). Start by swapping one job, confirm the steps pass unchanged as described in [whether faster runners need workflow changes](/answers/do-i-need-to-change-my-workflow-to-use-faster-runners), then price your own minutes against the table on the [pricing page](/pricing). --- # How Do I Choose a GitHub Actions Runner Size? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-choose-a-github-actions-runner-size Description: Size a GitHub Actions runner from measured utilization: start most builds on 4 vCPU, move up a label when sustained CPU or memory reaches 80 percent. # How Do I Choose a GitHub Actions Runner Size? Size a GitHub Actions runner from measured utilization: start most builds on 4 vCPU, move up a label when sustained CPU or memory reaches 80 percent. Last verified: 2026-08-13 Choose a GitHub Actions runner size by measuring what your jobs actually use, then picking the smallest label that keeps CPU, memory, filesystem, and disk I/O under 80 percent for the length of the job. Before any measurement exists, start lint and type-check jobs on 2 vCPU, most builds and test suites on 4 vCPU, and reserve 16 vCPU and above for monorepo compiles and wide integration matrices. ## [Answer](#answer)Copy Anchor Link Runner size is a cost decision and a wall-clock decision at the same time. Each step up the ladder doubles the per-minute rate, so a larger runner returns money only when it cuts the job duration by more than half, and a smaller runner saves money only when the extra minutes stay cheap enough to be worth the wait. Here is the default starting point per workload shape, with the catalog label and rate on each platform. The three columns below cover the platforms where a size choice exists at every tier. | Workload shape | Linux x64 | Linux ARM64 | Windows | | -------------------------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------- | | Lint, formatting, type checks, short unit suites | warp-ubuntu-latest-x64-2x, 2 vCPU, 8 GB, $0.004/min | warp-ubuntu-latest-arm64-2x, 2 vCPU, 8 GB, $0.003/min | warp-windows-latest-x64-4x, 4 vCPU, 16 GB, $0.016/min | | Application test suites, interpreted-language builds, packaging | warp-ubuntu-latest-x64-4x, 4 vCPU, 16 GB, $0.008/min | warp-ubuntu-latest-arm64-4x, 4 vCPU, 16 GB, $0.006/min | warp-windows-latest-x64-4x, 4 vCPU, 16 GB, $0.016/min | | Compiled builds, Docker image builds, parallel test shards | warp-ubuntu-latest-x64-8x, 8 vCPU, 32 GB, $0.016/min | warp-ubuntu-latest-arm64-8x, 8 vCPU, 32 GB, $0.012/min | warp-windows-latest-x64-8x, 8 vCPU, 32 GB, $0.032/min | | Monorepo builds, Rust and C++ compiles, large Gradle or Bazel graphs | warp-ubuntu-latest-x64-16x, 16 vCPU, 64 GB, $0.032/min | warp-ubuntu-latest-arm64-16x, 16 vCPU, 64 GB, $0.024/min | warp-windows-latest-x64-16x, 16 vCPU, 64 GB, $0.064/min | | Whole-repo rebuilds, heavy link steps, wide integration matrices | warp-ubuntu-latest-x64-32x, 32 vCPU, 128 GB, $0.064/min | warp-ubuntu-latest-arm64-32x, 32 vCPU, 128 GB, $0.048/min | warp-windows-latest-x64-32x, 32 vCPU, 128 GB, $0.128/min | Shapes, storage, and rates come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [WarpBuild pricing page](/pricing). Linux runners carry a 150GB SSD at every size and Windows runners carry a 256GB SSD. Windows has no 2 vCPU tier: it was removed on June 8, 2026, so 4 vCPU at $0.016 per minute is the Windows floor and the first two rows collapse onto the same label. macOS sizing works differently because the catalog offers two shapes rather than a ladder: 6 vCPU with 14 GB at $0.08 per minute and 12 vCPU with 28 GB at $0.16 per minute. Pick the larger one when Xcode builds run out of memory or when simulator matrices run in parallel inside a single job. Treat these rows as a starting label and nothing more. The measured numbers decide the final size, and the next section covers where those numbers live. ## [Detail](#detail)Copy Anchor Link ### [Measure first, then move one label](#measure-first-then-move-one-label)Copy Anchor Link Every WarpBuild runner reports utilization to the [observability view](/docs/ci/features/observability), grouped by repository, workflow, job, and instance type. The Recommendations view applies fixed thresholds and highlights the jobs that cross them, leaving the label change to you. | Metric | Threshold that marks an undersized instance | | -------------------------- | -------------------------------------------- | | Max sustained CPU | 80 percent or higher | | Max memory utilization | 80 percent or higher | | Max filesystem utilization | 80 percent or higher | | Max disk I/O | 80 percent or higher of supported throughput | Low utilization is the opposite signal. A job that holds CPU in the low tens and memory under 20 percent on every run is paying for cores it never touches, and the Recommendations view flags it as over-provisioned. Two details change how you read the numbers. Observability collects metrics and logs only for jobs longer than about one minute, so a fleet of 30 second jobs shows gaps. And a single hot run proves little: read the value that holds across a week of runs, since a p90 duration far above p75 with CPU pinned at 80 percent is the usual signature of an instance that copes most of the time and falls over when the machine is busy. Each metric points at a different fix. Sustained CPU at the threshold means more vCPU. Memory at the threshold means the next size up, since memory and vCPU scale together in this catalog. Filesystem near 80 percent on a 150GB SSD usually means artifacts left behind inside the job rather than an undersized machine. High disk I/O with idle cores usually means a caching problem, and the [guide to finding the slow step](/guides/find-the-slow-step-github-actions) walks through separating those cases. Sizing is the cheapest lever of the set, so pull it before reaching for the others. ### [What a size step costs against GitHub-hosted list prices](#what-a-size-step-costs-against-github-hosted-list-prices)Copy Anchor Link Rate doubles per step within a platform, which makes the trade explicit. Here is the ladder next to the nearest GitHub-hosted shape. | WarpBuild label | Shape | WarpBuild rate | Nearest GitHub-hosted runner | GitHub rate | Difference | | ---------------------------- | --------------- | -------------- | -------------------------------------- | ----------- | --------------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004/min | ubuntu-latest, private repositories | $0.006/min | 33 percent lower list price | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008/min | 4-core Linux larger runner | $0.012/min | 33 percent lower list price | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016/min | 8-core Linux larger runner | $0.022/min | 27 percent lower list price | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032/min | 16-core Linux larger runner | $0.042/min | 24 percent lower list price | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064/min | 32-core Linux larger runner | $0.082/min | 22 percent lower list price | | warp-ubuntu-latest-arm64-2x | 2 vCPU, 8 GB | $0.003/min | ubuntu-24.04-arm, private repositories | $0.005/min | 40 percent lower list price | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006/min | 4-core Linux ARM64 larger runner | $0.008/min | 25 percent lower list price | | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | $0.012/min | 8-core Linux ARM64 larger runner | $0.014/min | 14 percent lower list price | | warp-ubuntu-latest-arm64-16x | 16 vCPU, 64 GB | $0.024/min | 16-core Linux ARM64 larger runner | $0.026/min | 8 percent lower list price | | warp-ubuntu-latest-arm64-32x | 32 vCPU, 128 GB | $0.048/min | 32-core Linux ARM64 larger runner | $0.05/min | 4 percent lower list price | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016/min | 4-core Windows larger runner | $0.022/min | 27 percent lower list price | | warp-windows-latest-x64-8x | 8 vCPU, 32 GB | $0.032/min | 8-core Windows larger runner | $0.042/min | 24 percent lower list price | | warp-windows-latest-x64-16x | 16 vCPU, 64 GB | $0.064/min | 16-core Windows larger runner | $0.082/min | 22 percent lower list price | | warp-windows-latest-x64-32x | 32 vCPU, 128 GB | $0.128/min | 32-core Windows larger runner | $0.162/min | 21 percent lower list price | GitHub list prices checked on 2026-08-13 against the [GitHub Actions per-minute rate list](https://docs.github.com/en/billing/reference/actions-minute-multipliers). GitHub gives public repositories a 4 vCPU, 16 GB Linux shape at no charge, so every GitHub number above is the private-repository price that paying teams see on an invoice. The ARM64 column is where sizing and platform choice meet. A 4 vCPU ARM64 runner at $0.006 per minute is the same shape as the 4 vCPU x64 runner at $0.008, so a job with no x86 dependency lowers its rate without giving up a core. ARM64 runners do not support nested virtualization, which keeps Android emulator jobs on x64. ### [The break-even test before you move up](#the-break-even-test-before-you-move-up)Copy Anchor Link One arithmetic rule settles most upgrade arguments. Rate doubles per step, so the bill stays flat only when duration falls below half. Anything above that is buying wall-clock time, which is often the right purchase, and it should be a deliberate one. Take a job that runs 800 times a month with a p90 duration of 10.0 minutes on `warp-ubuntu-latest-x64-4x`. That is 8,000 minutes at $0.008, or $64.00 a month. The break-even duration on `warp-ubuntu-latest-x64-8x` at $0.016 is 5.0 minutes. Assume the dominant step parallelizes and p90 lands at 7.0 minutes: 5,600 minutes at $0.016 is $89.60, which is $25.60 more per month and returns 3 minutes on every run, or 2,400 minutes a month of engineers waiting on a pull request. The downsize case runs the same way. A lint job runs 1,200 times a month at 4.0 minutes p90 on `warp-ubuntu-latest-x64-8x` with max sustained CPU at 22 percent. That is 4,800 minutes at $0.016, or $76.80\. Assume 2 vCPU stretches it to 6.5 minutes: 7,800 minutes at $0.004 is $31.20, a reduction of $45.60 a month. The intermediate stop at 4 vCPU, assuming 5.0 minutes, costs 6,000 minutes at $0.008, or $48.00\. Run the numbers at both stops before committing, because the middle rung often wins. ### [Sizing one workflow job by job](#sizing-one-workflow-job-by-job)Copy Anchor Link Sizing is a per-job decision, so a single workflow usually carries several labels. Copy Text ``` name: ci on: pull_request: branches: [main] jobs: lint: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run lint unit-tests: runs-on: warp-ubuntu-latest-x64-4x strategy: matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/4 build-image: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - run: docker build -t app:${{ github.sha }} . integration: runs-on: warp-ubuntu-latest-x64-16x steps: - uses: actions/checkout@v4 - run: ./scripts/integration.sh ``` Assume the lint job takes 3 minutes, each test shard takes 5 minutes, the image build takes 6 minutes, and the integration job takes 14 minutes. | Job | Runner | Billed minutes | Rate | Cost per run | | --------------------- | --------------------------- | -------------- | ---------- | ------------ | | lint | warp-ubuntu-latest-x64-2x | 3 | $0.004/min | $0.012 | | unit-tests (4 shards) | warp-ubuntu-latest-x64-4x | 20 | $0.008/min | $0.160 | | build-image | warp-ubuntu-latest-arm64-8x | 6 | $0.012/min | $0.072 | | integration | warp-ubuntu-latest-x64-16x | 14 | $0.032/min | $0.448 | | Total | 43 | $0.692 | | | At 400 pull request runs a month that workflow costs $276.80\. The same minutes on the nearest GitHub-hosted shapes come to $0.018, $0.240, $0.084, and $0.588, which is $0.930 per run and $372.00 a month, a difference of $95.20 on list prices checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Two lines in that table deserve a second look. The integration job holds 65 percent of the cost, so it is the first candidate for measurement, and if its utilization sits in the low tens then 8 vCPU at the same duration takes $0.224 off every run. The shard count is a separate lever from the size: four shards of 5 minutes bill 20 minutes and return a result in 5, while one unsharded job at 18 minutes bills 18 and returns a result in 18. ### [Where a bigger size stops helping](#where-a-bigger-size-stops-helping)Copy Anchor Link Some jobs ignore vCPU entirely. A job dominated by network waits, an artifact upload, a rate-limited API, or a single-threaded compiler step runs for the same wall-clock minutes on 32 vCPU as on 4, and the invoice doubles at every step. The [larger runners cost guide](/guides/github-actions-larger-runners-cost) works through the sizes where that trade turns. Queue time is another number that size will not move. When jobs sit waiting rather than running, the fix is concurrency and label routing, and the duration you are trying to shrink lives outside the machine. Cold caches look like an undersized runner in exactly the same way: high disk I/O, moderate CPU, and a long tail on p90\. Confirm the cache is warm before you move a label, because a bigger machine downloads the same dependencies at the same rate. The [observability solution page](/solutions/github-actions-observability) covers reading those metrics per job, and the [Linux x64 runner page](/runners/linux-x64) lists every image and size available at each rate. ## [Related Questions](#related-questions)Copy Anchor Link ### [When does a bigger GitHub Actions runner not pay for itself?](#when-does-a-bigger-github-actions-runner-not-pay-for-itself)Copy Anchor Link Each size step doubles the per-minute rate, so an upgrade holds the bill flat only when the job finishes in under half the time. Jobs that wait on network calls, run one long single-threaded step, or queue behind a rate-limited service keep the same duration on more cores and double the cost. Measure the p90 duration at both sizes over a week before deciding, and treat any upgrade above the break-even point as a purchase of wall-clock time. ### [How do I know my GitHub Actions runner is too small?](#how-do-i-know-my-github-actions-runner-is-too-small)Copy Anchor Link The Recommendations view highlights a job as under-provisioned when max sustained CPU, max memory utilization, or max filesystem utilization reaches 80 percent, or when max disk I/O reaches 80 percent of supported throughput. Utilization in the low tens on every run points the other way, toward a smaller label. The thresholds and the collection details are in the [observability documentation](/docs/ci/features/observability). ### [Is a 2 vCPU runner enough for GitHub Actions?](#is-a-2-vcpu-runner-enough-for-github-actions)Copy Anchor Link For lint, formatting, type checks, and short unit suites, yes. `warp-ubuntu-latest-x64-2x` carries 2 vCPU, 8 GB, and a 150GB SSD at $0.004 per minute. Move up when sustained CPU sits at 80 percent for most of the job or a process gets killed for memory. Windows starts at 4 vCPU because the 2 vCPU Windows tier was removed on June 8, 2026. ### [Should I pick ARM64 at the same size to lower the rate?](#should-i-pick-arm64-at-the-same-size-to-lower-the-rate)Copy Anchor Link ARM64 is the cheaper Linux platform at every size. `warp-ubuntu-latest-arm64-4x` costs $0.006 per minute against $0.008 for the x64 runner of the same 4 vCPU, 16 GB shape. Move jobs that carry no x86 dependency and no nested virtualization requirement, and check the full rate list on the [WarpBuild pricing page](/pricing) before you plan the switch. --- # How Do I Clear a GitHub Actions Cache? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-clear-a-github-actions-cache Description: Change the cache key to walk away from a stale entry, and delete entries only when the stored bytes are wrong. The key bump, the CLI, the API, and the cost. # How Do I Clear a GitHub Actions Cache? Change the cache key to walk away from a stale entry, and delete entries only when the stored bytes are wrong. The key bump, the CLI, the API, and the cost. Change the cache key: entries are immutable, so a new key addresses a new entry and the next run starts clean from one edit to the workflow file, with no API call and no token carrying write access. Delete entries through the repository Caches view, `gh cache delete`, or the REST API only when the stored bytes themselves are the problem rather than merely out of date. ## [Answer](#answer)Copy Anchor Link Two different problems get described with the same sentence, and they take opposite fixes. **A stale entry** holds a tree you no longer want restored: last week's dependencies, an old compiler output directory, a build that predates a toolchain bump. Nothing about the stored bytes is dangerous. The workflow is simply asking for them by name. Change the name and the problem is over on the next run. **A poisoned entry** holds bytes that must not be handed to another job: a credential file that a login step wrote into a cached directory, an archive that fails to unpack and breaks every restore, binaries built for the wrong architecture. Here the entry has to go, and the key change alone leaves it sitting in the store until it expires. ### [The version prefix, for invalidating a family in one edit](#the-version-prefix-for-invalidating-a-family-in-one-edit)Copy Anchor Link Bumping the lockfile hash is not enough on its own, because `restore-keys` performs a prefix match and will still hand the job the previous tree. Put a version segment at the front of the key and at the front of every rung, and one edit cuts the whole ladder: Copy Text ``` - name: Cache npm downloads uses: WarpBuilds/cache@v1 with: path: ~/.npm key: v3-${{ runner.os }}-${{ runner.arch }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | v3-${{ runner.os }}-${{ runner.arch }}-npm- ``` Change both `v3` strings to `v4` and every key in the family stops matching stored entries at once. The next run misses, installs cold, and writes a `v4` entry. The `v3` entries stay until they age out, restored by nothing, which is the behavior you want while a branch that has not merged your edit is still running the old workflow. `WarpBuilds/cache@v1` takes the same `path`, `key`, and `restore-keys` inputs as `actions/cache@v4` and is a drop-in replacement for it ([WarpBuild caching documentation](/docs/ci/features/caching)). ### [The three ways to delete](#the-three-ways-to-delete)Copy Anchor Link | Mechanism | Scope | Needs | Use it when | | --------------------------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- | | Caches view under the repository Actions tab | One entry at a time, picked from a list | Repository write access in the browser | You are looking at the list already and want two or three entries gone | | gh cache delete or gh cache delete --all | One key, or every entry in the repository | The GitHub CLI, authenticated with write access | Scripted cleanup, or clearing a repository before a fresh baseline | | DELETE /repos/{owner}/{repo}/actions/caches?key=&ref= | Every entry matching that key, narrowed to one ref when ref is given | A token with actions: write | A workflow step, or removal that has to be auditable | The REST surface also exposes `DELETE /repos/{owner}/{repo}/actions/caches/{cache_id}` for a single entry by id, and `GET /repos/{owner}/{repo}/actions/cache/usage` for the active entry count and total size in bytes ([GitHub Actions cache REST reference](https://docs.github.com/en/rest/actions/cache), checked on 2026-08-13). `gh cache list --sort last_accessed_at` is the fastest way to see which entries are candidates before anything is removed. ## [Detail](#detail)Copy Anchor Link ### [What deletion costs the next run](#what-deletion-costs-the-next-run)Copy Anchor Link The bill for deleting an entry is one cold run per branch that was restoring it. Take an npm download cache of about 0.8 GB where `npm ci` takes 4 minutes from empty and 30 seconds from a warm restore, which is 3.5 extra minutes on the first run after the delete. Substitute your own two numbers from the job log; the rates are fixed. | Runner label | Price per minute | Cost of one cold rebuild | Cost across 6 active branches | | -------------------------- | ---------------- | ------------------------ | ----------------------------- | | warp-ubuntu-latest-x64-2x | $0.004 | $0.014 | $0.084 | | warp-ubuntu-latest-x64-4x | $0.008 | $0.028 | $0.168 | | warp-ubuntu-latest-x64-8x | $0.016 | $0.056 | $0.336 | | warp-ubuntu-latest-x64-16x | $0.032 | $0.112 | $0.672 | Per-minute rates from the [pricing page](/pricing), checked on 2026-08-13\. Each cold run also writes one new entry at $0.0001 per cache operation, and storage returns to its previous level once the tree is rewritten, so the money is rounding error. The number that matters is the other column: 3.5 minutes added to whichever pull request happens to run next, multiplied by every branch that `--all` touched. On a repository with six active branches that is 21 minutes of pipeline time paid back over the following hours. Deleting one key on one branch is cheap. Deleting everything on a Friday afternoon is a decision the whole team feels on Monday. That asymmetry is the reason the key bump is the default move. It costs exactly the same cold run, but it costs it once, on your schedule, on the branch carrying the edit. ### [Clearing entries on WarpBuild cache](#clearing-entries-on-warpbuild-cache)Copy Anchor Link The WarpBuild cache serves every one of those except Windows, where workflows keep `actions/cache@v4`. Entries have their own removal path. The `delete-cache` input removes the entry and skips both restore and save for that step: Copy Text ``` - name: Drop the poisoned entry uses: WarpBuilds/cache@v1 with: path: ~/.npm key: v3-${{ runner.os }}-${{ runner.arch }}-npm-${{ hashFiles('**/package-lock.json') }} delete-cache: true ``` The same removal is available from the WarpBuild console at any time. Left alone, an entry expires 7 days after its last use ([WarpBuild caching documentation](/docs/ci/features/caching)), which is a sliding window: an entry restored every weekday never reaches it, and an entry written once and never read again collects itself. There is no repository size ceiling on the WarpBuild store, so nothing is evicted to make room for a newer write, and clearing entries to reclaim space stops being a maintenance task. [The GitHub Actions cache size limit, explained](/guides/github-actions-cache-size-limit) covers the ceiling that makes it one. One behavior surprises people mid-cleanup. A key is only half of an entry's identity: the cache action derives a version from the `path` list and the compression tool on the machine, and that version is a field on every entry in the cache list API ([GitHub Actions cache REST reference](https://docs.github.com/en/rest/actions/cache), checked on 2026-08-13), so two jobs with identical key text and different paths address different entries. Delete by key and the sibling entry under the other version survives, which reads as a delete that did nothing. Entries are also scoped per branch, so removing the copy your pull request restores leaves the default branch copy that every other branch falls back to. ### [Seeing cache usage as its own line in the billing report](#seeing-cache-usage-as-its-own-line-in-the-billing-report)Copy Anchor Link Before deleting anything, it is worth knowing what the store actually costs. The [Reports page](/docs/ci/features/reports) splits billing into CI, Docker Builder, and Cache tabs. The Cache tab carries a daily chart broken down by cache type, summary cards for total cost, storage cost, operations cost, and total entries, a per-entry table, and CSV export of every row matching the current filters. | Cache type filter | What it bills | Rate on hosted runners | Rate on BYOC | | ----------------- | ------------------------ | ---------------------- | ------------ | | storage | Gigabytes held per month | $0.20 per GB-month | Free | | operation-hit | Restore and list calls | $0.0001 per operation | Free | | operation-commit | Writes | $0.0001 per operation | Free | Rates from the [WarpBuild caching documentation](/docs/ci/features/caching), checked on 2026-08-13\. Reading the storage card before and after a cleanup is how you find out whether the entries you removed were the expensive ones, and the CI tab prices the cold runs the cleanup caused, per job and per runner label. The [guide to persistent caches on GitHub Actions](/guides/persistent-caches-github-actions) covers the cases where the answer is a different mechanism instead of a cleaner cache. ## [Related Questions](#related-questions)Copy Anchor Link ### [Should I delete a cache entry or change the key?](#should-i-delete-a-cache-entry-or-change-the-key)Copy Anchor Link Change the key in almost every case. Cache entries are immutable, so a new key addresses a new entry and the next run starts clean without an API call or a token with write access. Delete entries when the stored bytes themselves are the problem, such as a credential written into the archive or a tree that fails to unpack, and when you need space back under the repository cache cap right now. [How to write a good cache key in GitHub Actions](/answers/how-do-i-write-a-good-cache-key-in-github-actions) covers the shape that makes a bump safe. ### [How do I delete every cache in a repository at once?](#how-do-i-delete-every-cache-in-a-repository-at-once)Copy Anchor Link Run `gh cache delete --all`, which removes every entry the repository holds across all branches. Add `--succeed-on-no-caches` so the command exits zero on a repository that already has none, which matters when the call sits inside a workflow. Deleting everything means every active branch pays one cold run before its entry is rewritten, priced in the table above at the per-minute rate on the [pricing page](/pricing). ### [Why did my cache come back after I deleted it?](#why-did-my-cache-come-back-after-i-deleted-it)Copy Anchor Link Because the next run computed the same key and the save step wrote a fresh entry under it. Deletion removes stored bytes and changes nothing about what the workflow asks for. When the goal is that the old contents never return, bump a version prefix in the key and in every `restore-keys` rung in the same edit, then delete the old entries if the bytes were unsafe to keep. [Cache eviction](/glossary/cache-eviction) separates the four ways an entry disappears, only one of which you control from the workflow file. --- # How Do I Debug a Failed GitHub Actions Job? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-debug-a-failed-github-actions-job Description: Reproduce the failure on the runner itself with an SSH session gated on failure, then read the runner telemetry for that job beside the GitHub Actions logs. # How Do I Debug a Failed GitHub Actions Job? Reproduce the failure on the runner itself with an SSH session gated on failure, then read the runner telemetry for that job beside the GitHub Actions logs. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Debug a failed GitHub Actions job by reproducing it on the runner that produced it, using an SSH session that opens only when the job fails, then reading the runner telemetry for that same job beside the GitHub Actions logs. Those two moves replace the re-run loop: the session gives you a shell at the moment of failure, and the telemetry says whether the machine ran out of CPU, memory, filesystem, disk I/O, or network before the step died. The job log alone cannot separate those two answers. A log records what a process printed, and a process killed by the kernel out-of-memory killer prints nothing at all. The job page shows a step that ended with exit code 137 and no stack trace, which reads as a mystery until the memory chart for that instance is next to it. | Instrument | Question it answers | Where it lives | | --------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | Action Debugger SSH session | What does this machine look like right now, at the failing step | One step in the workflow, documented in the [Action Debugger reference](/docs/ci/tools/action-debugger) | | CI observability | Did the machine run out of a resource while that step ran | Usage view for the instance, documented in the [observability reference](/docs/ci/features/observability) | Both are part of the WarpBuild product surface, alongside snapshot runners, remote Docker builders, and an MCP server. Work in this order on a single red run: 1. Open the Usage view for the instance that ran the job and line the failing timestamp up against the five utilization charts. 2. If a resource sits at its ceiling, the class is resource exhaustion and the fix is a bigger label or less work per job. Stop there. 3. If every chart stays low and flat, the machine was fine, and the failure is logic, drift, or permissions. 4. Add the debugger step behind a failure condition, push once, and connect when the run pauses. That ordering matters because a shell answers questions about one machine at one moment and tells you nothing about the shape of the machine over the whole job. The long form of the procedure, with the five failure classes and the fix per class, is in the [guide to debugging GitHub Actions failures](/guides/debug-github-actions-failures). ## [Detail](#detail)Copy Anchor Link ### [The step that pauses only on failure](#the-step-that-pauses-only-on-failure)Copy Anchor Link Three settings turn the debugger from a demo into something safe to leave in a workflow: the failure condition, the access restriction, and the timeout. Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x permissions: contents: read checks: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test - name: Setup interactive ssh session if: ${{ failure() }} uses: Warpbuilds/action-debugger@v1.3 timeout-minutes: 15 with: limit-access-to-actor: true ``` `if: ${{ failure() }}` runs the step only when an earlier step in the job has failed, so green runs skip it and bill nothing extra. On a red run the workflow pauses at that step, an SSH session opens on the runner machine, and the URL is written to the action logs and posted as a check on the GitHub run. The job holds on that step until a user connects and exits the session. `limit-access-to-actor: true` restricts the session to the GitHub user who triggered the run. Leave it off and the default depends on that account: if the triggering user has SSH keys on their GitHub account, only they can connect, and if that account has no keys, anyone who has or guesses the generated SSH URL can connect. Run logs on a public repository are public, and the URL is in them. `timeout-minutes: 15` bounds the pause. GitHub kills workflows after 6 hours by default, and runner minutes bill for the whole time a session sits open, so an abandoned session is a 6 hour bill on a machine nobody is typing into. The step key sits beside `uses` rather than under `with`. The `permissions` block grants the `checks` scope the URL check needs. When the URL appears in the logs and no check shows up on the run, workflow permissions are the cause, and on many organizations that setting is enforced at the organization level. Detached mode, named sessions, and the full input list are covered in the answer on [SSH access to a GitHub Actions runner](/answers/can-i-ssh-into-a-github-actions-runner). ### [The telemetry path](#the-telemetry-path)Copy Anchor Link WarpBuild agents on the runner collect three things: utilization metrics, system logs from the machine, and the GitHub Actions logs for the job. The third is the point of the design, because it puts workflow execution and system behavior on the same timeline in one view instead of in two browser tabs. Five metrics are recorded per instance. Read them against the timestamp of the failing step. | Metric | Collected as | Failure it explains | | ---------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | | CPU utilization | Maximum rolling average over the last 30 seconds | A step that crawls while the machine is pinned, then hits a job timeout | | Memory utilization | Maximum | Exit code 137, Killed, a step that stops mid-output with no traceback | | Filesystem utilization | Maximum | no space left on device during a build, an image pull, or an artifact upload | | Disk I/O | Maximum rolling average of read plus write throughput over the last 30 seconds | A step that slows as caches, layers, and artifacts land on disk | | Network utilization | Maximum rolling average of read plus write throughput over the last 30 seconds | A download or push step that stalls and then times out | WarpBuild labels an instance under-provisioned when max sustained CPU, max memory utilization, or max filesystem utilization reaches 80 percent, and when max disk I/O reaches 80 percent of supported throughput. The Recommendations view aggregates those readings by repository, workflow, job, and instance type, which is where a failure that arrived with a specific merge becomes visible. The Usage view is the one to open for a named failing job. Two collection rules decide whether there is anything to read. Observability collects metrics and logs only for jobs longer than about 1 minute, so a job that dies in 20 seconds has no charts. Collection can also be paused for an organization, in which case the Usage view shows the telemetry agent initializing with no data behind it. Telemetry is gathered with OpenTelemetry over port `33931`. The reading rule is short, and it maps directly to a catalog move. | Reading at the failing step | Class | Move | | ------------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------- | | Memory at the ceiling, exit code 137 | Resource | Same family, more RAM: warp-ubuntu-latest-x64-4x at 16 GB to warp-ubuntu-latest-x64-8x at 32 GB | | Filesystem at the ceiling | Resource | Prune the workspace, or move image builds off the runner to a remote Docker builder | | Disk I/O pinned through a long step | Resource | Size up one step and measure the same job again | | CPU at 80 percent or higher on a job that finished late | Sizing | Size up one step; Linux x64 is priced at $0.002 per vCPU minute at every size | | All five low and flat | Logic, drift, or permissions | Open the SSH session and reproduce by hand | ### [Reproduce on the platform that failed](#reproduce-on-the-platform-that-failed)Copy Anchor Link A platform-specific failure gets reproduced on the same platform rather than guessed at from a Linux shell. Point a throwaway `workflow_dispatch` job at the label that failed, with the debugger step attached. Rates below come from the [WarpBuild pricing page](/pricing), checked on 2026-08-13. | Platform | Label to reproduce on | vCPU | RAM | Per minute | | ----------- | --------------------------- | ---- | ----- | ---------- | | Linux x64 | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | Linux ARM64 | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | | macOS | warp-macos-15-arm64-6x | 6 | 14 GB | $0.08 | | Windows | warp-windows-latest-x64-4x | 4 | 16 GB | $0.016 | Set `fail-fast: false` on any matrix you use for this. The default cancels sibling jobs on the first failure, which removes the evidence you are collecting. Two platform limits produce failures that read as missing tooling: macOS runners cannot run Docker, and Linux x64 nested virtualization is opt-in through a label, so an Android emulator job that needs `/dev/kvm` fails until that label is added. Once connected, four checks cover most of what the log left out: `printenv | sort` against your local values, `which` plus `--version` on the tools the failing step uses, `df -h` and `free -m` for headroom at the moment of failure, and a hand run of the failing command with flags removed one at a time. Runner storage is ephemeral, so upload anything worth keeping as an artifact before you exit. ### [What one failure costs](#what-one-failure-costs)Copy Anchor Link Work the arithmetic on a concrete case, then substitute your own durations. A job on `warp-ubuntu-latest-x64-4x` at $0.008 per minute normally runs 20 minutes and now dies of memory pressure at minute 12. * The failed run bills 12 x $0.008 = $0.096 and produces no build. * One paused session with `timeout-minutes: 15` bills at most 12 + 15 = 27 minutes, or $0.216, and returns the environment that caused it. * The memory fix moves the job to `warp-ubuntu-latest-x64-8x` at $0.016 per minute. A 20 minute run costs $0.32 there against $0.16 on the 4 vCPU label. * At 500 runs per month that is 10,000 minutes: $160.00 on the 8 vCPU label against $80.00 on the 4 vCPU label, a difference of $80.00 per month. The telemetry is what makes that $80.00 a decision rather than a guess. Reading the charts costs no runner minutes, and confirming memory at the ceiling before the resize is the difference between paying for headroom you need and paying for headroom you do not. Full rates by runner type are on the [pricing page](/pricing). For failures that move between runs rather than repeating, go to the [guide to flaky GitHub Actions jobs](/guides/flaky-github-actions-jobs). For the fleet-wide view of where jobs fail and how instances are utilized across every repository, see [GitHub Actions observability](/solutions/github-actions-observability). ## [Related Questions](#related-questions)Copy Anchor Link ### [How do I stop a failed GitHub Actions job so I can inspect the runner?](#how-do-i-stop-a-failed-github-actions-job-so-i-can-inspect-the-runner)Copy Anchor Link Add the Action Debugger step with the condition `if: ${{ failure() }}`. The job pauses at that step with the workspace, the environment, and the half-written build outputs still in place, prints an SSH URL in the logs and as a check on the run, and holds until someone connects and exits. The ordered procedure around that step is in the [guide to debugging GitHub Actions failures](/guides/debug-github-actions-failures). ### [Why are the runner metrics empty for the job I want to debug?](#why-are-the-runner-metrics-empty-for-the-job-i-want-to-debug)Copy Anchor Link Observability collects metrics and logs only for jobs longer than about 1 minute, so a job that fails in 20 seconds has no charts. Collection can also be paused for an organization, in which case the Usage view shows the agent initializing and no data. Both cases leave the SSH session as the way in. The collection rules are in the [observability documentation](/docs/ci/features/observability). ### [The job passes when I re-run the same commit. Is a debug session still useful?](#the-job-passes-when-i-re-run-the-same-commit-is-a-debug-session-still-useful)Copy Anchor Link Only for reproducing it. A shell shows one machine at one moment, and a test that fails on one run in twenty is a sampling problem. Run the same commit twice to confirm the pattern, then follow the [guide to flaky GitHub Actions jobs](/guides/flaky-github-actions-jobs), which covers isolation, ordering, and retry policy. ### [Is it safe to leave a debug session in a workflow on a public repository?](#is-it-safe-to-leave-a-debug-session-in-a-workflow-on-a-public-repository)Copy Anchor Link Set `limit-access-to-actor` to `true` and set `timeout-minutes` on the step. Without the first, an account with no SSH keys on it leaves the session reachable by anyone holding the URL from the run logs. Without the second, the runner bills until GitHub kills the workflow at 6 hours. The answer on [SSH access to a GitHub Actions runner](/answers/can-i-ssh-into-a-github-actions-runner) covers both inputs. --- # How Do I Delete a BYOC Stack? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-delete-a-byoc-stack Description: Delete the custom runner configurations first, then the stack, then the cloud connection. WarpBuild blocks each delete until its dependents are gone. # How Do I Delete a BYOC Stack? Delete the custom runner configurations first, then the stack, then the cloud connection. WarpBuild blocks each delete until its dependents are gone. ## [Answer](#answer)Copy Anchor Link Delete BYOC objects in dependency order: the custom runner configurations that reference the stack, then the stack itself, then the cloud connection if you are disconnecting the account as well. WarpBuild enforces that order for you, because the [BYOC documentation](/docs/ci/byoc) states that a stack cannot be deleted until the custom runner configurations depending on it are deleted, and a cloud connection cannot be deleted until the stacks depending on it are deleted. Each delete shows a confirmation listing what still depends on the object, so the dashboard tells you what is blocking before it refuses. | Order | What you delete | What the confirmation lists | What blocks it | | ----- | ------------------------------------------------------ | ----------------------------------------------------- | ---------------------------------------------------------------- | | 1 | Custom runner configurations pointing at the stack | The runner configurations you selected | Nothing on the WarpBuild side, so this is where draining matters | | 2 | The stack: region, VPC, subnets, object storage bucket | Custom runner configurations that depend on the stack | Any remaining runner configuration on that stack | | 3 | The cloud connection and its IAM role | Stacks that depend on the connection | Any remaining stack on that connection | Step 3 is optional. Keep the cloud connection when you are deleting one stack out of several, or when you plan to recreate a stack in a different region: the stack name, the object storage bucket, and the region are fixed at creation, so changing any of the three means a delete and a rebuild rather than an edit. Deleting stops the WarpBuild meter for those runners. Your cloud account keeps billing anything the delete leaves behind, which is what the checklist below is for. ## [Detail](#detail)Copy Anchor Link ### [Check for an update before you delete](#check-for-an-update-before-you-delete)Copy Anchor Link Some deletes are unnecessary. The [BYOC documentation](/docs/ci/byoc) notes that WarpBuild may require changes to cloud connections or stacks to support new features, and that those changes appear as updates to apply. Registry authentication on public-subnet runners is the common example: stacks on CloudFormation template versions before v1.4 can hit ECR login timeouts, and the fix is a template upgrade from the dashboard rather than a rebuild ([AWS BYOC configuration guide](/docs/ci/byoc/aws/config)). The update mechanics are covered in [applying stack updates](/byoc/stack-updates). Three changes an update cannot make, because the values are fixed at stack creation: the stack name, the object storage bucket, and the region. A region move, a bucket move, or a rename is a delete and a rebuild. ### [Draining before the delete](#draining-before-the-delete)Copy Anchor Link Runner instances are ephemeral. Each job runs on a distinct, freshly provisioned instance that is terminated after the job completes, with no shared state between jobs ([BYOC security hardening](/docs/ci/byoc/aws/security)). There is no long-lived agent to unregister, so draining is a matter of stopping new work from arriving and waiting out the work already running. 1. Find every reference to the labels. Runner configurations are addressed by Runner ID with the `warp-custom-` prefix, so an organization-wide code search for `warp-custom-` finds the workflows, the reusable workflows they call, and the composite actions that hard-code a label. 2. Repoint those `runs-on` values at a label that will survive, and merge that change first. 3. Check the schedules. A nightly or weekly workflow on a release branch can still carry the old label days after main is clean. 4. Let in-flight jobs finish. The drain window is the duration of your longest job on that runner set. 5. Delete the runner configurations, then the stack. A job that reaches the queue after its label has gone away does not fail immediately. GitHub cancels a job that has been queued for 24 hours ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), so a missed scheduled workflow turns into a red run a full day later. Search first, delete second. Only the labels served by the deleted stack stop working. Jobs on hosted `warp-` labels in the same workflow keep running through the deletion. ### [The leftover check in your account](#the-leftover-check-in-your-account)Copy Anchor Link The WarpBuild dashboard stops showing the objects once they are deleted. Your cloud bill is the thing to verify against. Names and tags below come from the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config), checked on 2026-08-13. | Resource | How to find it | Why it can linger | | ------------------------------ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | EC2 runner instances | Name pattern warp-\* | A job that started before the drain finished | | EBS volumes | Name pattern warp-\* | Standby disk pools: the VM is shut down rather than terminated, and the network disk stays billable while the VM is not ([standby disks](/docs/ci/byoc/standby-disks)) | | Launch templates | Name pattern tmpl-warp-\* | One per runner configuration | | Object storage bucket | Name pattern warpbuild-\* | Holds artifact cache and runner telemetry; in import mode the bucket is yours and was never WarpBuild's to remove | | Custom AMIs and snapshots | Images built for custom VM image runners | Deregistering an image can leave its snapshots behind | | Connection IAM role | warpbuild- | Created by the CloudFormation connection stack, so it outlives a stack-only delete | | Addresses, gateways, endpoints | Quota usage in the region | Create mode provisions per-stack networking; import mode uses the resources you supplied | Filter by tag rather than by name where you can. Every resource the stack creates carries `warpbuild-stack-id` and `warpbuild-stack-name`, alongside `warpbuild-managed-by`, `warpbuild-github-org`, `warpbuild-runner-id`, and `warpbuild-runner-labels`. Stack resources are tagged from template version 1.3 in create mode; runners are tagged in all cases. The AWS quota table for BYOC is a second read on the same question, since elastic IPs and VPC counts per stack should drop back after a clean delete ([BYOC on AWS](/byoc/aws)). One caveat on import mode. If you handed WarpBuild an existing VPC, subnets, security group, and bucket, those objects were yours before the stack existed and stay after it goes. The delete removes the stack record and the resources WarpBuild created inside it, and the bucket keeps whatever cache and telemetry objects accumulated under it until your lifecycle policy expires them. ### [Deleting on GCP and Azure](#deleting-on-gcp-and-azure)Copy Anchor Link BYOC runs on AWS, GCP, and Azure, and the dependency rules above live in the shared BYOC documentation, so the runner-then-stack-then-connection order holds on all three. What changes per cloud is the leftover list: the naming patterns and tags in this section are the AWS ones, and on GCP and Azure you are checking the equivalent instances, disks, images, and storage in the project or subscription you connected. The creation side of that lifecycle is described in [how BYOC runners are created and destroyed](/byoc/runner-lifecycle). ## [Related Questions](#related-questions)Copy Anchor Link **What order do I delete BYOC objects in?** Custom runner configurations first, then the stack, then the cloud connection if you are disconnecting the account as well. Each delete shows a confirmation listing its dependents and refuses to proceed until they are gone, per the [BYOC documentation](/docs/ci/byoc). Keep the connection if another stack still runs on it. **What happens to jobs that are still running when I delete a stack?** Drain before you delete. Runner instances are ephemeral and terminate after the job completes, so the drain window equals your longest running job on that runner set. Repoint the `runs-on` labels, merge, wait, then delete. A job that queues against a label with no runner behind it waits until GitHub cancels it at 24 hours ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). **Which cloud resources should I check for after the delete?** On AWS: instances and volumes named `warp-*`, launch templates named `tmpl-warp-*`, the `warpbuild-*` bucket, the `warpbuild-` connection role, custom images and their snapshots, and standby disk volumes. Filter on the `warpbuild-stack-id` tag rather than the name where the console allows it. The naming and tagging tables are in the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config). Rebuild the stack against the [AWS BYOC page](/byoc/aws) when you are moving regions, apply pending changes with [stack updates](/byoc/stack-updates), and price the replacement fleet against your own minutes on the [pricing page](/pricing). --- # How Do I Detect an Undersized Runner? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-detect-an-undersized-runner Description: A GitHub Actions runner is undersized when max sustained CPU or memory utilization holds at 80 percent or higher across a job's runs. Read both signals. # How Do I Detect an Undersized Runner? A GitHub Actions runner is undersized when max sustained CPU or memory utilization holds at 80 percent or higher across a job's runs. Read both signals. Last verified: 2026-08-13 A runner is undersized when a job holds max sustained CPU at the ceiling for most of its duration, or when its memory line climbs to a plateau and flattens there right before a step slows down or a process is killed. Both shapes are visible per job in the [Recommendations view](/docs/ci/features/observability), which applies a fixed 80 percent threshold to CPU, memory, filesystem, and disk I/O and marks the instance as under-provisioned. ## [Answer](#answer)Copy Anchor Link Two signal shapes account for almost every genuine case, and they call for different changes. | Signal shape | What the chart shows | What it means | What to change | | -------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------- | | CPU ceiling | Max sustained CPU pinned at or above 80 percent for most of the job, memory well below its own line | The job is compute bound and the cores are the constraint | Move up one label for more vCPU | | Memory plateau | Memory climbs, flattens near the top, and stays flat while the running step slows or ends early | The job is at the memory cap, and a process is being paged or killed | Move up one label, since memory and vCPU scale together in this catalog | The thresholds behind the alert labels are fixed and published, so the reading is the same for every job in the organization. | Metric | Threshold | Alert label | | -------------------------- | -------------------------------------------- | --------------------- | | Max sustained CPU | 80 percent or higher | High CPU Usage | | Max memory utilization | 80 percent or higher | High Memory Usage | | Max filesystem utilization | 80 percent or higher | High Filesystem Usage | | Max disk I/O | 80 percent or higher of supported throughput | High Disk IO | Three collection details change how you read those numbers, all from the [observability documentation](/docs/ci/features/observability). Max sustained CPU is the maximum rolling average over the last 30 seconds rather than an instantaneous peak, which is why a single spiky step rarely trips it. Disk I/O and network are read the same way, as rolling averages over 30 seconds. And metrics and logs are collected only for jobs longer than about one minute, so a fleet of 40 second jobs shows gaps where the evidence would be. [. ](/docs/ci/cloud-runners), and the same five metrics come off each one. The upgrade path differs by platform: Linux and Windows offer a doubling ladder, while the macOS catalog carries two shapes, 6 vCPU with 14 GB and 12 vCPU with 28 GB, so the macOS decision is a single step rather than a rung. ## [Detail](#detail)Copy Anchor Link ### [Reading each shape without guessing](#reading-each-shape-without-guessing)Copy Anchor Link A CPU ceiling that holds for most of the job is the clean case. Cores are saturated for the whole run, the duration scales with core count, and the next label up returns wall-clock time in proportion. A CPU line that touches 80 percent for 20 seconds of a 12 minute job is a different reading entirely, and moving up buys nothing. The memory plateau is the shape worth learning, because it hides. Memory climbs during a build step, reaches a level close to the instance total, and then goes flat while throughput on the same step drops. That flat top is the cap, and what happens next shows in the logs view, which carries both runner system logs and GitHub Actions logs on one timeline. A step that stops with exit code 137 next to a kill message in syslog is a memory cap that was hit rather than a step that failed on its own. Filesystem utilization at the threshold usually points somewhere else. The [150GB SSD on Linux runners](/docs/ci/cloud-runners) filling up is normally artifacts left behind inside the job rather than a machine that is too small, and the fix is cleanup or a larger disk tier rather than more cores. High disk I/O with idle cores is the cold cache signature covered further down. ### [The endpoint that returns a suggested runner per job](#the-endpoint-that-returns-a-suggested-runner-per-job)Copy Anchor Link It returns, for every GitHub job in the organization's last 7 days that should move to a different runner size, a right-sizing recommendation derived from that job's aggregate CPU and memory usage across all its runs, plus a sample of its most recent runs. Copy Text ``` curl -X GET 'https://api.warpbuild.com/api/v1/org_metrics/job_runner_recommendations?search=integration&per_page=50' \ -H 'Accept: application/json' \ -H 'Authorization: Bearer wkey-xxxx' ``` A single entry in the `recommendations` array looks like this. Copy Text ``` { "repository": "acme/platform", "workflow_name": "ci.yml", "job_name": "integration-tests", "total_runs": 148, "recommendation": { "type": "upgrade", "resources": ["cpu"], "current_label": "warp-ubuntu-latest-x64-4x", "current_vcpu": 4, "current_memory_gb": 16, "recommended_label": "warp-ubuntu-latest-x64-8x", "recommended_vcpu": 8, "recommended_memory_gb": 32, "already_at_max_size": false }, "runs": [ { "runner_display_name": "warp-ubuntu-latest-x64-4x", "max_sustained_cpu": 93.4, "max_memory_utilization": 61.2, "max_filesystem_utilization": 38.9, "job_link": "https://github.com/acme/platform/actions/runs/..." } ] } ``` Four fields carry the decision. `type` is `upgrade` or `downgrade`. `resources` is the subset of `cpu`, `memory`, and `disk` that is hot on an upgrade or underused on a downgrade, which tells you which of the two shapes above triggered it. `recommended_label` is the suggested runner. And `already_at_max_size` is set when a job is on the largest runner and still needs more, which is a sharding problem rather than a sizing one. The query parameters are `search` across repository, workflow, and job name, `repo_workflows` for specific pairs, `account_id`, `page`, and `per_page` with a default of 50 and a maximum of 200\. The operation is marked alpha in the API reference. Authentication is a bearer API key created on the API keys page with the `ci` scope, the same key type the [automation documentation](/docs/ci/api-keys/automation) uses for the runner and runner image endpoints. ### [A worked before and after](#a-worked-before-and-after)Copy Anchor Link Take the job in the response above. It runs 600 times a month on `warp-ubuntu-latest-x64-4x` at a p90 duration of 12.0 minutes, with max sustained CPU at 93.4 percent and memory at 61.2 percent. CPU is the only hot resource, which matches the `resources` array. | Reading | Before | After | | ------------------------ | ------------------------- | ------------------------- | | Label | warp-ubuntu-latest-x64-4x | warp-ubuntu-latest-x64-8x | | Shape | 4 vCPU, 16 GB | 8 vCPU, 32 GB | | Rate | $0.008/min | $0.016/min | | p90 duration | 12.0 min | 7.0 min | | Billed minutes, 600 runs | 7,200 | 4,200 | | Monthly cost | $57.60 | $67.20 | The rate doubles at that step, so the break-even duration on 8 vCPU is 6.0 minutes. Landing at 7.0 costs $9.60 more a month and returns 5 minutes on every run, which is 3,000 minutes a month of waiting removed from pull requests. That is a purchase of wall-clock time, and it should be a deliberate one. The rate difference against the nearest GitHub-hosted shape sits at the same step. `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The same 4,200 minutes bill $92.40 on that shape. Re-read the same job a week after the change. The recommendation window is the last 7 days, so a correct upgrade drops the job out of the response entirely. ### [What looks undersized and is not](#what-looks-undersized-and-is-not)Copy Anchor Link Four cases produce the same complaint and need a different fix. A cold cache shows high disk I/O with cores mostly idle and a long p90 tail. The machine is downloading dependencies, and a larger machine downloads them at the same rate. Queue time lives outside the instance. When jobs wait rather than run, the number to move is concurrency and label routing, and no size changes it. A single-threaded step ignores vCPU. A job dominated by one long compile, a linker, an artifact upload, or a rate-limited API call runs the same wall-clock minutes on 32 vCPU as on 4 while the invoice doubles at every step. A job under about one minute has no telemetry at all, so absence of a signal there is a collection gap rather than a healthy runner. Detection is the cheapest lever of the set. The [observability solution page](/solutions/github-actions-observability) covers the wider metric set per job, and the [right-sizing guide](/guides/runner-right-sizing-github-actions) works through the ladder once detection is done. ## [Related Questions](#related-questions)Copy Anchor Link ### [What CPU number means a GitHub Actions runner is too small?](#what-cpu-number-means-a-github-actions-runner-is-too-small)Copy Anchor Link Max sustained CPU of 80 percent or higher, where max sustained CPU is the maximum rolling average over the last 30 seconds. The [Recommendations view](/docs/ci/features/observability) labels an instance at or above that line as High CPU Usage. One hot run proves little, so read the value across a week of runs of the same job, and see [runner utilization](/glossary/runner-utilization) for how each metric is derived. ### [How do I tell an undersized runner from a cold cache?](#how-do-i-tell-an-undersized-runner-from-a-cold-cache)Copy Anchor Link Read disk I/O against CPU. A cold cache shows high disk I/O with cores mostly idle, because the job is pulling dependencies rather than computing. An undersized runner shows CPU at the ceiling while disk I/O stays moderate. A bigger machine downloads the same dependencies at the same rate, so fix the cache first and re-read the chart on the [observability solution page](/solutions/github-actions-observability). ### [Is there an API that returns a recommended runner size per job?](#is-there-an-api-that-returns-a-recommended-runner-size-per-job)Copy Anchor Link Yes. [GET /org\_metrics/job\_runner\_recommendations](/docs/api/org-metrics/GetJobRunnerRecommendations) returns, for every job in the organization's last 7 days that should move size, the current label, the recommended label, whether the move is an upgrade or a downgrade, which resources are hot, and a sample of recent runs with their utilization. The operation is marked alpha, and it authenticates with a bearer API key created as described in the [automation documentation](/docs/ci/api-keys/automation). ### [What does moving up one Linux size cost?](#what-does-moving-up-one-linux-size-cost)Copy Anchor Link Each Linux x64 step doubles the rate, so `warp-ubuntu-latest-x64-4x` at $0.008 per minute becomes `warp-ubuntu-latest-x64-8x` at $0.016 per minute on the [pricing page](/pricing). The bill stays flat only when the job finishes in under half the time. Anything above that is buying wall-clock time back, and [how to choose a runner size](/answers/how-do-i-choose-a-github-actions-runner-size) works through the ladder step by step. --- # How Do I Estimate My GitHub Actions Bill? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-estimate-my-github-actions-bill Description: Estimate a GitHub Actions bill by multiplying billed minutes per runner label by its rate, with the minutes pulled from the jobs report rather than guessed. # How Do I Estimate My GitHub Actions Bill? Estimate a GitHub Actions bill by multiplying billed minutes per runner label by its rate, with the minutes pulled from the jobs report rather than guessed. Last verified: 2026-08-13 Estimate a GitHub Actions bill by multiplying the billed minutes on each runner label by the per-minute rate for that label, then summing the labels and adding the usage add-ons that sit next to runner minutes. Pull the minutes term from the jobs report rather than from a guess, because the rates are fixed and published while the minutes are the only part of the estimate that can be wrong. ## [Answer](#answer)Copy Anchor Link The formula is one line per runner label: Copy Text ``` monthly cost = sum over labels of (runs per month x billed minutes per run x rate per minute) ``` Three inputs feed it. Runs per month and billed minutes per run come from your own history. The rate comes from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners). Nothing else enters the formula: pricing is purely usage based, and there is no base subscription fee, no platform fee, and no seat fee, so the estimate has no fixed term to carry. These are the rates for the labels used in the worked estimate below. | Runner label | Shape | Rate | | -------------------------- | ------------- | ---------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004/min | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008/min | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016/min | | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08/min | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016/min | Rates checked on 2026-08-13 against the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [Step 1: pull real minutes per label](#step-1-pull-real-minutes-per-label)Copy Anchor Link The Billing section of [Reports](/docs/ci/features/reports) has a CI tab where every row is a single job execution carrying repository, job name, runner label, stack, snapshot usage, execution time, billed time, and the cost split between runner and snapshot. The daily chart above the table stacks cost by repository or by runner label, which is the fastest way to see which labels are worth modelling and which round to nothing. The Jobs section aggregates the same history per unique repository, workflow, and job name, with run count, success rate, duration at the 75th and 90th percentile, and queue time at the same percentiles. That is the table to estimate from, since it gives you both terms of the multiplication in one row. Every tab exports to CSV, and the export contains all rows matching the current filters and sort order rather than the current page. Filter to a single month, export the Jobs table, and the estimate becomes a spreadsheet with one row per job. | Where the number lives | What it gives you | | ----------------------------- | ------------------------------------------------------------------------- | | Reports, Jobs section | Run count and duration P75 and P90 per repository, workflow, and job name | | Reports, Billing, CI tab | Billed time and execution time per job execution, with runner label | | Reports, Billing, daily chart | Cost per day grouped by runner label or repository | | CSV export on any tab | The full filtered row set for spreadsheet modelling | If you have no history on WarpBuild yet, count the workflow runs of each job over the last 30 days in the GitHub Actions UI and use the durations shown there. Job durations move by a few percent when the same job changes machines, and job counts do not move at all, so a first estimate built from GitHub run counts lands close. ### [Step 2: write the assumptions down](#step-2-write-the-assumptions-down)Copy Anchor Link An estimate that hides its assumptions cannot be argued with or corrected. State three numbers per job: runs per month, billed minutes per run, and the label the job uses. The assumptions below describe a team merging roughly 200 pull requests a month, with three Linux jobs on every pull request, an iOS build gated to changes under `ios/`, and a Windows test job on a subset. ### [Step 3: run the arithmetic](#step-3-run-the-arithmetic)Copy Anchor Link | Job | Runner label | Runs per month | Billed minutes per run | Minutes | Rate | Cost | | ------------------ | -------------------------- | -------------- | ---------------------- | ------- | ---------- | ------- | | lint | warp-ubuntu-latest-x64-2x | 600 | 2 | 1,200 | $0.004/min | $4.80 | | unit tests | warp-ubuntu-latest-x64-4x | 600 | 6 | 3,600 | $0.008/min | $28.80 | | integration shards | warp-ubuntu-latest-x64-8x | 800 | 11 | 8,800 | $0.016/min | $140.80 | | iOS build | warp-macos-latest-arm64-6x | 90 | 18 | 1,620 | $0.08/min | $129.60 | | Windows tests | warp-windows-latest-x64-4x | 120 | 9 | 1,080 | $0.016/min | $17.28 | | Runner minutes | 16,300 | $321.28 | | | | | Add the usage items that bill alongside runner minutes, priced on the [pricing page](/pricing): at 40 GB-month of cache storage the cache line is $8.00 at $0.20 per GB-month, and 150,000 cache reads and writes add $15.00 at $0.0001 per operation. The estimate lands at $344.28 for the month. Signup includes $10 free credits, so the first invoice on that usage is $334.28. Two rows carry most of the total. Integration shards are 800 job executions rather than 200, because a 4-way matrix multiplies the run count, and macOS bills $0.08 per minute against $0.004 per minute for the 2 vCPU Linux x64 label. Estimates that miss by a wide margin usually miss on one of those two mechanics. ### [Step 4: check the estimate against per-minute rounding](#step-4-check-the-estimate-against-per-minute-rounding)Copy Anchor Link Billing is per minute, so a job that finishes in 35 seconds bills a full minute. That gives the estimate a floor: billed minutes for a label are never lower than the number of job executions on it. A 20-way lint matrix where each shard finishes in 40 seconds bills 20 minutes, while the estimate built from a 40 second average would predict 14\. Fleets with thousands of short jobs should model job count first and duration second. The reverse check matters too. Billed time and execution time are separate columns in the Billing table, so comparing the two on a sample of rows tells you how much rounding your fleet actually absorbs before you extrapolate a year from it. ### [Step 5: price the same minutes at GitHub list prices](#step-5-price-the-same-minutes-at-github-list-prices)Copy Anchor Link Once the minutes are real, they can be priced twice. The 3,600 unit test minutes above cost $28.80 on WarpBuild: `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB), which is 33 percent lower list price, with the GitHub list price checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The same 3,600 minutes at $0.012 come to $43.20. Every cost figure on this page carries a number, a source, and a checked-on date, and holding your own estimate to that standard is what makes it reviewable by someone who did not build it. ### [Step 6: re-run the estimate monthly](#step-6-re-run-the-estimate-monthly)Copy Anchor Link Export the Jobs table on the same date each month and compare the previous estimate against the actual invoice line per label. Drift almost always comes from run count rather than duration, since matrices grow and path filters loosen faster than test suites slow down. The [minutes budget guide](/guides/github-actions-minutes-budget) turns the estimate into a budget with per-label ceilings, and the [cost reduction guide](/guides/reduce-github-actions-costs) works through the levers that move each row. ## [Related Questions](#related-questions)Copy Anchor Link ### [What minutes number should I use in the estimate?](#what-minutes-number-should-i-use-in-the-estimate)Copy Anchor Link Use billed minutes per runner label. The Billing section of [Reports](/docs/ci/features/reports) shows billed time and execution time on every job row, and the Jobs section gives run count with duration at the 75th and 90th percentile per repository, workflow, and job name. Multiply run count by P75 for a working estimate and by P90 for the pessimistic one. ### [Does the estimate change when we add engineers?](#does-the-estimate-change-when-we-add-engineers)Copy Anchor Link Only through the extra workflow runs those engineers trigger. The formula has no seat term and no account term, so growing from 40 to 60 engineers moves the estimate through job count and job duration alone. Every rate that feeds it is on the [pricing page](/pricing). ### [How do I check my estimate against GitHub-hosted list prices?](#how-do-i-check-my-estimate-against-github-hosted-list-prices)Copy Anchor Link Price the same minutes twice. `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB), which is 33 percent lower list price, with the GitHub list price checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Apply both rates to your minutes per label and compare the two totals. --- # How Do I Export GitHub Actions Job Data to CSV? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-export-github-actions-job-data-to-csv Description: Every WarpBuild report tab exports CSV with all filtered rows, and the same five reports return CSV from the API with format=csv for a scheduled pull. # How Do I Export GitHub Actions Job Data to CSV? Every WarpBuild report tab exports CSV with all filtered rows, and the same five reports return CSV from the API with format=csv for a scheduled pull. ## [Answer](#answer)Copy Anchor Link GitHub Actions job data exports to CSV from the download button on every report tab in the WarpBuild dashboard, and the file carries all rows matching the current filters and sort order rather than the page on screen, as described in the [reports documentation](/docs/ci/features/reports). The same five reports return CSV from the reports API when the request carries `format=csv`, which is the path to take when the export has to run on a schedule rather than by hand. Five exports exist, and each one is keyed differently. Billing has three tabs: CI, one row per job execution; Docker Builder, one row per builder session; and Cache, one row per cache billing entry. Jobs aggregates to one row per repository, workflow, and job name. Queue Timings aggregates to one row per runner label and stack pair. Picking the wrong one is the usual reason an export looks short: a month of 12,000 job executions is 12,000 rows in the CI export and perhaps 40 rows in the Jobs export, because Jobs has already rolled the runs up. ## [Detail](#detail)Copy Anchor Link ### [What each export carries](#what-each-export-carries)Copy Anchor Link Column names below are the field names the reports API returns, and the dashboard export uses the same set. | Export | One row is | Columns | | ------------------------------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Billing, CI tab (/reports/billing/ci) | one job execution | repo, job\_name, runner\_label, stack, stack\_kind, run\_id, vcs\_job\_id, timestamp, execution\_time, billed\_time, runner\_cost, snapshot, snapshot\_cost, total\_cost | | Billing, Docker Builder tab (/reports/billing/docker-builder) | one builder session | profile, arch, duration, cost, timestamp | | Billing, Cache tab (/reports/billing/cache) | one cache billing entry | type, cost, timestamp | | Jobs (/reports/jobs) | one repository, workflow, and job name | repo, workflow\_name, job\_name, run\_count, success\_rate, duration\_p75, duration\_p90, queue\_time\_p75, queue\_time\_p90, cpu\_p75, cpu\_p90, memory\_p75, memory\_p90 | | Queue Timings (/reports/queue-timings) | one runner label and stack pair | runner\_label, stack, stack\_kind, run\_count, queue\_time\_p75, queue\_time\_p90 | Three details decide whether a downstream analysis works. The CI export carries `vcs_job_id` and `run_id`, which are the GitHub Actions job id and workflow run id, so the rows join to anything already keyed on a GitHub Actions run. Snapshot runners bill inside the CI row through `snapshot` and `snapshot_cost`, while remote Docker builders bill on their own tab, so a workflow whose image build moved to a builder splits across two exports. And `cpu_p75` through `memory_p90` come from [CI observability](/docs/ci/features/observability); jobs without telemetry show a dash in the Jobs export, per the [reports documentation](/docs/ci/features/reports). ### [The scheduled path](#the-scheduled-path)Copy Anchor Link The API lives at `https://api.warpbuild.com/api/v1` and takes an API key that starts with `wkey-` on the `ci` scope, sent as an `Authorization: Bearer` header, the same key type used across the [automation endpoints](/docs/ci/api-keys/automation). `start_date` and `end_date` are required RFC3339 timestamps. Everything else is optional: `repos`, `runner_labels`, `stack_ids`, `job_names`, `vcs_job_ids`, `run_ids`, `snapshot`, `search`, `sort_by`, and `sort_order`. Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/reports/billing/ci' \ -H 'Authorization: Bearer wkey-xxxx' \ --data-urlencode 'start_date=2026-07-01T00:00:00Z' \ --data-urlencode 'end_date=2026-08-01T00:00:00Z' \ --data-urlencode 'chart_group_by=runner_label' \ --data-urlencode 'format=csv' \ -o ci-billing-2026-07.csv ``` Drop `format=csv` and the same call returns JSON with a `jobs.items` array, paginated at 50 rows by default and 200 at most. Wrapping the CSV call in a monthly workflow takes a small runner and one artifact upload. Copy Text ``` name: monthly-actions-job-export on: schedule: - cron: "0 6 1 * *" workflow_dispatch: jobs: export: runs-on: warp-ubuntu-latest-x64-2x steps: - name: Compute last month in UTC id: window run: | echo "start=$(date -u -d 'last month' +%Y-%m-01T00:00:00Z)" >> "$GITHUB_OUTPUT" echo "end=$(date -u +%Y-%m-01T00:00:00Z)" >> "$GITHUB_OUTPUT" - name: Download the job rows run: | curl -sS -G 'https://api.warpbuild.com/api/v1/reports/billing/ci' \ -H "Authorization: Bearer ${{ secrets.WARPBUILD_API_KEY }}" \ --data-urlencode "start_date=${{ steps.window.outputs.start }}" \ --data-urlencode "end_date=${{ steps.window.outputs.end }}" \ --data-urlencode 'format=csv' \ -o ci-billing.csv - name: Roll minutes up by repository and label run: | curl -sS -G 'https://api.warpbuild.com/api/v1/reports/billing/ci' \ -H "Authorization: Bearer ${{ secrets.WARPBUILD_API_KEY }}" \ --data-urlencode "start_date=${{ steps.window.outputs.start }}" \ --data-urlencode "end_date=${{ steps.window.outputs.end }}" \ --data-urlencode 'per_page=200' \ | jq -r '.jobs.items | group_by(.repo + "|" + .runner_label)[] | [ .[0].repo, .[0].runner_label, length, (map(.billed_time) | add), (map(.total_cost) | add) ] | @tsv' | tee by-repo-and-label.tsv - uses: actions/upload-artifact@v4 with: name: actions-job-export path: | ci-billing.csv by-repo-and-label.tsv ``` Keep the window boundaries on UTC midnight so consecutive months neither overlap nor leave a gap, and walk `page` until `jobs.next` stops advancing when a busy month exceeds 200 rows on the JSON path. ### [A worked rollup: minutes by label by repository](#a-worked-rollup-minutes-by-label-by-repository)Copy Anchor Link Take one month of the CI export, group the rows by `repo` and `runner_label`, sum `billed_time` per group and convert it to minutes, then multiply by the published rate for that label. Use `billed_time` rather than `execution_time`, because billed time is the figure that reconciles against an invoice. Rates come from the [WarpBuild pricing page](/pricing) and were checked on 2026-08-13; billing is per minute. | Repository | Runner label | Billed minutes | Rate | Cost | | ------------ | --------------------------- | -------------- | ------ | ------- | | platform-api | warp-ubuntu-latest-x64-4x | 18,400 | $0.008 | $147.20 | | platform-api | warp-ubuntu-latest-x64-16x | 3,100 | $0.032 | $99.20 | | web-app | warp-ubuntu-latest-x64-2x | 9,700 | $0.004 | $38.80 | | web-app | warp-ubuntu-latest-arm64-4x | 5,200 | $0.006 | $31.20 | | ios-client | warp-macos-latest-arm64-6x | 1,450 | $0.08 | $116.00 | | Total | 37,850 | $432.40 | | | Two readings fall straight out of that table. The `ios-client` repository holds 1,450 of 37,850 billed minutes, which is 3.8 percent of the fleet's minutes and $116.00 of $432.40, or 26.8 percent of the money, so a macOS review pass moves more than a Linux one. And the 16 vCPU line on `platform-api` holds 3,100 minutes against 18,400 on the 4 vCPU label, yet costs two thirds as much, which is the row to check against `cpu_p75` in the Jobs export before renewing its label for another quarter. Every one of them appears in the `runner_label` column, so a single export covers the whole fleet rather than one platform at a time. The [cost attribution guide](/guides/github-actions-cost-attribution) turns the same grouping into a chargeback ledger, and [GitHub Actions observability](/solutions/github-actions-observability) covers the reports these exports come from. ### [What the export will not tell you](#what-the-export-will-not-tell-you)Copy Anchor Link The rows carry usage, and usage is the whole bill. The [pricing page](/pricing) carries the full rate table these exports bill against. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does the CSV export contain only the rows visible on screen?](#does-the-csv-export-contain-only-the-rows-visible-on-screen)Copy Anchor Link The export contains every row matching the current filters and sort order rather than the current page, so a month with 12,000 job executions arrives as 12,000 rows in one file. Set the filters and the sort first, then download, because the export inherits both. The [reports documentation](/docs/ci/features/reports) lists the filters available on each tab. ### [Which columns does the GitHub Actions job export carry?](#which-columns-does-the-github-actions-job-export-carry)Copy Anchor Link The CI billing export carries one row per job execution with `repo`, `job_name`, `runner_label`, `stack`, `stack_kind`, `run_id`, `vcs_job_id`, `timestamp`, `execution_time`, `billed_time`, `runner_cost`, `snapshot`, `snapshot_cost`, and `total_cost`. Percentiles for duration, queue time, CPU, and memory live in the Jobs export instead, which is the file to pull when the question is per-workflow rather than per-run. The [cost per workflow answer](/answers/how-do-i-see-cost-per-workflow) covers that split. ### [Can the CSV export run on a schedule instead of a download button?](#can-the-csv-export-run-on-a-schedule-instead-of-a-download-button)Copy Anchor Link Yes. The reports API takes the same parameters as the dashboard and returns the same rows when the request carries `format=csv`, so a scheduled workflow with a `wkey-` API key on the `ci` scope can write the file into an artifact, a bucket, or a warehouse load on the first of every month. The [weekly health report guide](/guides/weekly-ci-health-report-github-actions) wires the same pull into a recurring summary, and the [automation documentation](/docs/ci/api-keys/automation) covers creating the key. --- # How Do I Find Which Workflow Got Slower? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-find-which-workflow-got-slower-this-month Description: Rank every workflow by the change in duration P90 across two date ranges. The WarpBuild Jobs report shows both periods per job and exports each one to CSV. # How Do I Find Which Workflow Got Slower? Rank every workflow by the change in duration P90 across two date ranges. The WarpBuild Jobs report shows both periods per job and exports each one to CSV. Find the workflow that got slower by pulling duration percentiles per job for two date ranges and ranking the jobs by the change in the tail percentile, because a tail move is the signal an average hides. The [WarpBuild Jobs report](/docs/ci/features/reports) carries Duration P75 and P90 for every unique repository, workflow, and job name combination, and it supports date range selection and CSV export, so the whole comparison is two exports, a join, and a subtraction. ## [Answer](#answer)Copy Anchor Link Rank on the tail, then rank on volume. A percentile that moved five minutes on a job that runs 60 times a month costs less total waiting than a percentile that moved one minute on a job that runs 1,200 times, and both readings come out of the same two exports. Here is why the tail is the field to diff. Take a unit test job with 1,240 runs in a month. Suppose the slowest tenth of those runs, 124 of them, each gained 5.7 minutes because a new integration test started retrying. The mean absorbs 124 x 5.7 minutes spread across 1,240 runs, which is 0.57 minutes, or 34 seconds. Duration P90 reports the same regression as 5.7 minutes, because P90 sits inside the group that moved. These are the fields the Jobs section reports per unique repository, workflow, and job name, per the [reports documentation](/docs/ci/features/reports). | Field | What it carries | | ---------------------- | --------------------------------------------------- | | Run Count | Executions in the selected date range | | Success Rate | Percentage of successful runs | | Duration P75 and P90 | 75th and 90th percentile execution time | | Queue Time P75 and P90 | 75th and 90th percentile wait before the job starts | | CPU P75 and P90 | 75th and 90th percentile peak CPU utilization | | Memory P75 and P90 | 75th and 90th percentile peak memory utilization | A worked comparison across two months, with example figures to replace with your own two exports. Both period columns come from the same table with the date range moved, durations are in minutes, and the last column multiplies the P90 change by the later month's run count to rank the regressions by total added wait. | Workflow and job | Runs in July | Duration P75 June to July | Duration P90 June to July | P90 change | Rank score (P90 change x runs) | | -------------------------- | ------------ | ------------------------- | ------------------------- | ---------- | ------------------------------ | | ci.yml / unit-tests | 1,240 | 6.2 to 6.4 | 9.1 to 14.8 | +5.7 | 7,068 | | nightly.yml / integration | 62 | 22.0 to 31.5 | 24.5 to 36.0 | +11.5 | 713 | | release.yml / docker-build | 180 | 11.0 to 14.5 | 13.2 to 17.0 | +3.8 | 684 | | e2e.yml / playwright | 620 | 18.4 to 18.6 | 26.0 to 26.4 | +0.4 | 248 | | ci.yml / lint | 1,240 | 2.1 to 2.2 | 2.6 to 2.7 | +0.1 | 124 | Three readings fall out of that table. The nightly job has the largest per-run regression at 11.5 minutes and ranks third on total wait, because it runs 62 times. The `ci.yml` unit test job held P75 almost flat while P90 gained 5.7 minutes, which describes a subset of runs going long rather than every run getting heavier. The Docker build job moved on both percentiles, which describes work added to every run. ## [Detail](#detail)Copy Anchor Link ### [The export procedure](#the-export-procedure)Copy Anchor Link 1. Open the Jobs section of [Reports](/docs/ci/features/reports) and set the date range to the earlier month. Filter by repository if the account holds more than one. 2. Export the table to CSV. The export includes every row matching the current filters and sort order rather than the visible page. 3. Set the date range to the later month and export again. 4. Join the two files on repository, workflow, and job name. Rows that appear in one file only are new or retired jobs and belong in a separate list. 5. Compute the P90 delta per row, multiply by the later run count, and sort by that product. 6. Read Queue Time P75 and P90 on the top rows before you read anything else. Step 6 decides which half of the problem you have. Queue wait sits before the job starts and is reported separately in the Queue Timings section per runner label and stack, so a job whose queue time grew while its duration held flat is a capacity story with no slow step to find. WarpBuild provides [Linux x64, Linux ARM64, macOS, and Windows runners](/docs/ci/cloud-runners), and the Jobs table aggregates all four in one place, so a regression confined to one platform ranks against everything else rather than hiding in a separate tool. ### [Date the bend, then open the run](#date-the-bend-then-open-the-run)Copy Anchor Link The time-series chart plots one metric, duration or queue time or CPU or memory, at P75 or P90 over time, with one line per job on the current table page. That is the surface that turns "July was worse" into a week, and often into a specific merge. Once you have the week, the next move is step level. Pull the jobs of one run from before the bend and one run from after it through the GitHub REST API and subtract `started_at` from `completed_at` for each step, using the [workflow jobs endpoint](https://docs.github.com/en/rest/actions/workflow-jobs). The ordered procedure is in the [guide to finding the slow step](/guides/find-the-slow-step-github-actions), and the percentile background is in the [guide to build duration percentiles](/guides/github-actions-build-duration-percentiles). If the step timings look the same on both runs, the machine is the remaining variable. CPU and memory percentiles in the Jobs table need [observability](/docs/ci/features/observability) enabled, and telemetry is collected only for jobs longer than about one minute, so short jobs show a dash in those columns. The Usage view puts utilization charts and GitHub Actions logs on one timeline for a named job. CI observability is part of the [WarpBuild product surface](/docs/ci/what-is-warpbuild) alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger. Tracking these deltas on a schedule instead of on demand is what [GitHub Actions observability](/solutions/github-actions-observability) covers, and turning a percentile into a target your team commits to is covered in the [guide to CI SLOs and build time budgets](/guides/ci-slo-and-build-time-budgets). ### [What the regression costs](#what-the-regression-costs)Copy Anchor Link Put a number on the top row before deciding whether to chase it. The unit test job above added 124 x 5.7 = 706.8 minutes of execution across July. At `warp-ubuntu-latest-x64-4x`, which is $0.008 per minute on the [pricing page](/pricing), that is $5.65 on the invoice. The same 706.8 minutes is about 11.8 hours of engineers waiting on red or yellow checks. The bill and the wait move at different scales, which is the point of running both columns. For reference on the rate, the 4-core Linux larger runner is listed at $0.012 per minute in [GitHub's published Actions prices](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. ## [Related Questions](#related-questions)Copy Anchor Link ### [Why does average build time stay flat when a workflow gets slower?](#why-does-average-build-time-stay-flat-when-a-workflow-gets-slower)Copy Anchor Link Because the mean spreads a regression across every run. If the slowest tenth of 1,240 runs each gained 5.7 minutes, the mean moves by 0.57 minutes while duration P90 moves by the full 5.7\. The tail percentile reports the regression at the size the person waiting on it experiences, which is the argument for tracking it in the [guide to build duration percentiles](/guides/github-actions-build-duration-percentiles). ### [Can I compare percentiles other than P75 and P90?](#can-i-compare-percentiles-other-than-p75-and-p90)Copy Anchor Link The [Jobs report](/docs/ci/features/reports) carries Duration P75 and P90 and Queue Time P75 and P90 per unique repository, workflow, and job name. For any other percentile, pull per-run job records from the [GitHub REST API](https://docs.github.com/en/rest/actions/workflow-jobs), subtract `started_at` from `completed_at`, and compute the percentile you want from the raw list. ### [Duration moved but the workflow file did not change. Where do I look next?](#duration-moved-but-the-workflow-file-did-not-change-where-do-i-look-next)Copy Anchor Link Read queue time P75 and P90 on the same row first, because queue wait sits outside the steps and no workflow edit changes it. If queue time held flat, open the time-series chart to date the bend, then rank the steps of a run from that week against a run from before it using the [guide to finding the slow step](/guides/find-the-slow-step-github-actions). --- # How Do I Forecast Runner Spend? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-forecast-runner-spend-for-next-quarter Description: Forecast runner spend by pricing last month's billed minutes per label at the published rate, then scaling by planned job growth rather than by headcount. # How Do I Forecast Runner Spend? Forecast runner spend by pricing last month's billed minutes per label at the published rate, then scaling by planned job growth rather than by headcount. Forecast runner spend by taking the billed minutes on each runner label from the last full month, applying that label's per-minute rate, and multiplying by a growth factor drawn from planned job count. Scale by job count rather than by headcount, because the bill has no seat term to grow. ## [Answer](#answer)Copy Anchor Link The forecast is one row per runner label and one line of arithmetic: Copy Text ``` quarterly spend = sum over labels of (minutes last full month x rate per minute x growth factor x 3) ``` Three inputs feed each row. Minutes per label come from your own billing history. The rate comes from the [pricing page](/pricing). The growth factor comes from what the roadmap says will happen to job count on that label: a matrix that gains shards, a new repository joining the same workflow, a path filter loosening. Every label prices independently, so the growth factor applies where the growth actually lands. Here is a fleet merging roughly 200 pull requests a month, forecast one quarter ahead. | Runner label | Minutes last full month | Rate | Growth factor | Forecast per month | Forecast per quarter | | -------------------------- | ----------------------- | ---------- | ------------- | ------------------ | -------------------- | | warp-ubuntu-latest-x64-2x | 1,200 | $0.004/min | 1.15 | $5.52 | $16.56 | | warp-ubuntu-latest-x64-4x | 3,600 | $0.008/min | 1.15 | $33.12 | $99.36 | | warp-ubuntu-latest-x64-8x | 8,800 | $0.016/min | 1.50 | $211.20 | $633.60 | | warp-macos-latest-arm64-6x | 1,620 | $0.08/min | 1.00 | $129.60 | $388.80 | | warp-windows-latest-x64-4x | 1,100 | $0.016/min | 1.15 | $20.24 | $60.72 | | Runner minutes | 16,320 | $399.68 | $1,199.04 | | | Rates checked on 2026-08-13 against the [pricing page](/pricing). The same minutes at a flat growth factor of 1.00 would forecast $964.80 for the quarter, so the growth assumptions are worth $234.24 on their own and deserve to be written down where a reviewer can argue with them. Add the usage items that bill alongside minutes. At 40 GB-month of cache storage the cache line is $8.00 at $0.20 per GB-month, and 150,000 cache reads and writes add $15.00 at $0.0001 per operation, both rates from the [pricing page](/pricing) checked on 2026-08-13, so $23.00 a month and $69.00 for the quarter. The forecast lands at $1,268.04\. Signup includes $10 free credits, and the rate for every line item above is on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [Where each input comes from](#where-each-input-comes-from)Copy Anchor Link Minutes per label come from the Billing section of [Reports](/docs/ci/features/reports), where the CI tab carries one row per job execution with repository, job name, runner label, stack, execution time, billed time, and the cost split between runner and snapshot. Use billed time rather than execution time, since billing is per minute and a 35 second job bills a full minute. Every tab exports to CSV with all rows matching the current filters, so the forecast starts as a spreadsheet with one row per job. The growth factor is the only input you invent, so give it a source too. Read it off planned work: three new services adopting the shared workflow, an integration matrix widening from four shards to six, a nightly job moving to every pull request. A factor with a named cause survives review; a factor of 1.2 with no cause behind it is a guess wearing a number. ### [The two drivers that move the number most](#the-two-drivers-that-move-the-number-most)Copy Anchor Link Matrix width is the first. The integration shards row above is 200 pull requests a month against a 4-way matrix, so 800 job executions at 11 billed minutes each, which is 8,800 minutes at $0.016 per minute, or $140.80\. Widening the matrix to 6 shards makes it 1,200 executions and 13,200 minutes, or $211.20 a month. That single change is the 1.50 factor in the table, and it adds $211.20 to that label's own quarterly forecast on its own, the largest piece of the $234.24 that growth assumptions add across the whole table. Matrix width multiplies job count directly, and job count is the term that per-minute rounding also amplifies. Re-run rate is the second. Track the share of workflow runs that are a second or later attempt on the same commit. Moving that share from 12 percent to 20 percent multiplies minutes on the affected labels by 1.20 divided by 1.12, which is 1.07, and a flaky suite can cross that gap in a single quarter without anyone filing a ticket. Duplicate triggers behave the same way: a workflow firing on both `push` and `pull_request` for the same branch doubles the run count on every label it touches. Neither driver shows up in a headcount model, which is why headcount models drift. ### [Refresh the forecast from the API](#refresh-the-forecast-from-the-api)Copy Anchor Link Create a key with the `ci` scope from the [API keys page](https://app.warpbuild.com/settings/api-keys), following the [automation documentation](/docs/ci/api-keys/automation), then pull daywise costs for the window you are forecasting. Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/jobs/daywise-costs' \ -H 'Authorization: Bearer wkey-xxxx' \ -H 'Accept: application/json' \ --data-urlencode 'start_date=2026-10-01T00:00:00Z' \ --data-urlencode 'end_date=2027-01-01T00:00:00Z' \ | jq -r '.[] | [.date, .amount, .cumulative_amount] | @tsv' ``` The response is one row per day carrying `date`, `amount`, and `cumulative_amount`, which is exactly the shape a burn-down needs. Take the last `cumulative_amount`, divide by elapsed days, and multiply by the days in the quarter. On day 20 of a 92 day quarter, a cumulative amount of $268.40 paces to $1,234.64, about 3 percent below the $1,268.04 forecast above. Run that check weekly on a schedule and the forecast stops being a document. ### [What never enters the forecast](#what-never-enters-the-forecast)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee ([pricing page](/pricing)), so the model has no fixed term, no per-user term, and nothing to renegotiate when the team grows. Adding a repository adds cost only when it queues jobs, and archiving one removes its minutes from the next invoice. Every figure on this page carries a number, a source, and a checked-on date, and a forecast that holds itself to the same standard is one a finance reviewer can accept without rebuilding it. ## [Related Questions](#related-questions)Copy Anchor Link ### [Should I forecast runner spend by headcount or by job count?](#should-i-forecast-runner-spend-by-headcount-or-by-job-count)Copy Anchor Link By job count. Pricing is purely usage based, with no base subscription fee, no platform fee, and no seat fee, so a team growing from 40 to 60 engineers changes the forecast only through the extra workflow runs those engineers trigger. Every rate that feeds the model sits on the [pricing page](/pricing), and the [bill estimation answer](/answers/how-do-i-estimate-my-github-actions-bill) covers the single-month version of the same arithmetic. ### [What growth factor should I use if there is no plan to read?](#what-growth-factor-should-i-use-if-there-is-no-plan-to-read)Copy Anchor Link Take the trailing three months of billed minutes per label, compute the month over month change for each label, and use that as the factor where no planned change exists. Cap it at the point where the label would exceed its budget and treat the cap as a review trigger rather than a prediction. The [minutes budget guide](/guides/github-actions-minutes-budget) covers how to set those per-label ceilings. ### [How often does a quarterly runner spend forecast need refreshing?](#how-often-does-a-quarterly-runner-spend-forecast-need-refreshing)Copy Anchor Link Once a month for the inputs and once a week for the pace check. Re-export the Billing rows from [Reports](/docs/ci/features/reports) monthly to refresh minutes per label, and run the daywise costs pull weekly to compare pace against the forecast line. A pace more than 10 percent above the forecast almost always traces back to matrix width or re-run rate. To turn the same rows into a per-change number, divide each label's forecast by the pull requests that trigger it, which the [cost per pull request guide](/guides/ci-cost-per-pull-request) works through in full. --- # How Do I Generate an SBOM During a Build? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-generate-an-sbom-during-a-github-actions-build Description: Set sbom true on the build step so BuildKit attaches an SPDX document to the image it just produced, then read it back by digest or upload it as an artifact. # How Do I Generate an SBOM During a Build? Set sbom true on the build step so BuildKit attaches an SPDX document to the image it just produced, then read it back by digest or upload it as an artifact. Generate the SBOM from the image build itself: set `sbom: true` on the build step so BuildKit scans the build result and attaches an SPDX document to the image as an attestation ([Docker SBOM attestations](https://docs.docker.com/build/metadata/attestations/sbom/)). The inventory then describes the exact digest you pushed, and a later step can either read it back out of the registry or upload it as a workflow artifact for the release job that needs it. ## [Answer](#answer)Copy Anchor Link A GitHub Actions workflow can produce an SBOM at two moments, and the moment decides how much the document is worth. The first moment is inside the build. BuildKit 0.11 and later accept an `--attest type=sbom` request, which runs a scanner over the filesystem the build just produced and writes the result as an attestation manifest referenced from the image index ([Docker build attestations](https://docs.docker.com/build/metadata/attestations/)). The build action exposes this as a boolean input, so the change to an existing workflow is one line. The second moment is after the push, when a separate job pulls the image and scans it. That produces a loose file describing a tag, and tags move. WarpBuild's `Warpbuilds/build-push-action@v6` is a drop-in replacement for `docker/build-push-action@v6` that points the build at a remote builder profile, so the attestation inputs pass through unchanged ([Docker builders documentation](/docs/ci/docker-builders)). Copy Text ``` name: image on: push: branches: [main] permissions: contents: read packages: write jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build, attest, and push id: build uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} sbom: true provenance: mode=max profile-name: "app-builder" - name: Read the SBOM out of the pushed image run: | docker buildx imagetools inspect \ ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }} \ --format '{{ json .SBOM.SPDX }}' > sbom.spdx.json - name: Upload the SBOM uses: actions/upload-artifact@v4 with: name: sbom-${{ github.sha }} path: sbom.spdx.json retention-days: 90 ``` Three details in that file carry the weight. `sbom: true` is the whole generation mechanism. `steps.build.outputs.digest` addresses the image by content rather than by tag, so the inspect step reads the attestation belonging to this build even if `main` moves during the run. And the upload step matters because each runner runs in its own virtual machine that is destroyed after the build ([WarpBuild security documentation](/docs/ci/security)), so anything still on the runner disk at the end of the job is gone. On a multi-platform build the `.SBOM` field becomes a map keyed by platform, and the format string changes to `{{ json (index .SBOM "linux/amd64").SPDX }}` ([Docker SBOM attestations](https://docs.docker.com/build/metadata/attestations/sbom/)). That case is worth planning for early because a builder profile with both architectures enabled produces one attestation per architecture. The [Docker builds hub](/solutions/docker-builds-github-actions) covers the builder profile setup that sits under all of this. ## [Detail](#detail)Copy Anchor Link ### [Build-time attestation versus scanning the pushed image](#build-time-attestation-versus-scanning-the-pushed-image)Copy Anchor Link The two documents differ in three ways that show up during an audit. **Binding.** The attestation is a manifest inside the same image index as the runtime layers, addressed by the index digest. A post-push scan writes a file whose only link to the image is a tag string in the filename or the JSON. If the tag is rebuilt, the file describes something that is no longer there. **Coverage of intermediate stages.** BuildKit reads two Dockerfile build args, `BUILDKIT_SBOM_SCAN_STAGE` and `BUILDKIT_SBOM_SCAN_CONTEXT`, which extend the scan to named build stages and to the build context ([Docker SBOM attestations](https://docs.docker.com/build/metadata/attestations/sbom/)). A compile stage that vendors dependencies and emits a single static binary can therefore be inventoried even though nothing of its package metadata survives into the final layer. A scan of the final image sees only the final filesystem, so those dependencies are invisible to it. **Where the work happens.** The scan runs on the builder that already holds the filesystem in its cache. A post-push scan job pulls the image back down over the network before it can start. ### [Where the SBOM lives and how a later job gets it](#where-the-sbom-lives-and-how-a-later-job-gets-it)Copy Anchor Link | Store | Written by | Read back by | Lifetime | | ----------------------------- | ---------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Registry attestation manifest | sbom: true with push: true | docker buildx imagetools inspect @ --format '{{ json .SBOM.SPDX }}' | As long as the image index exists in the registry | | Workflow artifact | actions/upload-artifact@v4 | actions/download-artifact@v4 | 90 days by default ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13) | | Release asset | gh release upload | gh release download | As long as the release exists | | Local build output | outputs: type=local,dest=out | out/sbom.spdx.json on disk | The job only | The fourth row is the escape hatch for builds that never push. With the `local` or `tar` exporter, BuildKit writes the attestation to a file named `sbom.spdx.json` at the root of the output directory instead of into an index ([Docker SBOM attestations](https://docs.docker.com/build/metadata/attestations/sbom/)). A release job in the same workflow takes the artifact route: Copy Text ``` release: needs: build runs-on: warp-ubuntu-latest-x64-2x steps: - name: Fetch the SBOM the build job produced uses: actions/download-artifact@v4 with: name: sbom-${{ github.sha }} - name: Attach it to the release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload ${{ github.ref_name }} sbom.spdx.json ``` A release job in a different workflow run cannot use the plain form. Pass `run-id` and a `github-token` with `actions: read` to `actions/download-artifact@v4` ([actions/download-artifact](https://github.com/actions/download-artifact)), or skip the artifact store entirely and run the `imagetools inspect` command against the digest you are promoting. The registry route is the one that still answers a question asked eleven months later, once the 90 day artifact retention has expired. If the SBOM also needs a Sigstore signature, [actions/attest-sbom](https://github.com/actions/attest-sbom) signs an existing document and records it in the transparency log, which is the same machinery covered in [attaching build provenance in GitHub Actions](/answers/how-do-i-attach-build-provenance-in-github-actions). ### [What the scan costs](#what-the-scan-costs)Copy Anchor Link The scan is extra work on the builder, billed like the rest of the session. Builder rates from the [Docker builders documentation](/docs/ci/docker-builders) and the [pricing page](/pricing), checked on 2026-08-13: | Builder size | Architectures | Price per minute | | --------------------------- | ------------------- | ---------------- | | 16 vCPU, 32 GB, 100GB disk | amd64, arm64, multi | $0.06 | | 32 vCPU, 64 GB, 200GB disk | amd64, arm64, multi | $0.12 | | 64 vCPU, 128 GB, 200GB disk | amd64, arm64, multi | $0.24 | Substitute your own numbers into the arithmetic. If the SBOM stage adds one minute of builder session time and the repository runs 400 image builds a month, the 16 vCPU profile bills 400 x $0.06 = $24.00 per month for the inventory. The runner side of the same workflow, `warp-ubuntu-latest-x64-4x`, is $0.008 per minute ([pricing page](/pricing), checked on 2026-08-13). Measuring your own delta means comparing two session durations with the input flipped, which is cheaper than estimating it. ## [Related Questions](#related-questions)Copy Anchor Link **Do I need a separate scanner job to produce an SBOM?** No. BuildKit runs a scanner over the build result when the build step sets `sbom: true` and writes the SPDX document as an attestation next to the image manifest ([Docker SBOM attestations](https://docs.docker.com/build/metadata/attestations/sbom/)). A separate job that pulls the pushed image and scans it produces a second inventory of the same bytes, one step further from the build. The [security scanning guide](/guides/security-scanning-jobs-github-actions) covers the scanning jobs that remain worth running on their own schedule. **Why does my build fail when I turn the SBOM attestation on?** Attestations ride on an OCI image index, so the build needs an exporter that can write one ([Docker build attestations](https://docs.docker.com/build/metadata/attestations/)). Pushing to a registry works. Loading into the classic local Docker image store does not, so a build that sets `load: true` needs the containerd image store or one of the file exporters instead. **How do I read the SBOM back out weeks after the build?** Address the image by digest rather than by tag and run `docker buildx imagetools inspect @ --format '{{ json .SBOM.SPDX }}'`. The attestation lives in the registry for as long as the image index does, which outlasts the 90 day default retention on workflow artifacts ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). **What does SPDX mean here, and is CycloneDX an option?** SPDX is the document format BuildKit emits by default, and [SBOM, defined](/glossary/sbom) covers what the format records and why regulators ask for it. A different generator image can be selected through the `sbom` input when a downstream tool needs another format. Set the flag on one image first, check the output of the inspect step, then roll it across the rest of the matrix from the [Docker builds hub](/solutions/docker-builds-github-actions). Per-minute rates for every builder size and runner label are on the [pricing page](/pricing). --- # How Do I Install Windows SDK Components in a Job? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-install-windows-sdk-components-in-github-actions Description: Add the SDK component with the Visual Studio installer in a quiet step, or pick a Windows runner image that already ships the SDK build your project pins. # How Do I Install Windows SDK Components in a Job? Add the SDK component with the Visual Studio installer in a quiet step, or pick a Windows runner image that already ships the SDK build your project pins. Two routes work. Run the Visual Studio installer inside a step with `--add --quiet --norestart`, waiting for the process to exit, or point `runs-on` at a Windows runner image that already ships the SDK build your project targets so nothing is downloaded during the job. The choice matters because the images differ: GitHub's Windows Server 2022 image readme lists four installed Windows SDK builds and the Windows Server 2025 readme lists one, and WarpBuild Windows images carry the same tooling as the GitHub-hosted equivalents ([preinstalled software](/docs/ci/preinstalled-software), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link The Visual Studio installer sits at its standard location, `C:\Program Files (x86)\Microsoft Visual Studio\Installer\vs_installer.exe`, and the instance it manages on the Windows images is Visual Studio Enterprise 2022 at `C:\Program Files\Microsoft Visual Studio\2022\Enterprise` ([Windows Server 2022 image readme](https://github.com/actions/runner-images/blob/main/images/windows/Windows2022-Readme.md)). Adding an SDK component means calling that installer in `modify` mode against that install path. Component and workload IDs are published by Microsoft in the [Visual Studio component ID reference](https://learn.microsoft.com/en-us/visualstudio/install/workload-component-id-vs-enterprise), and the argument list is documented in the [command line parameters reference](https://learn.microsoft.com/en-us/visualstudio/install/use-command-line-parameters-to-install-visual-studio). Copy Text ``` name: windows-native-build on: pull_request: jobs: build: runs-on: warp-windows-latest-x64-8x steps: - uses: actions/checkout@v4 - name: Add the desktop C++ workload and the 26100 SDK shell: pwsh run: | $installer = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vs_installer.exe" $arguments = @( "modify", "--installPath", "C:\Program Files\Microsoft Visual Studio\2022\Enterprise", "--add", "Microsoft.VisualStudio.Workload.NativeDesktop", "--add", "Microsoft.VisualStudio.Component.Windows11SDK.26100", "--quiet", "--norestart", "--nocache" ) $process = Start-Process -FilePath $installer -ArgumentList $arguments -Wait -PassThru if ($process.ExitCode -notin @(0, 3010)) { throw "Visual Studio installer exited with $($process.ExitCode)" } - uses: microsoft/setup-msbuild@v2 - name: Build run: msbuild App.sln /p:Configuration=Release /p:Platform=x64 ``` Three details decide whether this step behaves in a workflow. `--quiet` suppresses the interface so the installer runs unattended. `Start-Process -Wait` holds the step open until the installer process exits, which matters because the installer returns control early otherwise and the next step starts against a half installed toolset; the installer also accepts a `--wait` argument for the same purpose. Exit code 3010 means the component installed and the machine wants a restart, so treat 0 and 3010 as success and fail the step on anything else ([command line parameters reference](https://learn.microsoft.com/en-us/visualstudio/install/use-command-line-parameters-to-install-visual-studio)). Teams that need the SDK without the Visual Studio instance can run Microsoft's standalone installer, `winsdksetup.exe`, with `/features + /quiet /norestart` from the [Windows SDK download page](https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/). Before writing any of that, check whether the build already has what it needs. The two current Windows images differ in exactly this dimension: | Runner label | Image | Installed Windows SDK builds | Source | | ------------------------------ | ------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | warp-windows-latest-x64- | Windows Server 2022 | 10.0.17763.0, 10.0.19041.0, 10.0.22621.0, 10.0.26100.0 | [Windows2022-Readme.md](https://github.com/actions/runner-images/blob/main/images/windows/Windows2022-Readme.md) | | warp-windows-2025-x64- | Windows Server 2025 | 10.0.26100.0 | [Windows2025-Readme.md](https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md) | A project that pins `WindowsTargetPlatformVersion` to `10.0.22621.0` builds on the Windows Server 2022 label with no install step and fails on the Windows Server 2025 label until the component is added. Compare the pinned value in your `.vcxproj` against the readme list first, then either move the pin to a build the image carries or install the one you want. The `warp-windows-2025-vs2026-x64-` labels run the same Windows Server 2025 base with the Visual Studio 2026 toolset ([Windows Visual Studio image reference](/runners/windows/visual-studio-images)), so probe that image with `Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\Include"` before you pin against it. Every label, shape, and rate is listed in the [Windows runner catalog](/runners/windows). ## [Detail](#detail)Copy Anchor Link ### [What the install step costs per job](#what-the-install-step-costs-per-job)Copy Anchor Link An installer step bills like any other minute of runner time. The arithmetic is install minutes times jobs per month times the per minute rate for the shape, and the rates come from the [WarpBuild pricing page](/pricing), checked on 2026-08-13. | Windows label | Per minute | 3 install minutes across 1,000 jobs | 6 install minutes across 1,000 jobs | | --------------------------- | ---------- | ----------------------------------- | ----------------------------------- | | warp-windows-latest-x64-4x | $0.016 | $48.00 | $96.00 | | warp-windows-latest-x64-8x | $0.032 | $96.00 | $192.00 | | warp-windows-latest-x64-16x | $0.064 | $192.00 | $384.00 | | warp-windows-latest-x64-32x | $0.128 | $384.00 | $768.00 | Read your own install duration off the step timing in a job log and substitute it. Selecting a label whose image already carries the component takes that column to zero, which is the whole argument for checking the readme before adding the step. ### [The custom image route](#the-custom-image-route)Copy Anchor Link Teams that need the same components on every run, and cannot get them from a stock image, build the image once. On BYOC that is a custom VM image in your own cloud account, and [custom VM images](/docs/ci/byoc/custom-vm-images) documents the Windows requirements: `aria2` must be present and on the system `PATH`, an AWS instance must be sysprepped before the image is created, and jobs run as the `runneradmin` user, which is the same user GitHub's Windows runners use. There is no additional cost for using a custom VM image ([pricing](/pricing)). The [custom runner image guide](/guides/custom-runner-images-github-actions) covers the build and registration loop. [Snapshot runners](/docs/ci/features/snapshot-runners), which boot a later job from a captured runner state, cover Linux x64 and Linux ARM64, so on Windows the custom image is the route that removes a repeated install from the job path. ### [Windows SDK, .NET SDK, and Windows App SDK are separate installs](#windows-sdk-net-sdk-and-windows-app-sdk-are-separate-installs)Copy Anchor Link The Windows SDK carries the Win32 headers, libraries, and tools that a native build links against, and it arrives through the Visual Studio installer or the standalone installer above. The .NET SDK is installed with `actions/setup-dotnet` and pinned in `global.json`. The Windows App SDK arrives as a NuGet package restored by the project, and the Visual Studio component group `Microsoft.VisualStudio.ComponentGroup.WindowsAppSDK.Cs` is already listed on the [Windows Server 2022 image](https://github.com/actions/runner-images/blob/main/images/windows/Windows2022-Readme.md). An `MSB8036` error names a missing Windows SDK build specifically, so reach for the Visual Studio installer or a different label when you see it, and leave the other two installs alone. ## [Related Questions](#related-questions)Copy Anchor Link **Can I cache the Visual Studio installer download between jobs?** The installer pulls payloads from Microsoft and writes them into the Visual Studio instance, so `actions/cache` has no clean unit to restore. WarpBuild caches are not supported on Windows runners either, so the durable answer is a custom image or a label whose image already carries the component. Details are in the [Windows runner catalog](/runners/windows). **Which Visual Studio toolset ships on each Windows label?** The `latest` and `2025` labels carry Visual Studio Enterprise 2022 and the `2025-vs2026` labels carry Visual Studio 2026 on the same Windows Server 2025 base. The [Windows Visual Studio image reference](/runners/windows/visual-studio-images) lists what each one installs. **Do I need the full workload or a single component?** A single component ID installs faster than a workload because a workload pulls its whole recommended set. Start with the component that names the SDK build, such as `Microsoft.VisualStudio.Component.Windows11SDK.26100`, and add `Microsoft.VisualStudio.Workload.NativeDesktop` only when the compiler and the C++ tooling are also missing. The [Build Tools installation guide](/guides/visual-studio-build-tools-github-actions) walks through both shapes. **What if the build needs a Windows SDK build older than 10.0.17763.0?** Those builds are not on either stock image, so they come from a `modify` call with the matching component ID or from a custom VM image. Check the ID against the [Visual Studio component ID reference](https://learn.microsoft.com/en-us/visualstudio/install/workload-component-id-vs-enterprise) before you pin it. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. --- # How Do I Invalidate a Snapshot? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-invalidate-a-runner-snapshot Description: Change the alias in the snapshot.key label so the next GitHub Actions job boots from the base image, or delete the snapshot through the WarpBuild API. # How Do I Invalidate a Snapshot? Change the alias in the snapshot.key label so the next GitHub Actions job boots from the base image, or delete the snapshot through the WarpBuild API. There are two ways to invalidate a WarpBuild runner snapshot: change the alias in the `snapshot.key=` label so the next job finds nothing saved under the new name and boots from the base image, or delete the stored snapshot with `DELETE /api/v1/snapshots/{id}`. Prefer the key change: it takes effect on the very next job, it leaves the old image in place for jobs that are already running against it, and it reverts with a one-line commit or a variable edit. ## [Answer](#answer)Copy Anchor Link A snapshot has no invalidate label and no purge flag. It is reached through an alias you pick, so the alias is the control surface. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) states the resolution rule directly: `snapshot.key=` boots from an existing snapshot when one is available for that alias, and when none exists the runner boots from the base image. An alias nobody has saved to therefore behaves exactly like a clean runner, which is what invalidation means in practice. That gives you two levers with different blast radii. | Situation | Lever | Why this one | | ----------------------------------------------------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The image drifted: a stale lockfile tree, a wrong toolchain version, a bad fixture set | Change the alias in snapshot.key and in the save action | The next job boots clean and republishes under the new name. Jobs already restoring from the old alias are untouched. | | A credential or key file reached the image because the cleanup step was missing or failed | Delete through the API, and rotate the credential | The old image stays reachable by alias until the 15-day lifetime runs out, and on a public repository any contributor who reads the workflow file knows the alias, per the [security section of the docs](/docs/ci/features/snapshot-runners) | | Retired aliases are accumulating on the storage line | Delete through the API | Storage bills per snapshot-hour until the 15-day deletion, and the delete call stops that meter now | | You want a scheduled clean rebuild | Change the alias to a dated suffix on a cadence | One edit, no API key, and the old image ages out on its own | Platform scope is worth confirming before you plan any of this. Snapshot runners apply to the Cloud Ubuntu part of that catalog, listed on the [snapshot runners hub](/runners/snapshot). ## [Detail](#detail)Copy Anchor Link ### [Changing the key](#changing-the-key)Copy Anchor Link Snapshot behavior is requested through a dynamic label appended to the runner label with a semicolon, so the edit is inside the `runs-on` string: Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-4x;snapshot.key=web-app-main-2026-08-13 steps: - uses: actions/checkout@v5 - name: Install system packages run: | if [ -z "$WARPBUILD_SNAPSHOT_KEY" ]; then sudo apt-get update sudo apt-get install -y libvips-dev protobuf-compiler fi - run: npm ci - run: npm run build - name: Cleanup credentials run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - uses: WarpBuilds/snapshot-save@v1 with: alias: "web-app-main-2026-08-13" ``` The alias appears in two places, and both have to move together. The `alias` input of `WarpBuilds/snapshot-save` is required and is the name a later `snapshot.key` resolves against, per the [action inputs in the docs](/docs/ci/features/snapshot-runners). Rotating only the `runs-on` label gives you an alias that boots cold on every run forever, because nothing ever saves to it. On the run right after the rotation, the runner boots from the base image, so `WARPBUILD_SNAPSHOT_KEY` is unset. That variable is set only on a machine created from a snapshot, which means any setup step you guarded on it runs in full, exactly as it did on the first day. The save action then publishes a fresh image under the new alias, and the run after that boots warm again. Keeping the alias in a repository or organization variable turns invalidation into a settings change with no commit and no pull request: Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-4x;snapshot.key=${{ vars.SNAPSHOT_KEY }} steps: - uses: actions/checkout@v5 # build steps - uses: WarpBuilds/snapshot-save@v1 with: alias: ${{ vars.SNAPSHOT_KEY }} ``` Both places read the same variable, so they cannot drift apart. Editing `SNAPSHOT_KEY` from `web-app-main-2026-08-13` to `web-app-main-2026-09-01` invalidates the snapshot for every workflow that reads it, and GitHub documents the variable scoping rules in [use variables](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-variables). ### [Deleting through the API](#deleting-through-the-api)Copy Anchor Link List first to get the ID, then delete: Copy Text ``` curl -X GET 'https://api.warpbuild.com/api/v1/snapshots' \ -H 'Accept: application/json' \ -H 'Authorization: Bearer wkey-xxxx' curl -X DELETE 'https://api.warpbuild.com/api/v1/snapshots/' \ -H 'Accept: application/json' \ -H 'Authorization: Bearer wkey-xxxx' ``` The list response carries `id`, `status`, `created_at`, and the provider fields for each snapshot in the organization, and the ID from that response is the path parameter for the delete call. The reference pages are [GET /snapshots](/docs/api/snapshotter/ListSnapshots) and [DELETE /snapshots/{id}](/docs/api/snapshotter/DeleteSnapshot). The key itself comes from the [API keys page](https://app.warpbuild.com/settings/api-keys); the [automation documentation](/docs/ci/api-keys/automation) walks through creating one and granting the `ci` and `cache` scopes that the runner and cache APIs expect. Delete when the content of the image is the problem rather than its freshness. A leaked credential is the clearest case, because a rotation leaves the old image addressable for the remainder of its 15 days while a delete removes it now. Storage is the second case. Once the image is gone, no snapshot is available for that alias, and the documented fallback applies: the next job boots from the base image and runs normally. ### [Pricing the first run after invalidation](#pricing-the-first-run-after-invalidation)Copy Anchor Link Every invalidation buys a cold run, and the price of that run is arithmetic you can do in advance. Assume a job that takes 12 minutes on a clean boot, of which 7 minutes is environment setup, and a warm boot that adds about a minute and skips the setup, so 6 minutes. Restore bills $0.04 per job and per-minute rates come from the [pricing page](/pricing), checked on 2026-08-13. | Runner label | Price per minute | Cold run, 12 minutes | Warm run, 6 minutes plus $0.04 | Extra cost of the cold run | | -------------------------- | ---------------- | -------------------- | ------------------------------ | -------------------------- | | warp-ubuntu-latest-x64-2x | $0.004 | $0.048 | $0.064 | \-$0.016 | | warp-ubuntu-latest-x64-4x | $0.008 | $0.096 | $0.088 | $0.008 | | warp-ubuntu-latest-x64-8x | $0.016 | $0.192 | $0.136 | $0.056 | | warp-ubuntu-latest-x64-16x | $0.032 | $0.384 | $0.232 | $0.152 | | warp-ubuntu-latest-x64-32x | $0.064 | $0.768 | $0.424 | $0.344 | Every row costs the same 6 minutes of wall clock, and the money column swings with the rate. The 2 vCPU row is negative because the fixed $0.04 restore fee outweighs 6 minutes at $0.004, which is a sign that the alias was not earning its keep at that size in the first place. ARM64 labels run lower at the same sizes, $0.003 to $0.048 per minute, so their cold-run penalty is smaller again. [Pricing](/pricing) is purely usage based. ### [Storage left behind by a rotation](#storage-left-behind-by-a-rotation)Copy Anchor Link A key change does not remove anything. The retired snapshot keeps billing storage at $0.025 per snapshot-hour, per the [pricing page](/pricing), until its 15-day lifetime expires, which puts a hard ceiling on what an abandoned alias can cost. | Aliases retired without a delete call | Storage per day | Cost until the 15-day deletion | | ------------------------------------- | --------------- | ------------------------------ | | 1 | $0.60 | $9.00 | | 5 | $3.00 | $45.00 | | 20 | $12.00 | $180.00 | One rotation a month on one alias is $9.00 of dead storage, which is cheaper than the engineering minute spent on a delete call. A per-branch alias scheme rotated weekly across twenty branches is $180.00, which pays for a cleanup script that lists snapshots and deletes the ones whose alias no longer appears in any workflow file. ### [Ordering a rotation so fewer jobs boot cold](#ordering-a-rotation-so-fewer-jobs-boot-cold)Copy Anchor Link Which jobs pay the cold-run cost depends on the pattern the repository uses. With a save-on-main workflow, only the default branch republishes, so every pull request run between the key change and the next merge boots cold. Merge the rotation to the default branch first, let that run publish the new alias, then let pull requests pick it up. With a single self-updating alias, the first run after the change is the only cold one, because that same run saves the replacement. The [snapshot runners hub](/runners/snapshot) covers both patterns and where each one fits. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does changing snapshot.key delete the old snapshot?](#does-changing-snapshotkey-delete-the-old-snapshot)Copy Anchor Link No. The old image stays until you delete it through the API or until the 15-day lifetime expires. Storage bills at $0.025 per snapshot-hour, per the [pricing page](/pricing), so an abandoned alias costs $0.60 per day and at most $9.00 before it is deleted for you. [How long do runner snapshots last?](/answers/how-long-do-runner-snapshots-last) covers the lifetime and what happens when an alias goes stale. ### [Where do I find the snapshot ID for the delete call?](#where-do-i-find-the-snapshot-id-for-the-delete-call)Copy Anchor Link `GET https://api.warpbuild.com/api/v1/snapshots` lists the snapshots in your organization with their `id`, `status`, and `created_at` fields, and you pass the ID you want removed to [DELETE /snapshots/{id}](/docs/api/snapshotter/DeleteSnapshot) with the same API key. Create the key on the [API keys page](https://app.warpbuild.com/settings/api-keys) as described in the [automation documentation](/docs/ci/api-keys/automation). ### [What does the first run after invalidation cost?](#what-does-the-first-run-after-invalidation-cost)Copy Anchor Link The next job that asks for the new alias boots from the base image and repeats its setup. On `warp-ubuntu-latest-x64-16x` at $0.032 per minute, a 12-minute cold job costs $0.384 against $0.232 for the 6-minute warm job plus the $0.04 restore fee, so the invalidation costs $0.152 and 6 minutes of wall clock on that one run. Per-minute rates for every label are on the [pricing page](/pricing), and [VM snapshot](/glossary/vm-snapshot) defines the underlying term. --- # How Do I Keep a Docker Layer Cache Across Pull Requests? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-keep-a-docker-layer-cache-between-pull-requests Description: GitHub Actions caches written by one pull request cannot be restored by another. Keep Docker layers on a builder that outlives the job instead. # How Do I Keep a Docker Layer Cache Across Pull Requests? GitHub Actions caches written by one pull request cannot be restored by another. Keep Docker layers on a builder that outlives the job instead. Last verified: 2026-08-13 Keep the layer cache on a builder that outlives the job rather than in a per-branch cache entry, because a GitHub Actions cache written by one pull request is invisible to every other pull request. On WarpBuild that builder is a remote Docker builder profile: a dedicated build VM that holds the layer cache on its own disk and is selected by name from the workflow, so the second pull request of the day starts from warm layers instead of rebuilding them. ## [Answer](#answer)Copy Anchor Link The behavior comes from GitHub's cache access rules, not from anything in your Dockerfile. Three of them decide the outcome, all quoted from the [GitHub Actions dependency caching documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13: * "Workflow runs can restore caches created in either the current branch or the default branch (usually `main`)." A pull request run "can also restore caches created in the base branch, including base branches of forked repositories." * "Workflow runs cannot restore caches created for child branches or sibling branches." Two open pull requests are siblings. * "When a cache is created by a workflow run triggered on a pull request, the cache is created for the merge ref (`refs/pull/.../merge`). Because of this, the cache will have a limited scope and can only be restored by re-runs of the pull request." Put together: the only layer cache two unrelated pull requests share is whatever the default branch wrote at the last merge. Every export a pull request performs lands in a scope that pull request alone can read, and it ages out. GitHub "will remove any cache entries that have not been accessed in over 7 days", and by default caps "the total size of all caches in a repository" at 10 GB, so a few multi-stage images can push the shared default-branch entry out of the repository budget on their own. That leaves three places to put Docker layers, and only one of them is keyed by something other than a git ref. Remote Docker builders run outside the GitHub Actions job with a persistent layer cache on local disks, described in the [Docker builders documentation](/docs/ci/docker-builders). A builder profile maps to one dedicated builder VM, and every job that names the same `profile-name` reaches that VM and that cache, whichever branch or pull request the job belongs to. [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) covers the whole configuration; this page covers the cross-pull-request question specifically. ## [Detail](#detail)Copy Anchor Link ### [The three placements compared](#the-three-placements-compared)Copy Anchor Link | Placement | Where layers live | Keyed by | Second, unrelated pull request | First-time pull request from a fork | | ------------------------------------------ | ----------------------------------------- | -------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | Branch-scoped Actions cache (type=gha) | GitHub's cache service, as exported blobs | Git ref | Reads the default branch entry only, then writes to its own merge ref | Reads the base branch entry if one survives eviction; its own export is merge-ref scoped | | Registry cache (type=registry) | A cache image in a container registry | Registry tag | Reads any tag it has credentials for | Reads a public cache tag; cannot write, because push credentials are a secret | | Persistent builder cache (builder profile) | The builder VM's local disk | Builder profile name | Reads the layers the previous build on that profile left | Reads the same layers, with no repository secret required on a WarpBuild runner | The first two rows explain the failure mode most teams hit. `cache-to: type=gha,mode=max` looks like it should fix cross-pull-request misses, and it does not, because the export inherits the ref scoping described above. A registry cache escapes ref scoping, and it introduces a credential requirement instead. ### [What a fork pull request actually sees](#what-a-fork-pull-request-actually-sees)Copy Anchor Link Fork pull requests are where the three placements diverge most sharply, so they are worth working through directly. GitHub is explicit about the credential side: "With the exception of `GITHUB_TOKEN`, secrets are not passed to the runner when a workflow is triggered from a forked repository", and "the `GITHUB_TOKEN` has read-only permissions in pull requests from forked repositories" ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13). A registry cache backend therefore cannot write from a fork pull request at all, and it can only read a cache image that is publicly readable. A builder profile sits outside that constraint. The `Warpbuilds/build-push-action` `api-key` input is not required when the job runs on a WarpBuild runner, per the [Docker builders documentation](/docs/ci/docker-builders), so a fork pull request reaches the builder without any repository secret. Two prerequisites go with that. Self-hosted runners, including managed ones, are disabled on public repositories until an organization owner checks `Allow public repositories` on the default runner group, per the [public repositories documentation](/docs/ci/public-repos). And a fork pull request that writes into a shared profile cache is writing into a cache your default branch builds read, so on a repository that accepts untrusted contributions, point fork pull requests at a second profile and keep the trusted one clean. ### [The workflow](#the-workflow)Copy Anchor Link The profile choice is a workflow expression, which keeps both paths in one file: Copy Text ``` name: docker on: push: branches: [main] pull_request: jobs: image: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Select builder profile id: builder run: | if [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then echo "profile=app-images-untrusted" >> "$GITHUB_OUTPUT" else echo "profile=app-images" >> "$GITHUB_OUTPUT" fi - name: Log in to the registry if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: ${{ github.event_name == 'push' }} tags: ghcr.io/acme/app:${{ github.sha }} profile-name: ${{ steps.builder.outputs.profile }} ``` Three things are missing on purpose. There is no `docker/setup-buildx-action`, because the action creates the remote builder from the profile itself. There is no `cache-from` or `cache-to`, because a cached builder caches layers and reuses them on later builds without an export step. And there is no `api-key`, because the job runs on `warp-ubuntu-latest-x64-4x`. Move this workflow onto a GitHub-hosted runner and the `api-key` input becomes required, because the builder is reached over the WarpBuild API rather than through the runner image. ### [Lifetime and cost](#lifetime-and-cost)Copy Anchor Link The builder cache has a TTL of 10 days, and a profile unused for longer than that is reset automatically. You can also reset a profile's cache on demand through the WarpBuild API, which is the lever to pull when a bad layer gets cached. Dependency caches for the job itself are separate and live in the [WarpBuild cache](/docs/ci/features/caching), a drop-in replacement for `actions/cache@v4`. Builders are billed per session, measured from when the builder action starts until the job completes, and concurrent jobs sharing a profile share one session. Rates from the [pricing page](/pricing), checked on 2026-08-13: | Builder profile | Architectures | Price per minute | | ------------------------------ | ------------------- | ---------------- | | 16 vCPU, 32GB RAM, 100GB disk | amd64, arm64, multi | $0.06 | | 32 vCPU, 64GB RAM, 200GB disk | amd64, arm64, multi | $0.12 | | 64 vCPU, 128GB RAM, 200GB disk | amd64, arm64, multi | $0.24 | Two structural notes. Builders attach to jobs on any of them. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can one pull request restore a Docker layer cache created by another pull request?](#can-one-pull-request-restore-a-docker-layer-cache-created-by-another-pull-request)Copy Anchor Link No. GitHub creates a pull request cache for the merge ref `refs/pull/.../merge`, and that entry can only be restored by re-runs of the same pull request. Sibling branches cannot read each other's caches at all, so the only entry two open pull requests share is the one the default branch wrote at the last merge. [Cache scope](/glossary/cache-scope) defines the boundary these rules draw. ### [Does a pull request from a fork get a warm Docker layer cache?](#does-a-pull-request-from-a-fork-get-a-warm-docker-layer-cache)Copy Anchor Link With a branch-scoped cache it gets whatever the base branch last wrote and nothing newer. With a registry cache it gets nothing on write, because secrets are not passed to the runner when a workflow is triggered from a forked repository. With a persistent builder profile the cache is keyed to the profile rather than to a ref, so the build starts from the layers the previous build on that profile left behind. [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) has the full setup. ### [Do I still need cache-from and cache-to with a WarpBuild builder profile?](#do-i-still-need-cache-from-and-cache-to-with-a-warpbuild-builder-profile)Copy Anchor Link No. The builder profile keeps its layer cache on the builder VM's local disk, so `cache-from` and `cache-to` are not required and should be removed. The builder cache has a TTL of 10 days, and a profile that goes unused for longer than that is reset automatically. [WarpBuild remote Docker builders](/runners/docker-builders) lists the profile sizes, and [sharing a Docker cache between jobs](/guides/share-docker-cache-between-jobs) covers the parallel-job case. --- # How Do I Limit a Workflow to One Run at a Time? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-limit-a-workflow-to-one-run-at-a-time Description: Declare a concurrency group with a fixed name and leave cancel-in-progress off, so runs queue instead of overlapping. Each group holds one pending run. # How Do I Limit a Workflow to One Run at a Time? Declare a concurrency group with a fixed name and leave cancel-in-progress off, so runs queue instead of overlapping. Each group holds one pending run. ## [Answer](#answer)Copy Anchor Link Declare a `concurrency` block at the workflow level with a fixed group name and leave `cancel-in-progress` off, which is its default. Runs in that group then serialize: the run already in progress finishes, and a run that arrives while it is going parks in a pending state instead of starting alongside it, per [GitHub's workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax). One mechanical detail decides whether that gives you what you wanted. A group holds one running item and one pending item. When a third run enters the group, GitHub cancels the pending run and parks the newcomer in its place, so what you get is a lane with the newest commit at the front rather than a FIFO queue that replays every merge in order. For deploys that is usually the right behavior, because the newest commit is the one you want live. Here are the two shapes side by side. The serialized shape: Copy Text ``` name: deploy-production on: push: branches: [main] concurrency: group: deploy-production cancel-in-progress: false jobs: deploy: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/migrate.sh - run: ./scripts/deploy.sh ``` The cancel-newer shape, which belongs on pull request checks rather than on deploys: Copy Text ``` name: pr-checks on: pull_request: concurrency: group: pr-checks-${{ github.ref }} cancel-in-progress: true jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test ``` Three fields separate them. | Field | Serialized deploy | Cancel-newer checks | | -------------------------- | ------------------------------ | ----------------------------------- | | group | Fixed string, no ref | Scoped with github.ref | | cancel-in-progress | false | true | | Effect on the running item | Finishes | Canceled by the new arrival | | Effect on the pending item | Replaced by a newer arrival | Not applicable | | Fits | Deploys, shared external state | Repeated pushes to one pull request | ## [Detail](#detail)Copy Anchor Link ### [The two shapes that need serialization](#the-two-shapes-that-need-serialization)Copy Anchor Link Only two categories of work earn a serialized lane, and both fail the same test: two copies running in the same minute leave the world in a state that neither copy intended. **Deploys to one environment.** Two deploy runs racing against one cluster produce an ordering you did not choose. The older commit can land after the newer one because its image push finished first, and the environment then serves code nobody expects. The [deployment jobs guide](/guides/deployment-jobs-github-actions) covers the full deploy shape, including how the lane sits next to environment protection rules. **Jobs that share external state.** Terraform applies against one state file, schema migrations against one database, a publish step that pushes one tag to a registry, a job that holds a single seat on a device farm or a license server. Terraform makes the failure visible with a state lock error. Migrations and tag pushes often do not, which is what makes them worth serializing before an incident teaches you. Everything else should stay parallel. Test shards, lint jobs, and matrix builds write to nothing shared, and putting them in a lane converts a five minute check into a twenty minute one for no gain. ### [Name the group after the thing being protected](#name-the-group-after-the-thing-being-protected)Copy Anchor Link The group name is the whole design. Name it after the resource, and the lane covers exactly the runs that touch that resource. * `deploy-production` serializes every deploy to production, whichever workflow or branch triggered it. * `deploy-${{ github.event.inputs.environment }}` gives one lane per environment, so staging and production never block each other. * `pr-checks-${{ github.ref }}` gives one lane per branch, which is the pattern you want with `cancel-in-progress: true`. A group name is shared across the whole repository, so two workflows carrying the same string serialize against each other whether or not that was the intent. The [glossary entry on concurrency groups](/glossary/concurrency-group) covers the term, and [what happens when two workflows share a concurrency group](/answers/what-happens-when-two-workflows-use-the-same-concurrency-group) walks through the collision case and the naming convention that avoids it. When a workflow contains both a wide test matrix and a single writer, put the block inside the job instead of at the top of the file: Copy Text ``` jobs: test: strategy: matrix: shard: [1, 2, 3, 4, 5, 6] runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/6 deploy: needs: test concurrency: group: deploy-production cancel-in-progress: false runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/deploy.sh ``` Six shards still start together. Only `deploy` takes the lane. ### [What the queue costs](#what-the-queue-costs)Copy Anchor Link Take a deploy job that runs 9 minutes on `warp-ubuntu-latest-x64-4x`, which is 4 vCPU and 16 GB at $0.008 per minute from the [cloud runners catalog](/docs/ci/cloud-runners), checked on 2026-08-13\. That is $0.072 per deploy. Now land three merges on `main` inside one busy morning. | Time | Event | Group state | Result | | ----- | ---------- | -------------------- | ------------------------------------ | | 09:00 | Merge A | A running | A deploys, 9 billed minutes | | 09:03 | Merge B | A running, B pending | B waits, no runner assigned | | 09:05 | Merge C | A running, C pending | B canceled, C takes the pending slot | | 09:09 | A finishes | C running | C deploys, 9 billed minutes | The wall clock cost is real: C shipped at 09:18 instead of 09:14\. The money moves the other way. Billing starts when a job starts on a runner ([GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13), so B billed nothing while pending and nothing when canceled. Serialized: 18 billed minutes, $0.144\. Unserialized: three deploys, 27 billed minutes, $0.216, and two of the three racing against one cluster. Set that against what an overlapping pair costs to unwind. A Terraform state lock error blocks the next apply until somebody clears it by hand. A half-applied migration takes a restore window. A container tag pointing at the older image ships a rollback nobody asked for. Four minutes of lane time per collision is the cheaper side of that trade, and the lane is the only part of it you control from YAML. Price the lane against the size you actually need before widening anything: | runs-on label | OS | vCPU | RAM | Rate per minute | | --------------------------- | ------------ | ---- | ---- | --------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8GB | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16GB | $0.008 | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16GB | $0.006 | | warp-macos-latest-arm64-6x | macOS | 6 | 14GB | $0.08 | A serialized deploy is the job where a shorter runtime pays twice, because the duration sets both the wall clock and the width of the window in which the next merge has to wait. `warp-ubuntu-latest-x64-4x` costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner of the same 4 vCPU, 16 GB shape: 33 percent lower list price ([GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). Full per-minute rates by runner type sit on the [pricing page](/pricing). ### [Make sure the wait you see is the lane you wrote](#make-sure-the-wait-you-see-is-the-lane-you-wrote)Copy Anchor Link A run held by a concurrency group and a job waiting for a free runner look identical from the pull request page, and they have opposite fixes. Measure before editing YAML. The Queue Timings report breaks queue wait into P75 and P90 per runner label and stack, with CSV export, per the [reports documentation](/docs/ci/features/reports). That number covers waiting for a runner, so a flat P90 next to a run showing a Waiting banner names your own group as the cause. Runner capacity should not be the other half of the answer. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts as workflows fan out, which is scoped in the [cloud runners documentation](/docs/ci/cloud-runners) to Generally Available features on Linux and Windows runners. macOS concurrency runs on a per-organization quota arranged with support at [support@warpbuild.com](mailto:support@warpbuild.com). [Pricing](/pricing) is purely usage based. The [guide to speeding up GitHub Actions](/guides/speed-up-github-actions) covers the duration side of the same problem, which is the lever that shrinks the lane. ## [Related Questions](#related-questions)Copy Anchor Link ### [How do I make GitHub Actions runs queue instead of cancel each other?](#how-do-i-make-github-actions-runs-queue-instead-of-cancel-each-other)Copy Anchor Link Leave `cancel-in-progress` out of the `concurrency` block, or set it to `false`, which is the default in [GitHub's workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax). The run already in progress finishes and the next arrival waits. Each group holds one pending run, so a third arrival cancels the pending run and takes the pending slot, which means intermediate commits get skipped and the newest one deploys. ### [Does a run waiting on a concurrency group cost runner minutes?](#does-a-run-waiting-on-a-concurrency-group-cost-runner-minutes)Copy Anchor Link No. A run parked by a concurrency group has no runner assigned to it, and billing starts when a job starts on a runner ([GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13). A pending run canceled by a newer arrival costs nothing at all, which is why the serialized timeline above bills 18 minutes where the unserialized one bills 27. ### [Should the concurrency group name include the branch ref?](#should-the-concurrency-group-name-include-the-branch-ref)Copy Anchor Link For serializing deploys to one environment, no. A fixed name such as `deploy-production` keeps every deploy in one lane whichever branch triggered it. Include `github.ref` when you want one lane per branch, which is the pull request check pattern. Because the name is shared across the repository, check it against every other workflow first: [what happens when two workflows share a concurrency group](/answers/what-happens-when-two-workflows-use-the-same-concurrency-group) covers the collision. ### [Can I serialize one job instead of the whole workflow?](#can-i-serialize-one-job-instead-of-the-whole-workflow)Copy Anchor Link Yes. A `concurrency` block inside a job governs that job across runs, so a six-shard test matrix keeps fanning out while only the deploy job takes the lane. That is the shape the [deployment jobs guide](/guides/deployment-jobs-github-actions) builds on, and the [concurrency group glossary entry](/glossary/concurrency-group) defines the term the two blocks share. Confirm your own wait is the lane rather than the pool with the Queue Timings numbers in the [reports documentation](/docs/ci/features/reports), then price the deploy job against the per-minute rates on the [pricing page](/pricing). The [guide to speeding up GitHub Actions](/guides/speed-up-github-actions) covers the rest of the levers once queueing is not the bottleneck. --- # How Do I Measure Cache Hit Rate in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-measure-cache-hit-rate-in-github-actions Description: Read the cache-hit output of the restore step on every run, write it to the job summary, and price the count against the cache line in the billing report. # How Do I Measure Cache Hit Rate in GitHub Actions? Read the cache-hit output of the restore step on every run, write it to the job summary, and price the count against the cache line in the billing report. Read the `cache-hit` output of the restore step in each run and count how often it comes back `true`, which takes one extra step that writes the outcome into the job summary and one `gh` loop over recent runs. Then pair that count with the cache line in the billing report, because a hit rate only means something against what the cache costs and how many minutes a hit actually saves. ## [Answer](#answer)Copy Anchor Link The measurement has a numerator and a denominator that live in different places. The numerator comes from the workflow, where the cache action exposes a `cache-hit` output that is `true` only when an entry matched the primary `key` exactly, and `false` on a `restore-keys` prefix match or a miss ([WarpBuild caching documentation](/docs/ci/features/caching)). The denominator is every run of that job. Nothing records that boolean for you, so add a step that does. This version writes a machine-readable marker to both the step log and the [job summary](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#adding-a-job-summary): Copy Text ``` jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Restore dependency cache id: deps uses: WarpBuilds/cache/restore@v1 with: path: | ~/.npm node_modules key: npm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/package-lock.json') }} restore-keys: | npm-${{ runner.os }}-${{ runner.arch }}- - name: Record cache outcome if: always() run: | if [ "${{ steps.deps.outputs.cache-hit }}" = "true" ]; then result=hit else result=miss fi echo "CACHE_RESULT job=unit-tests entry=deps result=$result label=${{ runner.os }}-${{ runner.arch }}" \ | tee -a "$GITHUB_STEP_SUMMARY" - name: Install dependencies if: steps.deps.outputs.cache-hit != 'true' run: npm ci ``` The marker goes to stdout as well as the summary, which is what makes it countable later. Logs are what the GitHub CLI can read back, so the rate over the last 50 runs of a workflow is one command: Copy Text ``` gh run list --workflow ci.yml --branch main --limit 50 \ --json databaseId --jq '.[].databaseId' | while read -r id; do gh run view "$id" --log | grep -o 'CACHE_RESULT .*result=[a-z]*' || true done | grep -o 'result=[a-z]*' | sort | uniq -c ``` The output is two lines, `result=hit` and `result=miss`, with counts. Divide and you have the exact-match rate for that job on that branch over that window. Run logs are retained for 90 days by default and the period is configurable per organization ([GitHub log retention settings](https://docs.github.com/en/organizations/managing-organization-settings/configuring-the-retention-period-for-github-actions-artifacts-and-logs-in-your-organization), checked on 2026-08-13), so retention bounds the window rather than the workflow. Count per branch: the default branch and pull request branches read different entries, and a blended number hides the case where `main` is warm and every pull request is cold. Two properties of the number are worth stating before anyone reports it. A prefix restore through `restore-keys` counts as a miss here, because `cache-hit` is `false` for it, so what you are measuring is the rate at which the install step gets skipped entirely. And a job that never writes an entry has a hit rate of zero forever, which reads as a cache problem when it is a save-step problem. ## [Detail](#detail)Copy Anchor Link ### [The cost side of the ratio](#the-cost-side-of-the-ratio)Copy Anchor Link The Cache tab of the [Reports page](/docs/ci/features/reports) carries the other half. It shows a daily chart broken down by cache type, summary cards for total cost, storage cost, operations cost, and total entries, a per-entry table, and CSV export of every row matching the current filters. The cache type filter offers `storage`, `operation-hit`, and `operation-commit`. | Line in the Cache tab | What it bills | Hosted rate | BYOC | | --------------------- | ------------------------ | --------------------- | ---- | | storage | Gigabytes held per month | $0.20 per GB-month | Free | | operation-hit | Reads and list calls | $0.0001 per operation | Free | | operation-commit | Writes | $0.0001 per operation | Free | Rates from the [WarpBuild caching documentation](/docs/ci/features/caching), checked on 2026-08-13\. Those rows count billed operations rather than exact-key matches, so they price the cache and do not replace the `cache-hit` count as the numerator. The Jobs report supplies the third input. It aggregates per repository, workflow, and job name, with run count, success rate, and duration P75 and P90 ([Reports documentation](/docs/ci/features/reports)), which is where the warm and cold step durations come from without stopwatch work. Reading utilization alongside them is covered in [GitHub Actions observability and runner metrics](/solutions/github-actions-observability). ### [The rate at which a cache stops paying for itself](#the-rate-at-which-a-cache-stops-paying-for-itself)Copy Anchor Link A cache earns its place when the minutes it saves on hits outweigh the minutes it adds on misses plus the storage it holds. Write it as a break-even: **break-even rate = (monthly fixed cost / runs + cost per miss) / (saving per hit + cost per miss)** Take a job on `warp-ubuntu-latest-x64-4x` at $0.008 per minute ([pricing page](/pricing), checked on 2026-08-13), 1,000 runs a month, a 2 GB entry, an install that takes 4 minutes cold and 30 seconds warm, a restore that takes 24 seconds, and a save that takes 30 seconds. Substitute your own durations from the Jobs report; the rates are fixed. * Saving per hit: 4.0 minus 0.5 warm minus 0.4 restore = 3.1 minutes, or $0.0248. * Cost per miss: 0.1 minute of lookup plus 0.5 minute of save = 0.6 minutes, or $0.0048, plus one write at $0.0001, so $0.0049. * Fixed per month: 2 GB at $0.20 = $0.40 of storage, plus 1,000 restore operations at $0.0001 = $0.10, so $0.50. * Break-even: (0.0005 + 0.0049) / (0.0248 + 0.0049) = 0.0054 / 0.0297 = 0.18. Below roughly an 18 percent exact-match rate, that cache costs more than deleting the steps. The threshold moves a long way with the shape of the entry: | Entry shape | Cold step | Warm step | Restore | Save | Size | Break-even rate | | ------------------------------- | --------- | --------- | ------- | ------- | ---- | --------------- | | npm downloads and node\_modules | 4.0 min | 0.5 min | 0.4 min | 0.5 min | 2 GB | 18 percent | | Wide build output tree | 1.5 min | 0.5 min | 0.8 min | 0.9 min | 6 GB | 97 percent | The second row is the one to look for. A large entry that saves 12 seconds of a 90 second step needs a rate almost nothing achieves, and the honest move is to narrow the `path` list until the restore is cheaper than the work it replaces, or to drop that entry and keep the cheap one. [Cache hit rate](/glossary/cache-hit-rate) covers the definition and the counting conventions behind these numbers. ### [Measure per label rather than per repository](#measure-per-label-rather-than-per-repository)Copy Anchor Link The cache version hash covers the compression tool and the `path` list, so an entry written on `warp-macos-latest-arm64-6x` never restores on `warp-ubuntu-latest-x64-4x` ([caching documentation](/docs/ci/features/caching)). A matrix job that shares one key expression across labels therefore reports one blended rate that averages several separate caches, one of which may be at zero. The marker line above carries `runner.os` and `runner.arch` for exactly that reason. WarpBuild caching is not supported on WarpBuild Windows runners, where workflows keep `actions/cache@v4` ([caching documentation](/docs/ci/features/caching)). When the counted rate is near zero on every branch, the cause is usually the key rather than the store, and [why a GitHub Actions cache misses every run](/answers/why-does-my-github-actions-cache-miss-every-run) works through the three scopes that decide a lookup. When the rate is healthy and the job is still slow, the restore itself is the cost, and the [guide to persistent caches on GitHub Actions](/guides/persistent-caches-github-actions) covers the mechanisms that keep state on the machine instead of moving it each run. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does a restore-keys prefix match count toward the hit rate?](#does-a-restore-keys-prefix-match-count-toward-the-hit-rate)Copy Anchor Link No. The `cache-hit` output is `true` only on an exact match against the primary key, so a prefix restore through `restore-keys` puts files on disk and still reports `false` ([caching documentation](/docs/ci/features/caching)). A rate counted from `cache-hit` is an exact-match rate, which is the number you want when the question is whether the install step gets skipped. Log the restore step duration next to the outcome if you also want to see partial restores, and read [cache hit rate](/glossary/cache-hit-rate) for how the two counts differ. ### [Can I read the hit rate out of the WarpBuild reports instead of the workflow?](#can-i-read-the-hit-rate-out-of-the-warpbuild-reports-instead-of-the-workflow)Copy Anchor Link The Cache tab of the [Reports page](/docs/ci/features/reports) gives you the cost side: storage cost, operations cost, total entries, and a daily chart by cache type, with CSV export. Those rows count billed operations rather than exact-key matches, so keep the `cache-hit` count from the workflow as the numerator and use the report for what the cache costs and for the job durations you compare it against. [GitHub Actions observability and runner metrics](/solutions/github-actions-observability) covers the Jobs and Queue Timings sections alongside it. ### [What hit rate is high enough to keep a cache?](#what-hit-rate-is-high-enough-to-keep-a-cache)Copy Anchor Link It depends on the two durations and the entry size, so compute the break-even instead of guessing. A 2 GB npm cache that turns a 4 minute install into 30 seconds on `warp-ubuntu-latest-x64-4x` breaks even near an 18 percent exact-match rate at $0.008 per minute ([pricing page](/pricing), checked on 2026-08-13), while a 6 GB entry that saves 12 seconds of a 90 second step needs about 97 percent and should be narrowed or dropped. When the answer is that no key can carry the rate, [persistent caches on GitHub Actions](/guides/persistent-caches-github-actions) is the next mechanism to try. --- # How Do I Publish a Package From a Workflow? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-publish-a-package-from-github-actions Description: Gate the publish job behind a release event, authenticate with a credential scoped to the registry, and publish the artifact the test job already built. # How Do I Publish a Package From a Workflow? Gate the publish job behind a release event, authenticate with a credential scoped to the registry, and publish the artifact the test job already built. Publish from a job that runs only on a release event, authenticate with a credential scoped to the target registry, and ship the artifact the test job already built. The publish job downloads that artifact instead of rebuilding it, so the bytes that passed the test suite are the bytes that reach the registry. ## [Answer](#answer)Copy Anchor Link A publish workflow is three decisions: when the job runs, what credential it holds, and where the file it uploads comes from. Getting any one of them wrong shows up later as a bad release rather than as a red build. **When.** Publishing belongs on an event that a human or a tag creates. GitHub triggers a workflow on `release` with type `published`, and on `push` filtered by `tags`, both documented in the [events that trigger workflows reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows). A branch push runs the tests, and only the release event reaches the registry. **What credential.** The three options differ in blast radius rather than in effort. | Credential | How the job gets it | Tradeoff | | -------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | Workflow GITHUB\_TOKEN | Automatic, with permissions: packages: write in the job | Works for GitHub Packages registries tied to the repository. Expires with the job. Cannot publish to an external registry. | | Long-lived registry token | Stored as an encrypted repository or environment secret | Works with every registry. Lives until someone rotates it, and any job granted the secret can use it. | | OIDC exchange with a trusted publisher | The job requests id-token: write and swaps the token for a short-lived credential | No stored secret to leak or rotate. Requires registry support and a trust rule naming the repository and workflow file. | GitHub documents the [OIDC token flow](https://docs.github.com/en/actions/concepts/security/openid-connect), and the registries carry the other half: [npm trusted publishers](https://docs.npmjs.com/trusted-publishers) and [PyPI trusted publishers](https://docs.pypi.org/trusted-publishers/). Where a trusted publisher exists, use it, because a credential that lives for the length of one job removes the rotation schedule and the leak window that a long-lived token carries. **Which file.** Build once. The test job produces the tarball, wheel, or jar, uploads it with [actions/upload-artifact](https://github.com/actions/upload-artifact), and the publish job pulls it back with [actions/download-artifact](https://github.com/actions/download-artifact). Rebuilding inside the publish job re-runs a compiler against a fresh dependency resolution and produces a file that nothing tested. ## [Detail](#detail)Copy Anchor Link ### [A release-triggered publish job](#a-release-triggered-publish-job)Copy Anchor Link One workflow, two jobs. Every push runs `build`, and only a published release runs `publish`. Copy Text ``` name: release on: push: branches: [main] release: types: [published] permissions: contents: read concurrency: group: publish-${{ github.ref }} cancel-in-progress: false jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version: 22 registry-url: https://registry.npmjs.org - uses: WarpBuilds/cache@v1 with: path: ~/.npm key: npm-${{ hashFiles('package-lock.json') }} - run: npm ci - run: npm test - run: npm pack --pack-destination dist - uses: actions/upload-artifact@v4 with: name: package-tarball path: dist/*.tgz retention-days: 7 publish: needs: build if: github.event_name == 'release' runs-on: warp-ubuntu-latest-x64-2x environment: release permissions: contents: read id-token: write steps: - uses: actions/download-artifact@v4 with: name: package-tarball path: dist - uses: actions/setup-node@v4 with: node-version: 22 registry-url: https://registry.npmjs.org - run: npm publish dist/*.tgz --provenance --access public ``` Four lines carry the design. `if: github.event_name == 'release'` keeps pushes off the registry. `environment: release` routes the job through an environment, which is where a required reviewer or a branch restriction attaches. `id-token: write` is what makes the OIDC exchange and the `--provenance` flag work, and with a trusted publisher configured on the package there is no `NODE_AUTH_TOKEN` line at all. `concurrency` stops two releases from racing to the same version. If the registry has no trusted publisher support, the last step keeps a secret instead: Copy Text ``` - run: npm publish dist/*.tgz --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` Scope that secret to the `release` environment rather than the repository, so only jobs that declare `environment: release` can read it. On WarpBuild runners the secret handling is unchanged: each runner runs in its own virtual machine, created on demand and destroyed after the build, and WarpBuild does not access or store build secrets, which stay in your source code repository and are only accessible to your runner environment ([security documentation](/docs/ci/security)). ### [The build-once rule, and what breaks it](#the-build-once-rule-and-what-breaks-it)Copy Anchor Link The artifact handoff has two failure modes worth knowing before they bite. The first is expiry. Workflow artifacts have a retention window, 90 days by default and configurable per repository or per upload, per the [GitHub Actions limits reference](https://docs.github.com/en/actions/reference/limits). A publish job that runs in the same workflow run always finds its artifact. A publish workflow triggered days later by a separate release event has to fetch the artifact from the earlier run by run id, and it fails once retention has passed. The second is the rebuild that sneaks back in. Any `npm ci`, `mvn package`, or `cargo build` inside the publish job resolves dependencies again at publish time, and a dependency that moved between the test run and the release turns the published file into something untested. Keep the publish job free of build steps, and it stays a download plus an upload. Provenance makes the rule checkable rather than aspirational. npm records a [provenance statement](https://docs.npmjs.com/generating-provenance-statements) linking the published version to the workflow run that produced it, and [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) does the same for arbitrary artifacts. Both attest the run, so a rebuild in a different job is visible to anyone who checks. Which matters for releases that ship more than one platform binary. Build the macOS binary on a macOS runner and the Linux binary on a Linux runner, upload both as artifacts, and let one small Linux publish job collect them and push a single release. ### [What the split costs](#what-the-split-costs)Copy Anchor Link The publish job downloads a file and calls a registry API, so it belongs on the smallest runner available. Here is a release pipeline for a package with a nine minute test and build job, priced from the [WarpBuild pricing page](/pricing), checked on 2026-08-13. | Job | Runner | Minutes | Rate per minute | Cost per release | | ------------------------------- | ------------------------- | ------- | --------------- | ---------------- | | Test and build | warp-ubuntu-latest-x64-4x | 9 | $0.008 | $0.072 | | Publish, artifact download only | warp-ubuntu-latest-x64-2x | 2 | $0.004 | $0.008 | | Publish with a rebuild instead | warp-ubuntu-latest-x64-4x | 8 | $0.008 | $0.064 | The build-once pipeline is $0.080 per release. Rebuilding in the publish job takes the same release to $0.136, and at 40 releases a month that is $3.20 against $5.44 for a file nothing tested. The rate on the publish job is the cheapest line in the table for a reason. `warp-ubuntu-latest-x64-2x` gives 2 vCPU and 8 GB at $0.004 per minute, while GitHub-hosted `ubuntu-latest` on a private repository is the same 2 vCPU, 8 GB shape at $0.006 per minute: $0.002 divided by $0.006 is 33 percent lower list price ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). Two structural facts sit under those numbers. Package storage bills separately on GitHub's side under [GitHub Packages billing](https://docs.github.com/en/billing/concepts/product-billing/github-packages), and the dependency restore that dominates the build job is the line that [caching](/docs/ci/features/caching) moves. ## [Related Questions](#related-questions)Copy Anchor Link ### [Should the publish job rebuild the package?](#should-the-publish-job-rebuild-the-package)Copy Anchor Link No. Build and test once, upload the result with `actions/upload-artifact`, and have the publish job download that same file. A rebuild inside the publish job produces bytes that no test ever ran against, and it pays for the build minutes twice: $0.064 against $0.008 in the table above. The full artifact handoff pattern, including matrix builds that produce one artifact per platform, is in the [build artifacts and publishing guide](/guides/build-artifacts-publishing-github-actions). ### [Do I need a stored registry token to publish?](#do-i-need-a-stored-registry-token-to-publish)Copy Anchor Link It depends on the registry. GitHub Packages accepts the workflow `GITHUB_TOKEN` with `packages: write` permission, which expires when the job ends. npm and PyPI both support OIDC trusted publishing, which exchanges a short-lived workflow identity for a publish credential and removes the stored secret entirely. Registries without OIDC support still need a token in an encrypted secret, scoped to a deployment environment rather than to the whole repository. The [deployment environment definition](/glossary/deployment-environment) covers what that scoping buys. ### [How do I stop a publish from running on every push?](#how-do-i-stop-a-publish-from-running-on-every-push)Copy Anchor Link Put the publish work in its own job with a condition on the event, so pushes run tests and only a published release reaches the registry. Add an environment with a required reviewer when a human approval belongs in the path, and a concurrency group so two releases cannot publish the same version at the same time. Promoting one tested artifact through several environments is the same idea one step further, covered in [build once, deploy many](/guides/build-once-deploy-many-github-actions). ### [How do I make the release pipeline finish faster?](#how-do-i-make-the-release-pipeline-finish-faster)Copy Anchor Link Cut the test and build job, since it holds most of the minutes. Cache the dependency restore, split the test suite across a matrix, and size the runner to the job rather than to the repository. The [guide to speeding up GitHub Actions](/guides/speed-up-github-actions) works through each lever with configuration examples, and every per-minute rate quoted above is on the [pricing page](/pricing). --- # How Do I Push a Manifest List from GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-push-a-manifest-list-from-github-actions Description: Build each architecture on its own native runner, push both images by digest, then run docker buildx imagetools create in a final job to publish one tag. # How Do I Push a Manifest List from GitHub Actions? Build each architecture on its own native runner, push both images by digest, then run docker buildx imagetools create in a final job to publish one tag. Build each architecture on a runner of that architecture, push both images by digest with no tag attached, then run `docker buildx imagetools create` in a final job to publish one manifest list that points at both digests. That final job is the only step in the workflow that writes a tag, so the tag your users pull always resolves to an index rather than to a single architecture image ([Docker multi-platform image guide for GitHub Actions](https://docs.docker.com/build/ci/github-actions/multi-platform/), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link The shape is three jobs: one build job per architecture running in parallel, and one small merge job that runs after both. | Job | Runner label | What it pushes | Per minute | | ------------------- | --------------------------- | ------------------------------------------- | ---------- | | build (linux/amd64) | warp-ubuntu-latest-x64-8x | image manifest, addressed by digest, no tag | $0.016 | | build (linux/arm64) | warp-ubuntu-latest-arm64-8x | image manifest, addressed by digest, no tag | $0.012 | | merge | warp-ubuntu-latest-arm64-2x | one manifest list carrying every tag | $0.003 | Every rate comes from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13\. Both halves of this workflow run on native hardware. Copy Text ``` name: publish-image on: push: tags: ["v*"] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build: strategy: fail-fast: false matrix: include: - platform: linux/amd64 runner: warp-ubuntu-latest-x64-8x - platform: linux/arm64 runner: warp-ubuntu-latest-arm64-8x runs-on: ${{ matrix.runner }} permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Name this leg run: | platform="${{ matrix.platform }}" echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV" - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - id: build uses: docker/build-push-action@v6 with: context: . platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true - name: Record the digest run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - uses: actions/upload-artifact@v4 with: name: digests-${{ env.PLATFORM_PAIR }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 merge: needs: [build] runs-on: warp-ubuntu-latest-arm64-2x permissions: contents: read packages: write steps: - uses: actions/download-artifact@v4 with: path: /tmp/digests pattern: digests-* merge-multiple: true - uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,format=long type=raw,value=latest,enable={{is_default_branch}} - name: Create the manifest list working-directory: /tmp/digests run: | docker buildx imagetools create \ $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *) - name: Inspect what was published run: | docker buildx imagetools inspect \ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} ``` The handoff between the two stages is a file name. Each build job writes an empty file named after its digest into `/tmp/digests` and uploads it as an artifact, and the merge job downloads every `digests-*` artifact into one directory with `merge-multiple: true`. The `printf '...@sha256:%s ' *` line then turns that directory listing back into one fully qualified reference per architecture ([Docker multi-platform image guide for GitHub Actions](https://docs.docker.com/build/ci/github-actions/multi-platform/), checked on 2026-08-13). ## [Detail](#detail)Copy Anchor Link ### [Why pushing by digest and merging beats one emulated build](#why-pushing-by-digest-and-merging-beats-one-emulated-build)Copy Anchor Link The alternative is a single job that passes both platforms to one buildx invocation. On an x64 runner, the `linux/arm64` half of that build needs an aarch64 execution environment, and the usual way a workflow gets one is `docker/setup-qemu-action`, which registers binfmt handlers so the kernel routes aarch64 binaries through a QEMU user mode interpreter. QEMU translates every guest instruction into host instructions at run time before the host CPU executes it, and Docker's own multi-platform documentation names emulation as the slowest of the three build strategies it lists ([Docker multi-platform builds](https://docs.docker.com/build/building/multi-platform/), checked on 2026-08-13). That translation lands on exactly the steps that dominate an image build: compilers and linkers, native module builds under `npm`, wheels that `pip` builds from source, and anything that generates machine code at run time. It also produces failure modes that read like Dockerfile bugs, including `exec format error` when a handler was never registered in that job and `Illegal instruction` from libraries probing for CPU features the interpreter does not implement ([Docker multi-platform builds](https://docs.docker.com/build/building/multi-platform/), checked on 2026-08-13). The split workflow removes the interpreter from the picture. The aarch64 layers are compiled by an aarch64 kernel and userspace on `warp-ubuntu-latest-arm64-8x`, the amd64 layers on `warp-ubuntu-latest-x64-8x`, and the two jobs run in parallel, so the wall clock of the build stage is the slower leg rather than the sum of both. The merge job moves no layer bytes at all: `docker buildx imagetools create` writes a small JSON index that references manifests already in the registry ([docker buildx imagetools create reference](https://docs.docker.com/reference/cli/docker/buildx/imagetools/create/), checked on 2026-08-13). Assume 400 tagged builds a month, 6 minutes for the amd64 leg and 8 minutes for the arm64 leg, plus a 1 minute merge job. At the rates above that is 400 x 6 x $0.016 = $38.40 for the amd64 leg, 400 x 8 x $0.012 = $38.40 for the arm64 leg, and 400 x 1 x $0.003 = $1.20 for the merge, so $78.00 a month with the merge job accounting for $1.20 of it. ### [The registry authentication step](#the-registry-authentication-step)Copy Anchor Link Three jobs means three machines, so each one authenticates on its own. `docker/login-action@v3` against `ghcr.io` with `${{ github.actor }}` and `${{ secrets.GITHUB_TOKEN }}` works once the job grants `packages: write`, because the automatic token is scoped per job and defaults to read-only permissions on repositories configured that way ([GitHub automatic token authentication](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication), checked on 2026-08-13). Two details are easy to miss. The merge job needs the same login as the build jobs, since `imagetools create` reads the source manifests and writes the index through the registry API. And every architecture must be pushed to the same repository, because an index entry is a digest inside that repository; digests that live in a different registry force a copy instead of a reference ([docker buildx imagetools create reference](https://docs.docker.com/reference/cli/docker/buildx/imagetools/create/), checked on 2026-08-13). For Amazon ECR, replace the login step with `aws-actions/configure-aws-credentials` using an OIDC role and `aws-actions/amazon-ecr-login`, and keep the rest of the workflow unchanged. For Docker Hub, use a repository secret holding an access token rather than an account password. ### [The tag strategy that keeps the manifest list stable](#the-tag-strategy-that-keeps-the-manifest-list-stable)Copy Anchor Link The build jobs deliberately push no tag. `push-by-digest=true` with `name-canonical=true` stores each architecture manifest under its content address and writes no tag at all, so there is never a moment where `:latest` or `:1.4.0` resolves to an amd64-only image while the arm64 job is still running ([Docker multi-platform image guide for GitHub Actions](https://docs.docker.com/build/ci/github-actions/multi-platform/), checked on 2026-08-13). All tags are then written once, in the merge job, from a single `docker/metadata-action` block. That matters because `docker buildx imagetools create` accepts many `-t` flags in one invocation, so `1.4.0`, `1.4`, the long commit SHA, and `latest` are all created against the same index in the same command ([docker/metadata-action](https://github.com/docker/metadata-action), checked on 2026-08-13). Keep two habits alongside it. Deploy from the immutable tag or from the index digest that `imagetools inspect` prints, and treat `latest` as a convenience pointer only. And read the inspect output before you trust the result: a healthy index lists one entry per architecture, and BuildKit also stores provenance and SBOM attestations as extra entries with the platform set to `unknown/unknown`, which is normal and is explained in the [multi arch image glossary entry](/glossary/multi-arch-image). ### [When a merge job is the wrong shape](#when-a-merge-job-is-the-wrong-shape)Copy Anchor Link The three job workflow is the right default when the Dockerfile is cheap to rebuild and the registry cache carries most of the work between runs. A dependency heavy Dockerfile that recompiles the same base layers on every pull request wants the other shape: one job that hands the build to a remote Docker builder profile with both architectures enabled, keeping a persistent layer cache on the builder between runs. Each architecture runs on a separate builder instance, so a multi architecture build opens one session per architecture ([Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13). Remote Docker builders sit in the same product surface as snapshot runners, CI observability, an MCP server, and the Action Debugger. Sizes and rates for those profiles are on the [multi platform Docker builders page](/runners/docker-builders/multi-platform). ## [Related Questions](#related-questions)Copy Anchor Link ### [Do I still need docker/setup-qemu-action once the build is split?](#do-i-still-need-dockersetup-qemu-action-once-the-build-is-split)Copy Anchor Link No. Each build job compiles only its own architecture on a runner of that architecture, so no binfmt handler and no QEMU interpreter are involved. Keep `setup-qemu-action` only on workflows where one machine still has to produce a platform it cannot execute natively. The ARM64 labels and rates for the native leg are in the [Linux ARM64 runner catalog](/runners/linux-arm64). ### [Why do the build jobs push without a tag?](#why-do-the-build-jobs-push-without-a-tag)Copy Anchor Link Because a tag written by a build job would point at a single architecture image until the other job finished. `push-by-digest=true` stores the manifest under its digest and attaches no tag, so the merge job is the only step that ever writes a tag and that tag is created pointing at the manifest list. The full diagnosis of the single job alternative is in [multi platform Docker builds on native runners](/guides/multi-platform-docker-builds). ### [Can I use docker manifest create instead of docker buildx imagetools create?](#can-i-use-docker-manifest-create-instead-of-docker-buildx-imagetools-create)Copy Anchor Link Both write an index. `docker manifest create` is an experimental CLI feature that needs `DOCKER_CLI_EXPERIMENTAL=enabled` and a separate `docker manifest push` step, and it reads platform metadata from the referenced manifests ([docker manifest reference](https://docs.docker.com/reference/cli/docker/manifest/), checked on 2026-08-13). `docker buildx imagetools create` takes every tag in one invocation and needs no experimental flag, which is why the merge job above uses it. ### [Can I skip the merge job entirely?](#can-i-skip-the-merge-job-entirely)Copy Anchor Link Yes, by handing the whole build to a remote Docker builder profile with both architectures enabled and setting `platforms: linux/amd64,linux/arm64` on one job. Each architecture runs on a separate builder instance and bills as one session per architecture, and the builder publishes the index for you ([Docker builders documentation](/docs/ci/docker-builders)). Profile sizes and per-minute rates are on the [multi platform Docker builders page](/runners/docker-builders/multi-platform). --- # How Do I Reduce Docker Image Pull Time in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-reduce-docker-image-pull-time-in-github-actions Description: Shrink the image, keep the registry in the same Region as the runners, and stop re-pulling layers a remote builder or a snapshot runner already holds on disk. # How Do I Reduce Docker Image Pull Time in GitHub Actions? Shrink the image, keep the registry in the same Region as the runners, and stop re-pulling layers a remote builder or a snapshot runner already holds on disk. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Three changes reduce Docker image pull time in GitHub Actions: shrink what you pull, keep the registry in the same Region as the runners, and stop re-pulling what a builder or a snapshot already holds. The first two are edits to a Dockerfile and to where the runners sit, and the third needs a runner platform that keeps layer state between jobs. Pull time on an ephemeral runner is arithmetic. The runner asks the registry for the layers its local image store lacks, moves the compressed bytes over the network, then decompresses and extracts them onto disk. A GitHub-hosted runner starts every job with an empty image store, so it lacks every layer, every time. That is why the same base image gets pulled on every pull request in a busy repository, and why the fix is either fewer bytes or fewer pulls. The three levers, in the order most teams should work them: * **Fewer bytes.** Multi-stage builds, a smaller runtime base, a real `.dockerignore`, and layer ordering that puts stable layers first. The floor is the compressed size of the layers the runner does not already hold, so this lever never reaches zero. * **Shorter path.** Pulling from a registry in the same Region as the runner keeps the bytes inside the provider network. Pulling across the internet path adds latency to the pull and a per-gigabyte line to the cloud bill. * **Fewer pulls.** A remote Docker builder keeps layers on its own disk between builds. A snapshot runner boots the job from a disk image that already contains the pulled images. Both remove the pull rather than making it faster. ## [Detail](#detail)Copy Anchor Link ### [Lever one: shrink what you pull](#lever-one-shrink-what-you-pull)Copy Anchor Link Start by measuring. Add a timed `docker pull` step to one job and record the wall clock and the compressed size the registry reports. Every estimate below multiplies against that measured number, so guessing at it makes the rest of the arithmetic worthless. The usual reductions are structural. Move build toolchains into a builder stage that never ships. Pick a runtime base that carries the interpreter and nothing else. Write a `.dockerignore` that keeps `.git`, `node_modules`, and test fixtures out of the build context. Order the Dockerfile so dependency installation sits above application code, which keeps the large dependency layer stable across commits and lets a warm cache skip it. The limit is worth stating plainly. Slimming changes the numerator of the pull, and it does nothing about the fact that an ephemeral runner starts empty. A team that halves its image size still pays that reduced pull on every single job. ### [Lever two: keep the registry and the runners in the same place](#lever-two-keep-the-registry-and-the-runners-in-the-same-place)Copy Anchor Link This lever changes both the pull duration and the invoice. AWS states that data transferred between Amazon ECR and other services in the same Region, including Amazon EC2, is free of charge at $0.00 per GB ([Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/), checked on 2026-08-13). The same page states that data transferred between Amazon ECR and services in different Regions is charged at internet data transfer rates, and its own worked example uses $0.09 per GB. WarpBuild Cloud runners run in US and EU regions. BYOC places the runner fleet inside your own cloud account and Region, which is the placement that takes the registry pull onto the same-Region path by the provider's own pricing. Here is what the placement is worth on a deploy pipeline running 40 jobs per weekday across 22 weekdays, which is 880 jobs per month. Each row is one image size, and the two cost columns are the same gigabytes priced on the two paths. | Compressed image pulled per job | Jobs per month | GB pulled per month | Internet path at $0.09 per GB | Same-Region path at $0.00 per GB | | ------------------------------- | -------------- | ------------------- | ----------------------------- | -------------------------------- | | 0.4 GB | 880 | 352 | $31.68 | $0.00 | | 1.0 GB | 880 | 880 | $79.20 | $0.00 | | 1.5 GB | 880 | 1,320 | $118.80 | $0.00 | | 3.0 GB | 880 | 2,640 | $237.60 | $0.00 | Read the table down a column to see what slimming buys, and across a row to see what placement buys. Both rates come from the [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/) page, checked on 2026-08-13\. The full model, including the managed NAT residue that appears when runners sit in private subnets, is in the guide to [what ECR pulls cost in GitHub Actions](/guides/ecr-pull-costs-github-actions). ### [Lever three, option one: a remote Docker builder](#lever-three-option-one-a-remote-docker-builder)Copy Anchor Link A builder profile corresponds to one dedicated builder virtual machine with a persistent layer cache on its own local disk. Base image layers and intermediate layers stay on that disk between builds, so the second build reuses them in place with no export or restore step. Four limits shape whether this fits: * **The builder cache has a TTL of 10 days.** A profile that goes unused for more than 10 days is reset automatically, and the next build on it starts cold. * **Billing is per session.** A session runs from when the builder action starts until the job completes, and multiple concurrent jobs on the same profile share one session, billed from the first job's start to the last job's completion. * **Multi-arch splits into sessions.** Each architecture runs on a separate builder instance, so a multi-arch profile produces one session per architecture, billed independently. * **arm64 and multi-arch profiles cap at 64 vCPU.** The 96 vCPU and 192 vCPU sizes exist only for amd64-only profiles. Builder sizes and their published rates, from the [Docker builders documentation](/docs/ci/docker-builders) and the [pricing page](/pricing), checked on 2026-08-13: | Builder size | Disk | Price per minute | Architectures | | -------------------- | ----- | ---------------- | ------------------- | | 16 vCPU, 32 GB RAM | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU, 64 GB RAM | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU, 128 GB RAM | 200GB | $0.24 | amd64, arm64, multi | | 96 vCPU, 192 GB RAM | 600GB | $0.36 | amd64 only | | 96 vCPU, 192 GB RAM | 2TB | $0.52 | amd64 only | | 192 vCPU, 384 GB RAM | 600GB | $0.72 | amd64 only | | 192 vCPU, 384 GB RAM | 2TB | $0.88 | amd64 only | Scope this option correctly. A builder removes the base image pull that happens during a build, and it leaves the pull that a deploy job performs at run time untouched, because that pull happens on the job runner against your registry. Builders answer the build half of the question. The next option answers the run half. ### [Lever three, option two: snapshot runners](#lever-three-option-two-snapshot-runners)Copy Anchor Link A snapshot runner boots the job from a saved image of an earlier run's disk, so images already pulled into the local Docker store at capture time are present when the first step executes. Requesting one is a label change: append `snapshot.key=` to the runner label, and capture the disk with the `WarpBuilds/snapshot-save` action after the images are in place. The limits are firm. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, per the [snapshot runners documentation](/docs/ci/features/snapshot-runners). BYOC runners, Windows runners, and macOS runners are not supported, and the labels on those types are silently ignored, so a typo in the runner label yields a job that runs to completion at cold-boot speed with nothing in the log to flag it. Snapshots are deleted after 15 days. `/tmp` does not persist, since booting from a snapshot is a boot. Booting from a snapshot takes 45 to 60 seconds, which is the budget the skipped pull has to beat. Costs sit on top of the runner rate, from the [pricing page](/pricing), checked on 2026-08-13: snapshot restore at $0.04 per job and snapshot storage at $0.025 per hour per snapshot. One alias held for a 720-hour month is $18.00 in storage. ### [A workflow that uses both](#a-workflow-that-uses-both)Copy Anchor Link The default branch boots clean, builds on a remote builder, pulls the images the integration step needs, cleans credentials, and republishes the snapshot. Pull requests boot from that snapshot and skip the pull entirely. Copy Text ``` name: build-and-verify on: push: branches: [main] pull_request: jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-4x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-4x;snapshot.key=service-images-main' }} steps: - uses: actions/checkout@v5 - name: Build and push service image uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ${{ vars.REGISTRY }}/service:${{ github.sha }} profile-name: "service-builder" - name: Warm the local image store run: | if [ -z "$WARPBUILD_SNAPSHOT_KEY" ]; then docker pull postgres:16 docker pull redis:7 else echo "images restored from snapshot $WARPBUILD_SNAPSHOT_KEY" fi - name: Integration tests run: docker compose -f compose.test.yml up --exit-code-from tests - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "service-images-main" fail-on-error: false wait-timeout-minutes: 60 ``` Two details in that file carry weight. `Warpbuilds/build-push-action@v6` is the drop-in replacement for the standard build and push action and wires the remote builder for you, so the `docker/setup-buildx-action` step comes out. `fail-on-error: false` keeps a failed capture from turning a green pipeline red, which is the right default when the snapshot is an optimization. `warp-ubuntu-latest-x64-4x` is $0.008 per minute and `warp-ubuntu-latest-x64-2x` is $0.004 per minute on the [pricing page](/pricing), checked on 2026-08-13\. Every cost and performance number on this page carries a source link and a checked-on date, and the same number appears on every WarpBuild surface that states it. ### [What this is worth against the invoice](#what-this-is-worth-against-the-invoice)Copy Anchor Link Teams that want the transfer line removed rather than reduced should read the [zero egress page](/enterprise/zero-egress): on the enterprise tier, egress costs from the customer's cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores during deployments, on BYOC and on WarpBuild-hosted runners. For the compose-driven case specifically, where several service images get pulled before the tests start, [Docker Compose integration tests in GitHub Actions](/solutions/docker-compose-integration-tests-github-actions) works through the sizing. The [remote Docker builders page](/runners/docker-builders) covers profile setup and concurrency. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does a multi-arch image double the bytes a job pulls?](#does-a-multi-arch-image-double-the-bytes-a-job-pulls)Copy Anchor Link No. A multi-arch tag is a manifest list, and a runner resolves it to the layers for its own architecture, so one job moves one architecture's bytes. Building is the other half: on remote Docker builders each architecture runs on a separate builder instance, which creates one session per architecture on the same builder profile, and each session keeps its own layer cache. The [Docker builders documentation](/docs/ci/docker-builders) states the session rule. ### [How much does slimming the image actually help?](#how-much-does-slimming-the-image-actually-help)Copy Anchor Link Pull time and transfer cost both scale with the compressed bytes the runner does not already have. Taking a 1.5 GB image down to 0.4 GB moves 880 monthly deploy jobs from 1,320 GB to 352 GB, which is $118.80 against $31.68 at the AWS internet data transfer rate of $0.09 per GB ([Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/), checked on 2026-08-13). On the same-Region path both numbers are $0.00, which is why placement usually outranks slimming on the invoice even though slimming still wins on wall clock. ### [Do snapshot runners help with image pulls on Windows, macOS, or BYOC runners?](#do-snapshot-runners-help-with-image-pulls-on-windows-macos-or-byoc-runners)Copy Anchor Link No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. On BYOC runners, Windows runners, and macOS runners the snapshot labels are silently ignored, so the job runs normally with no snapshot behavior and no error in the log. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) lists the supported and unsupported platforms. ### [Can the cloud transfer charge on registry pulls go away entirely?](#can-the-cloud-transfer-charge-on-registry-pulls-go-away-entirely)Copy Anchor Link On the enterprise tier, egress costs from the customer's cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores during deployments, and this applies on BYOC and on WarpBuild-hosted runners. Qualification details are on the [zero egress page](/enterprise/zero-egress), and the per-gigabyte model that charge replaces is in [what ECR pulls cost in GitHub Actions](/guides/ecr-pull-costs-github-actions). --- # How Do I Reproduce a CI Failure Locally? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-reproduce-a-ci-failure-locally Description: Start on the runner: open a debug session on the machine that failed, then move locally once you know whether toolchain, work directory, or memory differs. # How Do I Reproduce a CI Failure Locally? Start on the runner: open a debug session on the machine that failed, then move locally once you know whether toolchain, work directory, or memory differs. ## [Answer](#answer)Copy Anchor Link Start on the runner instead: open a debug session on the machine where the job failed, read the environment there, and move to a local reproduction only once you know which piece of the environment differs. A local run that passes tells you your laptop and the runner disagree somewhere, and it leaves the location of the disagreement open, which is the part that costs the afternoon. Three differences cover most failures that survive a local run, and each one is a single command inside a session on the failing machine: the toolchain version the runner image resolves, the work directory the job checks out into, and the memory available to the process. Once you have the differing value, the local reproduction becomes a specific experiment rather than a search. | Question in front of you | Where it gets answered | What it costs | | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | What does the failing machine look like at the failing step | A debug session opened by the [Action Debugger](/docs/ci/tools/action-debugger) step | Runner minutes for the length of the session | | Did the machine exhaust CPU, memory, or disk while that step ran | The runner telemetry for that job, covered in [GitHub Actions observability](/solutions/github-actions-observability) | Nothing extra; charts are already recorded | | Does the same command fail under my local toolchain | A local shell, after the checklist below | Your own machine time | The Action Debugger is part of the WarpBuild product surface alongside snapshot runners, remote Docker builders, CI observability, and an MCP server. It is an ordinary open-source GitHub Action, so the step behaves the same on a GitHub-hosted label and on a `warp-` label. A Windows or macOS failure gets reproduced on the platform that produced it. ## [Detail](#detail)Copy Anchor Link ### [Pause the job at the step that failed](#pause-the-job-at-the-step-that-failed)Copy Anchor Link Three inputs turn the debugger into something safe to leave in a workflow: the failure condition, the access restriction, and the timeout. Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x permissions: contents: read checks: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test - name: Setup interactive ssh session if: ${{ failure() }} uses: Warpbuilds/action-debugger@v1.3 timeout-minutes: 15 with: limit-access-to-actor: true ``` `if: ${{ failure() }}` runs the step only when an earlier step in the job has failed, so green runs skip it and bill nothing extra. On a red run the workflow pauses there, an SSH session opens on the runner machine, and the URL is printed in the action logs and posted as a check on the GitHub run, per the [Action Debugger documentation](/docs/ci/tools/action-debugger). The workspace, the environment variables, and the half-written build outputs are still on disk at that point, which is the state a local reproduction is trying to recreate from scratch. `limit-access-to-actor: true` restricts the session to the GitHub user who triggered the run. Left off, the default depends on that account: a triggering user with SSH keys on their GitHub account is the only one who can connect, and an account with no keys leaves the session reachable by anyone who has or guesses the URL. Run logs on a public repository are public, and the URL is in them. The `checks: write` permission is what lets the URL appear as a check on the run. When the URL shows in the logs and no check appears, workflow permissions are the cause, and many organizations set that at the organization level. ### [Reconnect to the same address with a named session](#reconnect-to-the-same-address-with-a-named-session)Copy Anchor Link Session URLs are long random strings, generated fresh on every run so nobody can guess one. When several people join the same reproduction, or when you script the connection, a deterministic address is easier to work with. Named sessions produce the form `/@gha.warp.build` on every run. Copy Text ``` - name: Setup interactive ssh session if: ${{ failure() }} uses: Warpbuilds/action-debugger@v1.3 timeout-minutes: 15 with: limit-access-to-actor: true named-session-name: payments-api-repro named-session-api-key: ${{ secrets.WARPBUILD_API_KEY }} ``` Named sessions need an API key issued by WarpBuild support, and `limit-access-to-actor` has to be `true` when they are in use, since a predictable address removes the guessing barrier. Both inputs are documented in the [Action Debugger reference](/docs/ci/tools/action-debugger). ### [The three checks that explain the difference](#the-three-checks-that-explain-the-difference)Copy Anchor Link Run these in the session before you open a local shell. Each row is a value you can then pin locally. | Check | Command in the session | What a mismatch means | | ----------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Toolchain version | node --version, python3 --version, go version, xcodebuild -version | The image resolved a different version than your machine did. WarpBuild runner images carry the same tooling as GitHub-hosted runners ([cloud runners documentation](/docs/ci/cloud-runners)), and per-image contents are published in [actions/runner-images](https://github.com/actions/runner-images) and in the [GitHub-hosted runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13 | | Work directory | pwd, echo "$GITHUB\_WORKSPACE", ls -a | An absolute path compiled into a build, a cache key, or a coverage report breaks when the root moves. Ubuntu 24.04 ARM64 runners set the work dir to /runner/\_work, against /home/runner/work/ on GitHub-hosted runners | | Available memory | free -m on Linux, vm\_stat on macOS, plus nproc | A laptop with 64 GB hides a leak that a 16 GB runner surfaces. Exit code 137 with no traceback is the signature | Two more commands are worth the seconds they take: `df -h` for the filesystem behind `no space left on device`, and `printenv | sort` to diff the environment against your shell, since a secret or a `PATH` entry that exists in one place and not the other produces failures that read as logic bugs. Memory is the check that a local run is worst at, because the shapes differ. Rates and shapes below come from the [pricing page](/pricing), checked on 2026-08-13. | Label | vCPU | RAM | Per minute | 15 minute session | | -------------------------- | ---- | ----- | ---------- | ----------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | $0.06 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | $0.12 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | $0.24 | | warp-windows-latest-x64-4x | 4 | 16 GB | $0.016 | $0.24 | | warp-macos-latest-arm64-6x | 6 | 14 GB | $0.08 | $1.20 | A test suite that peaks at 20 GB passes on a 32 GB laptop, passes on `warp-ubuntu-latest-x64-8x`, and dies on `warp-ubuntu-latest-x64-4x`. Reading `free -m` in the session settles that in one command, and the fix is a label change rather than a code change. ### [Bound the session so it releases the machine](#bound-the-session-so-it-releases-the-machine)Copy Anchor Link Runner minutes bill for the whole time a session sits open, and GitHub kills workflows after 6 hours by default, so an abandoned session bills 360 minutes: $2.88 on `warp-ubuntu-latest-x64-4x` at $0.008 per minute and $28.80 on `warp-macos-latest-arm64-6x` at $0.08 per minute. `timeout-minutes: 15` on the step caps the same pause at $0.12 and $1.20\. The key sits beside `uses` rather than under `with`. Detached mode changes when the pause happens. With `detached: true`, every step of the job runs as normal and the action pauses at the end of the job, which suits a workflow whose later steps upload artifacts you also want to inspect. Pair it with `timeout-minutes` as well, since a detached session holds the machine the same way an inline one does. ### [Move to the local reproduction with a specific target](#move-to-the-local-reproduction-with-a-specific-target)Copy Anchor Link With the differing value in hand, the local run has a job: pin the toolchain version the image resolved, check out into a path with the same shape, or cap memory for the process to the runner's RAM. That reproduction is fast because it tests one variable. Some failures have no local equivalent, and the session is the end of the path rather than a step toward one. Linux x64 nested virtualization is opt-in through a label, and an Android emulator job that reaches `/dev/kvm` needs a permissions step on any GitHub-compatible runner, which the [common issues documentation](/docs/ci/common-issues) covers with the exact `udev` rule. macOS runners cannot run Docker, so a container step there fails on the platform regardless of what your laptop does. And a job that never starts is a routing problem rather than a build failure: the same page covers bot repository access, runner group access, and the workflow path restrictions that keep a group from picking up a job. For the ordered procedure across all five failure classes, read the [guide to debugging GitHub Actions failures](/guides/debug-github-actions-failures). For the fleet view of where jobs fail and how instances are utilized, see [GitHub Actions observability](/solutions/github-actions-observability). ## [Related Questions](#related-questions)Copy Anchor Link ### [Why does the job pass on my laptop and fail on the GitHub Actions runner?](#why-does-the-job-pass-on-my-laptop-and-fail-on-the-github-actions-runner)Copy Anchor Link Three differences explain most of these cases: the toolchain version resolved by the runner image, the work directory the job checks out into, and the memory the machine has. A debug session answers all three in about a minute, and the answer tells you what to pin before you run the same command locally. The step and its inputs are in the [Action Debugger documentation](/docs/ci/tools/action-debugger). ### [How do I tell whether the runner ran out of memory rather than the code failing?](#how-do-i-tell-whether-the-runner-ran-out-of-memory-rather-than-the-code-failing)Copy Anchor Link Read the runner telemetry for that job. A step killed by the kernel out-of-memory killer prints nothing and ends with exit code 137, so the log looks empty while the memory chart for the instance sits at its ceiling. Metrics and logs are collected for jobs longer than about 1 minute, so a 20 second failure has no charts. Both readings are covered in [GitHub Actions observability](/solutions/github-actions-observability) and in the answer on [debugging a failed GitHub Actions job](/answers/how-do-i-debug-a-failed-github-actions-job). ### [How do I keep a debug session from billing for six hours?](#how-do-i-keep-a-debug-session-from-billing-for-six-hours)Copy Anchor Link Set `timeout-minutes` on the debugger step and gate the step behind `if: ${{ failure() }}`. GitHub kills workflows after 6 hours by default, and the runner bills for the whole time the session is open, so a forgotten session on `warp-ubuntu-latest-x64-4x` at $0.008 per minute bills $2.88 against $0.12 for a 15 minute cap. Rates from the [pricing page](/pricing), checked on 2026-08-13. ### [Can I open the session on a Windows or macOS runner too?](#can-i-open-the-session-on-a-windows-or-macos-runner-too)Copy Anchor Link Yes. The debugger step runs wherever the job runs. A Windows path failure or an Xcode failure gets reproduced on the platform that produced it. Session mechanics, access control, and named sessions are covered in the answer on [SSH access to a GitHub Actions runner](/answers/can-i-ssh-into-a-github-actions-runner). --- # How Do I Reset a Docker Builder Cache? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-reset-a-docker-builder-cache Description: Reset the persistent layer cache on a WarpBuild builder profile with one API call. The request shape, when a cheaper fix works, and what the next build costs. # How Do I Reset a Docker Builder Cache? Reset the persistent layer cache on a WarpBuild builder profile with one API call. The request shape, when a cheaper fix works, and what the next build costs. Reset the cache on the builder profile: one POST to the WarpBuild API clears the persistent layer cache held on that profile's builder VM and leaves the profile itself in place, so the profile name, size, and architectures stay as they were and no workflow file changes. The next build on that profile starts cold and rebuilds its layers, which is why a reset is worth timing rather than reaching for it at the first cache miss. ## [Answer](#answer)Copy Anchor Link A builder profile corresponds to one dedicated Docker builder virtual machine with caching attached, per the [Docker builders documentation](/docs/ci/docker-builders). The layers live on that machine's local disk and are addressed by profile name, so there is no cache key or tag to bump and nothing in the Dockerfile that clears them. Clearing happens through the API instead. Two calls do it. The first lists your builder profiles so you can read the profile id; the second resets that profile's cache: Copy Text ``` export WARPBUILD_API_KEY="wkey-xxxx" curl -s -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles?per_page=30&page=1" BUILDER_PROFILE_ID="wxxxxxxxxxxxxxxx" curl -s -X POST \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles/$BUILDER_PROFILE_ID/cache/reset" ``` The key comes from the [API keys page](https://app.warpbuild.com/settings/api-keys) with the `ci` and `cache` scopes granted, as described in the [automation documentation](/docs/ci/api-keys/automation). The same key drives the rest of the builder flow, so a scheduled reset is a few lines in an existing maintenance workflow. [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) covers the whole builder configuration; this page covers the reset. ## [Detail](#detail)Copy Anchor Link ### [The request shape](#the-request-shape)Copy Anchor Link | Call | Method | Path | Body | Effect | | ------------- | ------ | ----------------------------------------------------------- | ---- | ------------------------------------------------------- | | List profiles | GET | /api/v1/builder-profiles?per\_page=30&page=1 | none | Returns the profiles in the organization with their ids | | Reset cache | POST | /api/v1/builder-profiles/{builder\_profile\_id}/cache/reset | none | Clears the layer cache on that profile's builder VM | Both calls carry `Authorization: Bearer $WARPBUILD_API_KEY` against the `https://api.warpbuild.com` host. The reset takes no JSON body at all: the profile id in the path is the entire input, which makes it safe to wire into a chat command or a scheduled job without templating a payload. Three things the reset leaves alone are worth stating, because they decide whether you need a change window. The profile survives, so `profile-name` in every workflow still resolves. Deleting a profile is the separate operation, done from the [Docker Builders page](https://app.warpbuild.com/ci/docker-builders), and the documentation requires all builds to finish before a delete. And GitHub Actions cache entries written by `actions/cache` or a registry cache tag are a different store entirely, untouched by this endpoint. ### [When a reset is the right move](#when-a-reset-is-the-right-move)Copy Anchor Link A reset clears the whole profile disk, so every image built on that profile rebuilds from scratch afterwards, including the images that were fine. On backends keyed by a string, such as a registry cache tag or a GitHub Actions cache key, the cheap escape from a poisoned entry is to change the key and abandon it. A builder profile has an equivalent lever in the `profile-name` input: point the affected workflow at a second profile and the old disk stays warm for whatever else uses it. The second profile is a second builder VM with its own session billing, so that lever pays off when two pipelines want different base layers anyway. | Symptom | Cheaper fix to try first | Reset when | | ---------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------ | | Base image bumped, old layers still on disk | Pull the new base in the build so the FROM layer and everything after it rebuilds | Repeated bumps have filled the disk with dead layers | | One bad layer cached from a broken build | Rebuild the affected stage with caching off for that build | The bad layer keeps reappearing across images on the profile | | Two pipelines competing for one profile's disk | Move one pipeline to its own profile-name | A second builder session costs more than the rebuild | | Profile disk close to full | Check the profile size against what it holds | Nothing on the disk is worth keeping | `Warpbuilds/build-push-action@v6` is documented as a drop-in replacement for `docker/build-push-action@v6`, so the upstream [build-push-action inputs](https://github.com/docker/build-push-action) for pulling a fresh base and for skipping the cache on a single build apply unchanged. Reach for those first. Reach for the reset when the disk itself is the problem, which is where profile size matters: the 16 vCPU profile carries a 100GB disk, the 32 and 64 vCPU profiles carry 200GB, and the largest amd64 profiles carry 600GB or 2TB. A 100GB profile that serves four images across two base image families runs out of headroom sooner than the size chart suggests. ### [What the next build costs after a reset](#what-the-next-build-costs-after-a-reset)Copy Anchor Link Builders are billed per session, measured from when the builder action starts until the job completes, and concurrent jobs sharing a profile share one session. The job runner and the builder are separate resources and both are billed. Rates from the [pricing page](/pricing), checked on 2026-08-13: | Builder profile | Disk | Architectures | Price per minute | | ------------------- | ----- | ------------------- | ---------------- | | 16 vCPU, 32GB RAM | 100GB | amd64, arm64, multi | $0.06 | | 32 vCPU, 64GB RAM | 200GB | amd64, arm64, multi | $0.12 | | 64 vCPU, 128GB RAM | 200GB | amd64, arm64, multi | $0.24 | | 96 vCPU, 192GB RAM | 600GB | amd64 | $0.36 | | 96 vCPU, 192GB RAM | 2TB | amd64 | $0.52 | | 192 vCPU, 384GB RAM | 600GB | amd64 | $0.72 | | 192 vCPU, 384GB RAM | 2TB | amd64 | $0.88 | The arm64 and multi-architecture profiles top out at 64 vCPU, so the four largest sizes are amd64 only. A multi-architecture build also opens one session per architecture, because each architecture runs on its own builder instance, and each of those instances keeps its own layers, so a multi-architecture profile pays the cold rebuild on each architecture once its cache is cleared. The cost of a reset is one cold build per image on the profile. Take the cold-minus-warm delta from your own job log, call it `D` minutes, and the bill is `D` multiplied by the builder rate plus `D` multiplied by the runner rate. The table below holds `D` at 6 minutes so the rate arithmetic stays visible, with the job running on `warp-ubuntu-latest-x64-4x` at $0.008 per minute: | Builder profile | Builder cost of 6 cold minutes | Runner cost of 6 cold minutes | Total, one image | | ------------------------- | ------------------------------ | ----------------------------- | ---------------- | | 16 vCPU, $0.06 per minute | $0.36 | $0.048 | $0.408 | | 32 vCPU, $0.12 per minute | $0.72 | $0.048 | $0.768 | | 64 vCPU, $0.24 per minute | $1.44 | $0.048 | $1.488 | Multiply by the number of images the profile serves, since each one pays the delta on its next build. A 64 vCPU profile serving five images is around $7.44 of rebuild for one reset on those assumptions, which is small against a weekly build volume and large enough that a nightly reset cron is the wrong instinct. ### [Timing the reset](#timing-the-reset)Copy Anchor Link Multiple jobs can run in parallel on one builder profile, and the cache between concurrent builds is shared but eventually consistent: a layer produced by one in-flight build may not reach another until it syncs. Right after a reset that property costs the most, because several builds started at once all find an empty disk and each rebuilds the same base layers. Run the reset in a quiet window and let one build repopulate the cache before the next wave arrives. There is an automatic version of the same operation. The builder cache has a TTL of 10 days, and a profile that goes unused for longer than 10 days is reset automatically, so a profile that has already been idle that long needs no manual call. When the reset follows a base image change, trigger the rebuild on the new base first so the fresh disk fills with the layers you actually want. The builder runs outside the job on its own VM, so the job only has to reach it over the WarpBuild API. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does resetting the cache delete the builder profile?](#does-resetting-the-cache-delete-the-builder-profile)Copy Anchor Link No. The reset clears the layer cache on the profile's builder VM and leaves the profile in place, so its name, size, and architectures are unchanged and every workflow that names the profile keeps working. Deleting a profile is a separate action in the dashboard, and it requires waiting for in-flight builds to finish first. [WarpBuild remote Docker builders](/runners/docker-builders) lists the profile sizes and what each one holds. ### [Do I have to reset the cache after a base image update?](#do-i-have-to-reset-the-cache-after-a-base-image-update)Copy Anchor Link Usually no. A build that pulls the new base invalidates the FROM layer and every layer after it, so the affected layers rebuild themselves on the next run and the unrelated ones stay warm. Reset when repeated base bumps have left enough dead layers on the profile disk to matter. [Why Docker builds lose cache after a base image update](/answers/why-do-my-docker-builds-lose-cache-after-a-base-image-update) walks through the invalidation chain. ### [What does the first build after a reset cost?](#what-does-the-first-build-after-a-reset-cost)Copy Anchor Link It runs cold, so you pay the cold-minus-warm minute delta once per image, on the builder session and on the job runner. On a 16 vCPU profile at $0.06 per minute, a 6 minute delta is $0.36 on the builder plus $0.048 on a `warp-ubuntu-latest-x64-4x` runner at $0.008 per minute. The [pricing page](/pricing) carries the rate for every profile size, and [Docker builder profiles for GitHub Actions](/guides/docker-builder-profiles-github-actions) covers how many profiles a repository should have. --- # How Do I Retry a Failed Step in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-retry-a-failed-step-in-github-actions Description: GitHub Actions has no per-step retry. Wrap the command in a shell loop with backoff or use a retry action, and only for transient network and registry errors. # How Do I Retry a Failed Step in GitHub Actions? GitHub Actions has no per-step retry. Wrap the command in a shell loop with backoff or use a retry action, and only for transient network and registry errors. ## [Answer](#answer)Copy Anchor Link Retry a failed step in GitHub Actions by wrapping the command in a shell retry loop with backoff inside the `run` block, or by putting a retry action around the command, and scope that retry to the classes of failure that are genuinely transient. GitHub Actions has no per-step retry setting: the [re-run controls](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs) replay a whole run or a whole failed job, and [continue-on-error](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax) only stops a failed step from failing the job. Scope is the part that decides whether the retry helps. A network call to a registry you do not own can succeed on the second attempt. An assertion in a test suite cannot, so retrying it bills the same minutes again and pushes the red result later. | Failure signature | Retry it | Reason | | ------------------------------------------------------------------------ | ----------------- | ------------------------------------------------------------------ | | Connection reset, DNS failure, or TLS timeout against a package registry | Yes, with backoff | The remote side changed between attempts | | HTTP 429 or 503 from npm, PyPI, Maven Central, or a container registry | Yes, with backoff | Rate limits and upstream restarts clear on their own | | toomanyrequests or i/o timeout on a Docker image pull | Yes, with backoff | Pull limits reset; a pull-through cache removes the retry entirely | | Cloud API throttling during a deploy step | Yes, with backoff | The API returns a retryable status by design | | Assertion failure in a test | No | The same input produces the same result | | Compile, type, or lint error | No | Deterministic in the checked-out tree | | Exit code 137 or Killed | No | The machine ran out of memory; size the runner up instead | | no space left on device | No | Disk state carries into the next attempt | | HTTP 401 or 403 from any endpoint | No | A credential problem repeats until the credential changes | The one rule that holds across all of them: retry the step that talks to a machine you do not control, and fix the step that fails on your own code. ## [Detail](#detail)Copy Anchor Link ### [The retry loop written in the step](#the-retry-loop-written-in-the-step)Copy Anchor Link A loop in the `run` block needs no third-party action and gives exact control over the attempt count, the backoff, and the exit code. Copy Text ``` name: publish on: push: tags: ["v*"] jobs: publish: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 registry-url: https://registry.npmjs.org - run: npm ci - run: npm test - name: Publish to registry shell: bash timeout-minutes: 5 env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | set -euo pipefail attempt=1 max_attempts=3 delay=5 until npm publish --access public; do if [ "$attempt" -ge "$max_attempts" ]; then echo "npm publish failed after $attempt attempts" exit 1 fi echo "attempt $attempt failed, retrying in ${delay}s" sleep "$delay" attempt=$((attempt + 1)) delay=$((delay * 2)) done ``` Three details make this safe to leave in a workflow. The attempt cap exits non-zero after the last try, so a permanent failure still turns the job red. The delay doubles from 5 to 10 to 20 seconds, which spreads attempts across a rate-limit window instead of stacking three requests inside 15 seconds. [timeout-minutes](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax) on the step bounds the whole loop, which matters because the job-level default is 360 minutes and every one of those minutes bills. ### [The action alternative](#the-action-alternative)Copy Anchor Link A retry action is shorter and gives a per-attempt timeout, which a plain loop does not. [nick-fields/retry](https://github.com/nick-fields/retry) is the common public choice: Copy Text ``` - name: Publish to registry uses: nick-fields/retry@v3 env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} with: timeout_minutes: 5 max_attempts: 3 retry_wait_seconds: 15 retry_on: error command: npm publish --access public ``` `timeout_minutes` applies to each attempt rather than to the step as a whole, so a hung connection is killed and retried instead of holding the runner. `retry_on: error` retries on a non-zero exit and leaves timeouts alone; `retry_on: timeout` does the reverse. Pin the action to a tag or a commit SHA the same way you pin `actions/checkout`, because the retry wrapper sees the same secrets the command does. ### [What blind retries cost](#what-blind-retries-cost)Copy Anchor Link Wrapping every step in three attempts is where the minutes go. Take a repository with 300 red pull-request runs per month on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, from the [WarpBuild pricing page](/pricing), checked on 2026-08-13. | Setup | Added minutes per red run | Monthly minutes | Monthly cost | | ----------------------------------------------------------------------------------------------- | ------------------------- | --------------- | ------------ | | 3 attempts around a 6 minute test step, 15 second waits | 2 x (6 + 0.25) = 12.5 | 3,750 | $30.00 | | 3 attempts around a 40 second publish step, on the 20 runs per month that hit a transient error | 2 x (0.67 + 0.25) = 1.83 | 37 | $0.29 | | GitHub's re-run failed jobs on a 12 minute job | 12 | 3,600 | $28.80 | The dollar figure is the smaller half of the bill. The blind setup also adds 12.5 minutes to every failure notification, which is 3,750 minutes of waiting per month, about 62 hours of engineer time spent watching a run that was already going to fail. The scoped setup pays 37 minutes for the same protection because it runs only when a registry actually misbehaves. Runner rates differ by platform, and the arithmetic above moves with them. Priced per minute as follows on the same pricing page, checked on 2026-08-13. | Label | vCPU | RAM | Per minute | | --------------------------- | ---- | ----- | ---------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | | warp-windows-latest-x64-4x | 4 | 16 GB | $0.016 | | warp-macos-26-arm64-6x | 6 | 14 GB | $0.08 | Two wasted 6 minute attempts bill $0.096 on `warp-ubuntu-latest-x64-4x` and $0.96 on `warp-macos-26-arm64-6x` at these rates, so scope retries tightest on the expensive platforms. The loop syntax changes across platforms too: the [default shell on Windows runners](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstepsshell) is PowerShell, so either set `shell: bash` on the step or write the loop with `$LASTEXITCODE` and `Start-Sleep`. ### [When the retry is the wrong tool](#when-the-retry-is-the-wrong-tool)Copy Anchor Link Some failures look transient and are not. A job that never picks up a runner, a Dependabot workflow that skips on a public repository, or an Android emulator step that falls back to software emulation all fail in ways a retry loop repeats, and the fixes are in the [WarpBuild common issues documentation](/docs/ci/common-issues). Runner-side timeouts that repeat on the same hostname belong in the [guide to GitHub Actions network timeouts](/guides/github-actions-network-timeouts) rather than behind more attempts. For the ordering question of where retries sit against caching, runner sizing, and job splitting, work through the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions). Retries buy stability, and every attempt they add is a minute the pipeline was already trying to remove. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does GitHub Actions have a built-in retry for a single step?](#does-github-actions-have-a-built-in-retry-for-a-single-step)Copy Anchor Link No. GitHub's [re-run controls](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs) work at the run and job level, so re-running a failed job replays every step in that job from the top. Per-step retry comes from a loop written in the `run` block or from a retry action wrapped around the command, and both are scoped to the one step you name. The ordering of retries against the other levers is covered in the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions). ### [Which failures should I never retry?](#which-failures-should-i-never-retry)Copy Anchor Link Assertion failures, compile and type errors, exit code 137 from memory pressure, `no space left on device`, and 401 or 403 credential errors. Each of those repeats on the next attempt, so the extra attempts bill runner minutes and delay the red result without changing it. Timeouts against an external host are the borderline case, and the [guide to GitHub Actions network timeouts](/guides/github-actions-network-timeouts) separates the ones worth retrying from the ones that need a mirror or a longer client timeout. ### [Do retries hide a flaky test?](#do-retries-hide-a-flaky-test)Copy Anchor Link Yes, when the retry wraps the test step. A suite that fails on attempt one and passes on attempt two reports a green run, which removes the failure from the success-rate ranking that finds unstable jobs. A [flaky test](/glossary/flaky-test) produces different results on the same input, and the ranking is how you find it. Retry the network calls a test makes, then follow the [guide to flaky GitHub Actions jobs](/guides/flaky-github-actions-jobs) for the suite itself. --- # How Do I Reuse a Workflow Across Repositories? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-reuse-a-workflow-across-repositories Description: Publish it once as a reusable workflow with workflow_call, then call it from each repository with uses, a pinned tag, and explicit inputs and secrets. # How Do I Reuse a Workflow Across Repositories? Publish it once as a reusable workflow with workflow\_call, then call it from each repository with uses, a pinned tag, and explicit inputs and secrets. Publish the shared logic once as a reusable workflow: a file in `.github/workflows` on the host repository with `on: workflow_call`, declaring the inputs and secrets it needs. Every other repository then calls it from a job with `uses: OWNER/REPO/.github/workflows/FILE.yml@REF`, passing those inputs and secrets explicitly and pinning `REF` to a tag. ## [Answer](#answer)Copy Anchor Link A reusable workflow has two sides, and both are ordinary workflow files. The called side declares its interface with `workflow_call`. The caller side replaces a job's `steps` with a single `uses` line ([GitHub reusable workflow reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations), checked on 2026-08-13). Here is the called workflow, living in a repository named `acme/ci-workflows`: Copy Text ``` # acme/ci-workflows/.github/workflows/node-build.yml name: node-build on: workflow_call: inputs: runner-label: description: Runner label the calling repository wants this job on type: string default: warp-ubuntu-latest-x64-4x node-version: type: string default: "22" publish: type: boolean default: false secrets: NPM_TOKEN: required: false outputs: artifact-name: description: Name of the uploaded build artifact value: ${{ jobs.build.outputs.artifact-name }} jobs: build: runs-on: ${{ inputs.runner-label }} outputs: artifact-name: ${{ steps.pack.outputs.name }} steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} cache: npm - run: npm ci - run: npm run build - run: npm test - id: pack run: echo "name=dist-${{ github.sha }}" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v4 with: name: dist-${{ github.sha }} path: dist/ - if: ${{ inputs.publish }} run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ``` And here is a caller in a different repository: Copy Text ``` # acme/checkout-service/.github/workflows/ci.yml name: ci on: push: branches: [main] pull_request: jobs: build: uses: acme/ci-workflows/.github/workflows/node-build.yml@v3.2.0 permissions: contents: read with: runner-label: warp-ubuntu-latest-x64-8x node-version: "22" secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} ``` Three rules decide whether that call resolves at all. The called workflow has to be in the same repository, in a public repository, or in a private repository whose Actions access policy names the caller; a private caller can reach private and public workflows, and a public caller can reach public ones only. Cross-repository calls always carry a ref after the `@`. GitHub supports no redirects for reusable workflows, so renaming the host repository breaks every caller until each one is edited ([GitHub reusable workflow reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations), checked on 2026-08-13). Runner selection stays with the caller. GitHub evaluates runner assignment using only the caller's context and bills the caller, and the caller cannot borrow runners from the repository that hosts the workflow file ([GitHub reusable workflow reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations)). For `warp-` labels that means the WarpBuild GitHub bot needs access to each calling repository, granted from the WarpBuild dashboard after signup rather than from the GitHub Marketplace ([quick start documentation](/docs/ci/quick-start)). One shared workflow can serve a Go service and an iOS app by changing the label a caller passes ([cloud runners documentation](/docs/ci/cloud-runners)). ## [Detail](#detail)Copy Anchor Link ### [Pass the runner label as an input](#pass-the-runner-label-as-an-input)Copy Anchor Link A calling job may only use `name`, `uses`, `with`, `secrets`, `strategy`, `needs`, `if`, `concurrency`, and `permissions` ([GitHub reusable workflow reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations), checked on 2026-08-13). `runs-on` is absent from that list, so a caller that needs a bigger machine cannot set one directly. Declaring a `runner-label` input and setting `runs-on: ${{ inputs.runner-label }}` gives the caller the choice back, with the default deciding what every repository that says nothing gets. Rates below come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing). GitHub rates come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | runner-label value | Shape | Per minute | Nearest GitHub-hosted runner | Per minute | | --------------------------- | -------------- | ---------- | ------------------------------------- | ---------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | ubuntu-latest on a private repository | $0.006 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | the 4-core Linux larger runner | $0.012 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | the 8-core Linux larger runner | $0.022 | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | the 16-core Linux larger runner | $0.042 | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | the 4-core Linux ARM64 larger runner | $0.008 | The default is the interesting field once several repositories call the same file. Take twelve services that each burn 2,000 runner minutes a month, 24,000 minutes in total. On the `4x` default that is $192.00 a month. Callers that pass `warp-ubuntu-latest-x64-8x` pay $0.016 for their own minutes without changing anything for the other repositories, and moving the default itself to `8x` moves all twelve to $384.00 on their next run. One tag bump per repository is the throttle on that change. ### [Version by tag rather than by branch](#version-by-tag-rather-than-by-branch)Copy Anchor Link A reusable workflow can be referenced by SHA, release tag, or branch name ([GitHub reusable workflow reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations), checked on 2026-08-13). The reference style decides who absorbs a mistake. | Ref style | What a push to the shared repository does | Rollback | | --------------------- | ------------------------------------------- | ----------------------------------------------------- | | @main | Reaches every caller on its next run | Revert on the shared repository, then wait for reruns | | @v3 moving major tag | Reaches every caller once the tag moves | Move the tag back | | @v3.2.0 immutable tag | Reaches nobody until a caller bumps its ref | Revert one line in one repository | | @a1b2c3d commit SHA | Reaches nobody until a caller bumps its ref | Revert one line in one repository | Tag the shared repository on every change, and have callers pin the full version. A broken step then fails one pull request in one repository rather than every pipeline at once, and Dependabot can raise the bump on a schedule you control. Re-runs behave differently depending on the ref, which is worth knowing before a mixed fleet confuses you. Re-running all jobs in a workflow uses the reusable workflow at the specified reference, while re-running failed jobs or a single job uses the reusable workflow from the same commit SHA as the first attempt ([GitHub reusable workflow reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations)). A branch reference can therefore give two different results for what looks like the same button. ### [The limits that appear at scale](#the-limits-that-appear-at-scale)Copy Anchor Link | Limit | Value | Effect on a shared workflow library | | ---------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | Nesting depth | Up to ten levels of connected workflows | A caller, a language template, and a deploy workflow underneath fit comfortably | | Unique reusable workflows per file | 50, counting the whole nested tree | A matrix that fans out to per-language templates hits this before a plain caller does | | Caller env propagation | Not propagated to the called workflow | Anything the shared file needs arrives as an input, or from organization variables through the vars context | | Token permissions | Same or more restrictive as you descend | A caller granting contents: read blocks any nested workflow from writing packages | All four rows come from the [GitHub reusable workflow reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations), checked on 2026-08-13\. The `env` rule is the one that catches migrations: a workflow that reads `env.REGISTRY` at the top of the caller sees nothing after the logic moves into a shared file, and the fix is an input with a default rather than a copied `env` block. Secrets travel the same explicit path. List each one under `secrets` on both sides, or pass `secrets: inherit` when the caller and the shared workflow live in the same organization and you want the caller's whole secret set available ([GitHub reusable workflow reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations), checked on 2026-08-13). Explicit listing is the version that survives an audit, because the interface then states exactly what the shared file can read. ### [What stays in the caller](#what-stays-in-the-caller)Copy Anchor Link The caller keeps its triggers, its concurrency group, and its required status check names. Two details follow from that. `${{ github.workflow }}` inside a called workflow resolves to the caller's workflow name, so reusing that value as a `concurrency.group` in both files makes a run cancel itself ([GitHub reusable workflow reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations)). And the check name that branch protection matches comes from the caller's job id plus the called workflow's job name, so renaming a job inside the shared file changes the required check in every repository that pins the new tag. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can the caller choose the runner the shared workflow runs on?](#can-the-caller-choose-the-runner-the-shared-workflow-runs-on)Copy Anchor Link Not through `runs-on`, since that keyword is absent from the list of keywords a calling job may use. Declare a `runner-label` input on the called workflow, point `runs-on` at it, and each caller passes the size it wants while the default covers the rest. The [runner catalog and pricing page](/pricing) lists every label and rate, and [running reusable workflows at scale](/guides/reusable-workflows-at-scale-github-actions) covers the rollout across dozens of repositories. ### [Should I call a reusable workflow by branch or by tag?](#should-i-call-a-reusable-workflow-by-branch-or-by-tag)Copy Anchor Link By tag or commit SHA. A branch reference reaches every caller on the next run, so a single push can break the whole fleet, and re-running failed jobs pins to the first attempt's SHA anyway. Tags keep each change to one pull request in one repository. The [reusable workflow glossary entry](/glossary/reusable-workflow) covers the syntax in isolation. ### [When is a composite action the better shape?](#when-is-a-composite-action-the-better-shape)Copy Anchor Link A composite action packages a sequence of steps inside an existing job, so it inherits the runner, the checkout, and the working directory the job already has. A reusable workflow brings its own jobs and its own runner selection, which is what you want when the shared unit is a whole build. [Writing a composite action](/answers/how-do-i-write-a-composite-action) walks through the other half of that choice. Start with one shared workflow, one tag, and one caller, then measure a full pipeline before you fan the pattern out; [speeding up GitHub Actions](/guides/speed-up-github-actions) covers the measurements worth taking first. --- # How Do I Run a Job Only When Files Change? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-run-a-job-only-when-files-change Description: Gate a workflow with a paths filter on the trigger, and use a change detection job feeding job-level if conditions when one workflow serves many projects. # How Do I Run a Job Only When Files Change? Gate a workflow with a paths filter on the trigger, and use a change detection job feeding job-level if conditions when one workflow serves many projects. Two mechanisms cover this, and the right one depends on how many projects a single workflow serves. Put a `paths` filter on the trigger when the workflow belongs to one project, and run a short change detection job whose outputs feed `if` conditions on the other jobs when one workflow serves several. The choice has a consequence beyond tidiness: GitHub documents that a workflow skipped by path filtering leaves its checks in a Pending state that blocks merging, while a job skipped by a conditional reports Success ([troubleshooting required status checks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks#handling-skipped-but-required-checks)). ## [Answer](#answer)Copy Anchor Link ### [Gate the whole workflow with a trigger filter](#gate-the-whole-workflow-with-a-trigger-filter)Copy Anchor Link When every job in the file belongs to one project, the filter goes on the trigger and the run never starts. Copy Text ``` # .github/workflows/api.yml name: api on: pull_request: paths: - 'services/api/**' - 'packages/shared/**' - '!services/api/docs/**' jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - run: bun install --frozen-lockfile - run: bun run test --filter api ``` Four rules from GitHub's [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onpushpull%5Frequestpaths) decide whether that filter behaves the way you expect. You cannot use `paths` and `paths-ignore` for the same event in one workflow, so a mixed include and exclude list uses `paths` with `!` prefixes, as above. Pattern order matters, because a negative pattern after a positive match excludes the path and a later positive match includes it again. Path filters are skipped entirely for pushes of tags. And when `branches` and `paths` are both present, the run needs both to be satisfied. ### [Gate individual jobs with a detection job](#gate-individual-jobs-with-a-detection-job)Copy Anchor Link When one workflow serves several projects, the decision has to happen after the run starts. A first job computes which project directories changed and exposes the result as job outputs; every other job reads those outputs in its `if`. Copy Text ``` # .github/workflows/ci.yml name: ci on: [push, pull_request] jobs: changes: runs-on: warp-ubuntu-latest-x64-2x outputs: api: ${{ steps.filter.outputs.api }} web: ${{ steps.filter.outputs.web }} steps: - uses: actions/checkout@v5 - uses: dorny/paths-filter@v3 id: filter with: filters: | api: - 'services/api/**' - 'packages/shared/**' web: - 'apps/web/**' - 'packages/shared/**' api: needs: changes if: needs.changes.outputs.api == 'true' runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - run: bun run test --filter api web: needs: changes if: needs.changes.outputs.web == 'true' runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - run: bun run test --filter web ``` [dorny/paths-filter](https://github.com/dorny/paths-filter) is the common public action for the detection step. A `git diff --name-only` step against the base ref does the same work, provided the checkout uses `fetch-depth: 0`, because `actions/checkout` fetches a single commit by default and a shallow clone has nothing to diff against. Here is how the two mechanisms differ on the points that usually decide the choice. | Question | paths on the trigger | Detection job plus job-level if | | ---------------------------------------- | -------------------------- | ---------------------------------- | | Granularity of the decision | The whole workflow | One job at a time | | A skipped run appears as | Checks stay Pending | The job reports Success | | Works on schedule and workflow\_dispatch | No | Yes | | Runner minutes spent deciding | None | One short job per run | | Fans a shared package out to dependents | No | Yes, when the filters list it | | Where the rule lives | The on: block of each file | One job that every other job reads | ## [Detail](#detail)Copy Anchor Link ### [The required check trap](#the-required-check-trap)Copy Anchor Link This is the failure that sends teams back to running everything. A branch protection rule requires the check named `test`. Someone adds a `paths` filter to the workflow that produces it. A pull request that touches only documentation now shows `test` as Pending forever, and the merge button stays disabled. GitHub's own guidance on this is to avoid requiring workflows that can be skipped ([handling skipped but required checks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks#handling-skipped-but-required-checks)). | Situation | What the check reports | Effect on a required check | | --------------------------------------- | ---------------------- | -------------------------- | | Workflow skipped by path filtering | Pending | Blocks the merge | | Job skipped by an if condition | Success | Satisfies the rule | | Job skipped because a needed job failed | Skipped | May not block the merge | The third row is why the detection pattern usually ends with an aggregate gate. Point the branch rule at one job that always runs and inspects the results of the others. Copy Text ``` required-checks: if: always() needs: [changes, api, web] runs-on: warp-ubuntu-latest-x64-2x steps: - name: Fail when a needed job failed or was cancelled if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: exit 1 ``` Branch protection then requires `required-checks` and nothing else, so adding or removing a project job never means editing the rule. Renaming jobs stays cheap, which matters in a repository where the job list grows with the project list. ### [What the gating saves in a monorepo](#what-the-gating-saves-in-a-monorepo)Copy Anchor Link Skipped jobs consume no runner minutes, and pricing is purely usage based. That makes the saving a straight subtraction once you know how often each project is touched. Assumptions for the worked example, so you can substitute your own: a repository with four projects, 900 pull request pushes per month, project jobs on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, and a detection job of 30 seconds on `warp-ubuntu-latest-x64-2x` at $0.004 per minute. Rates are from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13\. The touch rates below are the share of pushes that change each project. | Project | Job length | Touch rate | Ungated minutes | Gated minutes | | ------------- | ---------- | -------------------- | --------------- | ------------- | | api | 12 min | 40 percent of pushes | 10,800 | 4,320 | | web | 9 min | 45 percent of pushes | 8,100 | 3,645 | | workers | 7 min | 20 percent of pushes | 6,300 | 1,260 | | docs | 3 min | 15 percent of pushes | 2,700 | 405 | | Detection job | 0.5 min | every push | 0 | 450 | | Line | Monthly minutes | Rate | Monthly cost | | --------------------------- | --------------- | ----------------- | ------------ | | Every project on every push | 27,900 | $0.008 per minute | $223.20 | | Gated project jobs | 9,630 | $0.008 per minute | $77.04 | | Detection job | 450 | $0.004 per minute | $1.80 | | Gated total | 10,080 | $78.84 | | Gating removes 17,820 runner minutes and $144.36 per month at these assumptions, and it shortens the critical path of the average pull request by more than it shortens the bill, because the jobs that disappear were running in parallel with the ones that remain. The [Jobs section of the reports page](/docs/ci/features/reports) gives you the two inputs the model needs from your own repository rather than from an assumption: run count and duration percentiles for each unique repository, workflow, and job name combination, with CSV export for the arithmetic. Substitute your P75 durations and your real touch rates before deciding whether the detection job earns its place. The pattern itself is platform independent. The `if` condition on a job is evaluated by GitHub before any runner is assigned, so a skipped macOS job costs the same as a skipped Linux one. The [guide to speeding up GitHub Actions](/guides/speed-up-github-actions) covers the levers that apply to the jobs that do run. ### [Where path filters get the answer wrong](#where-path-filters-get-the-answer-wrong)Copy Anchor Link GitHub builds the changed-file list with [two-dot diffs for pushes and three-dot diffs for pull requests](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onpushpull%5Frequestpaths), and it publishes three limits that override your filter. A push containing more than 1,000 commits always runs the workflow. A diff that times out always runs the workflow. And a diff with more than 3,000 files skips the workflow when the matching files fall outside the first 3,000 returned. Large merges and long-lived branches hit all three, so a repository that gates on paths should expect occasional full runs and size its concurrency for them. The harder failure is silent. A glob matches paths and knows nothing about imports, so a change to `packages/shared` skips every project that depends on it unless each filter lists that path explicitly, which is what the `filters` block above does by repeating `packages/shared/**` under both `api` and `web`. That hand-maintained mapping drifts the first time someone adds an import without touching the workflow file. Deriving the affected set from the build graph removes the drift, which is the subject of [test impact analysis on GitHub Actions](/guides/test-impact-analysis-github-actions). [Monorepo pipelines on GitHub Actions](/solutions/monorepo-github-actions) covers how the surviving jobs get sized and sharded once the gating is in place. ## [Related Questions](#related-questions)Copy Anchor Link ### [Why is my pull request stuck on a pending check after I added a paths filter?](#why-is-my-pull-request-stuck-on-a-pending-check-after-i-added-a-paths-filter)Copy Anchor Link Because the workflow never ran. GitHub documents that a workflow skipped by path filtering leaves the checks associated with it in a Pending state, and a pull request that requires those checks is blocked from merging. A job skipped by an `if` condition reports Success instead, so moving the gate from the trigger to the job level clears the block. The aggregate `required-checks` job above is the version of that pattern which also survives a failed dependency. ### [Do path filters work on schedule or workflow\_dispatch triggers?](#do-path-filters-work-on-schedule-or-workflow%5Fdispatch-triggers)Copy Anchor Link No. The `paths` and `paths-ignore` filters apply to `push`, `pull_request`, and `pull_request_target`, and GitHub documents that path filters are not evaluated for pushes of tags either. A workflow that also runs on a schedule or on manual dispatch has to do its gating inside the jobs, because the trigger filter has nothing to compare against on those events. The detection job pattern handles every trigger, at the cost of one short job per run. ### [How do I make a change in a shared package rebuild everything that depends on it?](#how-do-i-make-a-change-in-a-shared-package-rebuild-everything-that-depends-on-it)Copy Anchor Link A glob pattern knows file paths and nothing about the dependency graph, so a change to a shared package skips the projects that import it unless you say otherwise. Either add the shared path to every dependent filter by hand, which stays accurate until someone adds an import, or compute the affected set from the build graph and feed that list into the matrix. [Run only affected tests on GitHub Actions](/guides/test-impact-analysis-github-actions) works through the second approach, and [what a monorepo is](/glossary/monorepo) covers the repository shape where the question comes up. --- # How Do I Run a Workflow Manually? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-run-a-workflow-manually Description: Add a workflow_dispatch trigger with typed inputs, then start the run from the Actions tab or with one API call. Branch rules and a working example below. # How Do I Run a Workflow Manually? Add a workflow\_dispatch trigger with typed inputs, then start the run from the Actions tab or with one API call. Branch rules and a working example below. ## [Answer](#answer)Copy Anchor Link Add a `workflow_dispatch` trigger to the workflow file, declare the inputs it needs with types, and the run starts either from the Run workflow button on the Actions tab or from a single API call. The trigger is documented on the [events that trigger workflows reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows) (checked on 2026-08-13), and the one rule that catches most people is that the definition has to exist on the branch you run it from. Copy Text ``` name: release run-name: release ${{ inputs.version }} to ${{ inputs.environment }} on: workflow_dispatch: inputs: environment: description: Target environment type: choice required: true default: staging options: - staging - production version: description: Tag to release, for example v2.4.1 type: string required: true runner: description: Runner label for the build job type: choice required: true default: warp-ubuntu-latest-x64-8x options: - warp-ubuntu-latest-x64-4x - warp-ubuntu-latest-x64-8x - warp-ubuntu-latest-x64-16x dry_run: description: Build and test without publishing type: boolean default: true jobs: build: runs-on: ${{ inputs.runner }} steps: - uses: actions/checkout@v5 with: ref: ${{ inputs.version }} - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run build - run: npm test publish: needs: build if: ${{ !inputs.dry_run }} runs-on: warp-ubuntu-latest-x64-4x environment: ${{ inputs.environment }} steps: - uses: actions/checkout@v5 - run: ./scripts/publish.sh ${{ inputs.version }} ``` Merge that to the default branch and the Actions tab shows a Run workflow button with a branch picker and one form field per input. ## [Detail](#detail)Copy Anchor Link ### [The five input types and how the two contexts differ](#the-five-input-types-and-how-the-two-contexts-differ)Copy Anchor Link `workflow_dispatch` accepts up to 10 top level inputs, and each one declares a type. Every row below comes from the [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax) and the [contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts), checked on 2026-08-13. | Type | Form control | inputs. returns | github.event.inputs. returns | | ----------- | ----------------------------------- | --------------------- | ---------------------------------- | | string | Text box | String | String | | choice | Dropdown built from options | String | String | | boolean | Checkbox | Boolean | "true" or "false" as a string | | number | Text box | Number | String | | environment | Dropdown of repository environments | String | String | That last column is the reason the example uses `if: ${{ !inputs.dry_run }}` rather than the `github.event.inputs` form. Under `github.event.inputs`, an unchecked box arrives as the string `"false"`, which is truthy, so the publish job would run on every dispatch. Read booleans through the `inputs` context and the condition behaves the way the checkbox looks. Two more constraints worth knowing before you design the form. A `choice` input needs an `options` list, and its `default` has to be one of those options or the run fails validation. Inputs marked `required: true` block the form until they are filled, while the API rejects the call outright when one is missing. ### [Starting the run from the API](#starting-the-run-from-the-api)Copy Anchor Link The REST endpoint takes the workflow file name in the path and the branch or tag in the body ([workflows REST API](https://docs.github.com/en/rest/actions/workflows), checked on 2026-08-13): Copy Text ``` curl -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer $GH_TOKEN" \ -H "X-GitHub-Api-Version: 2022-11-28" \ https://api.github.com/repos/OWNER/REPO/actions/workflows/release.yml/dispatches \ -d '{"ref":"main","inputs":{"environment":"production","version":"v2.4.1","runner":"warp-ubuntu-latest-x64-16x","dry_run":"false"}}' ``` Four practical notes. The token needs Actions write access. Input values travel as strings and GitHub coerces them to the declared type. A successful call returns 204 No Content and no run id, so the run has to be located afterwards. And a dispatch sent with the built in `GITHUB_TOKEN` does create a run, because `workflow_dispatch` and `repository_dispatch` are the two exceptions to the recursion guard described in the [automatic token authentication documentation](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication). The same call from the GitHub CLI, which is easier to keep in a release script: Copy Text ``` gh workflow run release.yml --ref main \ -f environment=production \ -f version=v2.4.1 \ -f runner=warp-ubuntu-latest-x64-16x \ -f dry_run=false gh run list --workflow release.yml --event workflow_dispatch \ --branch main --limit 1 --json databaseId,displayTitle,status ``` The `run-name` line in the workflow is what makes that second command useful. Each dispatch gets a title containing its inputs, so the list distinguishes a staging dry run from a production release without opening either. ### [The branch rule](#the-branch-rule)Copy Anchor Link The trigger has to exist in the workflow file on the ref you are dispatching, and the Actions tab reads its workflow list from the default branch ([events that trigger workflows reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13). Three consequences follow. * A `workflow_dispatch` trigger that lives only on a feature branch produces no Run workflow button anywhere. Merge it to the default branch first. * Once the button exists, the branch dropdown covers any branch or tag, and the run executes that ref's copy of the file. Editing steps on a branch changes what a dispatch of that branch does. * Dispatching a ref whose copy of the file has no `workflow_dispatch` trigger returns HTTP 422 with the message that the workflow does not have a `workflow_dispatch` trigger ([workflows REST API](https://docs.github.com/en/rest/actions/workflows)). The fix is a merge, not a retry. The [workflow\_dispatch glossary entry](/glossary/workflow-dispatch) covers the event itself, and [running jobs on a schedule](/answers/how-do-i-run-jobs-on-a-schedule) covers the `schedule` trigger, which carries the same default branch rule. ### [What a manual run costs](#what-a-manual-run-costs)Copy Anchor Link Manual runs are usually the long ones: releases, migrations, load tests, full matrix reruns. Making the runner label an input prices them one dispatch at a time, since the same workflow can take a small machine for a routine release and a large one when a fix is waiting. Rates below come from the [cloud runners catalog](/docs/ci/cloud-runners) and the [pricing page](/pricing), and the GitHub rate from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), all checked on 2026-08-13. | Label chosen at dispatch time | Shape | Per minute | 20 minute release run | 12 runs a month | | --------------------------------------------- | -------------- | ---------- | --------------------- | --------------- | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | $0.16 | $1.92 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $0.32 | $3.84 | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | $0.64 | $7.68 | | the 8-core Linux larger runner, GitHub-hosted | 8 vCPU, 32 GB | $0.022 | $0.44 | $5.28 | At the 8 vCPU shape, `warp-ubuntu-latest-x64-8x` costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner: 27 percent lower list price, GitHub list price checked on 2026-08-13. A manual release that builds a Mac artifact and a Linux artifact can put both labels in the same dispatch form. Setup is the account, the GitHub bot from the dashboard, and the label ([quick start documentation](/docs/ci/quick-start)). ## [Related Questions](#related-questions)Copy Anchor Link ### [Why is the Run workflow button missing from the Actions tab?](#why-is-the-run-workflow-button-missing-from-the-actions-tab)Copy Anchor Link The workflow file carrying the trigger is not on the default branch. GitHub builds the Actions tab list from the default branch ([events that trigger workflows reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows)), so a `workflow_dispatch` block added on a feature branch stays invisible until it merges. After the merge, the branch dropdown on the button lets you dispatch the feature branch and run that branch's version of the file. ### [Can I start a run from a script or another workflow?](#can-i-start-a-run-from-a-script-or-another-workflow)Copy Anchor Link Yes. POST to `/repos/OWNER/REPO/actions/workflows/release.yml/dispatches` with a `ref` and an `inputs` object, using a token with Actions write access ([workflows REST API](https://docs.github.com/en/rest/actions/workflows)). The WarpBuild automation API exposes runner-image and custom-runner operations, so a script can prepare the runner and then dispatch the workflow that exercises it ([automation documentation](/docs/ci/api-keys/automation)). ### [Can a manual run choose its own runner size?](#can-a-manual-run-choose-its-own-runner-size)Copy Anchor Link Yes. Declare a `choice` input listing the labels you allow and set `runs-on` to that input, as in the example above. The dropdown becomes the size control, which keeps a routine release on `warp-ubuntu-latest-x64-4x` at $0.008 per minute and lets a release under time pressure take `warp-ubuntu-latest-x64-16x` at $0.032 per minute ([pricing page](/pricing), checked on 2026-08-13). More ways to shorten those runs are in [speeding up GitHub Actions](/guides/speed-up-github-actions). ### [How do I find the run the API just started?](#how-do-i-find-the-run-the-api-just-started)Copy Anchor Link The dispatch response is 204 No Content with no body, so nothing comes back to poll on directly. Put the inputs into `run-name`, then list runs filtered by `event=workflow_dispatch` and the branch you dispatched and take the newest `databaseId` ([workflow runs REST API](https://docs.github.com/en/rest/actions/workflow-runs)). Scheduled equivalents of the same job are covered in [nightly builds in GitHub Actions](/guides/nightly-builds-github-actions). --- # How Do I Run ARM64 Integration Tests? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-run-arm64-integration-tests-in-github-actions Description: Run the test job on a native ARM64 runner so the service under test and harness share the target architecture. warp-ubuntu-latest-arm64-8x is $0.012 a minute. # How Do I Run ARM64 Integration Tests? Run the test job on a native ARM64 runner so the service under test and harness share the target architecture. warp-ubuntu-latest-arm64-8x is $0.012 a minute. Last verified: 2026-08-13 Run the integration test job on a native ARM64 runner so the service under test, its service containers, and the test harness all execute on aarch64\. Point `runs-on` at a Linux ARM64 label such as `warp-ubuntu-latest-arm64-8x`, which carries 8 vCPU and 32 GB at $0.012 per minute ([WarpBuild cloud runners](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link An integration test is only meaningful when the binaries under test are the binaries you ship. On an emulated host the application code runs through instruction translation while the service containers, the language runtime, and the kernel behave like the x86-64 host underneath, so the run tells you about the emulator as much as about your build. A native ARM64 runner removes that gap: the kernel is aarch64, the container runtime pulls `linux/arm64` images, and the assertions execute against the same instruction set as your production hosts. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the Linux ARM64 line runs from 2 vCPU to 32 vCPU across Ubuntu 24.04 and Ubuntu 26.04 ([Linux ARM64 runner catalog](/runners/linux-arm64)). Service containers work the same way they do on x86-64: GitHub Actions starts each entry under `services` as a Docker container on the runner and maps its ports into the job ([GitHub service containers documentation](https://docs.github.com/en/actions/using-containerized-services/about-service-containers)). Copy Text ``` name: integration-tests-arm64 on: pull_request: push: branches: [main] jobs: integration: runs-on: warp-ubuntu-latest-arm64-8x timeout-minutes: 20 services: postgres: image: postgres:17 env: POSTGRES_PASSWORD: testpw POSTGRES_DB: app_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10 redis: image: redis:7 ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 5s --health-retries 10 env: DATABASE_URL: postgres://postgres:testpw@localhost:5432/app_test REDIS_URL: redis://localhost:6379 steps: - uses: actions/checkout@v4 - name: Confirm the architecture of the job and the services run: | uname -m docker exec "$(docker ps -qf name=postgres)" uname -m - uses: actions/setup-node@v4 with: node-version: 22 - uses: WarpBuilds/cache@v1 with: path: ~/.npm key: npm-arm64-${{ hashFiles('package-lock.json') }} - run: npm ci - name: Run migrations run: npm run db:migrate - name: Integration suite run: npm run test:integration -- --workers=4 - uses: actions/upload-artifact@v4 if: always() with: name: integration-logs-arm64 path: test-results/ ``` Both `uname -m` calls print `aarch64`. Cache is enabled by default on Linux runners including the ARM64 line, and `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` ([caching documentation](/docs/ci/features/caching)). ## [Detail](#detail)Copy Anchor Link ### [The two failure classes emulation hides](#the-two-failure-classes-emulation-hides)Copy Anchor Link **Architecture-specific race conditions.** aarch64 has a weaker memory model than x86-64, so a load or store that a compiler is free to reorder on ARM stays ordered on an x86-64 host. Code that omits an acquire or release barrier can pass every run on the x86-64 machine and fail on real ARM hardware ([Linux kernel memory-barriers documentation](https://github.com/torvalds/linux/blob/master/Documentation/memory-barriers.txt)). QEMU user-mode emulation, which `docker/setup-qemu-action` registers through binfmt handlers ([docker/setup-qemu-action](https://github.com/docker/setup-qemu-action)), translates guest instructions on that same x86-64 host, so the ordering guarantee your code accidentally depends on is still there. Concurrency bugs in connection pools, cache invalidation, and lock-free queues are the ones that surface first on native hardware. **Native module load failures.** Anything with a compiled extension resolves per architecture at install time. Python wheels ship as separate `manylinux` builds per architecture and an aarch64 wheel may be missing where an x86-64 wheel exists, which sends pip to a source build that needs system headers ([pypa/manylinux](https://github.com/pypa/manylinux)). Node packages with prebuilt binaries fall back to a `node-gyp` compile when no aarch64 prebuild is published ([nodejs/node-gyp](https://github.com/nodejs/node-gyp)). Under emulation your install step often resolves the x86-64 artifact and never exercises the aarch64 path, so the failure lands in production instead of in the test job. A third, quieter class sits alongside them: hardware probes. Core counts, CPU feature flags, and page size read from the host under emulation, so any code that sizes a thread pool or picks a SIMD path from those values takes an untested branch. ### [Picking the machine for the test job](#picking-the-machine-for-the-test-job)Copy Anchor Link Integration jobs share one runner between the harness and the service containers, so size for both. A workable rule is one vCPU per parallel test worker plus one per service, then round up to the next label. Every rate below comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Runner label | vCPU | RAM | Per minute | Fits | | ---------------------------- | ---- | ------ | ---------- | ------------------------------------------ | | warp-ubuntu-latest-arm64-2x | 2 | 8 GB | $0.003 | one service, serial suite | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | one database service, two workers | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | database plus cache or queue, four workers | | warp-ubuntu-latest-arm64-16x | 16 | 64 GB | $0.024 | a compose-sized topology, eight workers | | warp-ubuntu-latest-arm64-32x | 32 | 128 GB | $0.048 | large fixtures, sixteen workers | Storage is 150GB SSD on every size and is deleted when the runner terminates, so database dumps and coverage output go to an artifact or a cache. The `latest` labels track Ubuntu 24.04; pin `warp-ubuntu-2404-arm64-8x` or `warp-ubuntu-2604-arm64-8x` when you want the release fixed. ### [What the test job costs](#what-the-test-job-costs)Copy Anchor Link `warp-ubuntu-latest-arm64-8x` costs $0.012 per minute for 8 vCPU and 32 GB, against $0.014 per minute for the 8-core Linux ARM64 larger runner at the same 8 vCPU and 32 GB shape: 14 percent lower list price ([GitHub billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). A suite that runs on every pull request and every merge, 1,200 times a month at 8 minutes a run, is 9,600 minutes: * `warp-ubuntu-latest-arm64-8x` at $0.012: **$115.20 per month**. * GitHub 8-core Linux ARM64 larger runner at $0.014: **$134.40 per month**. * Difference: $19.20 a month, $230.40 over twelve months. The same 9,600 minutes on `warp-ubuntu-latest-x64-8x` at $0.016 per minute is $153.60, so moving the job to ARM64 at identical vCPU and RAM changes the line by $38.40 a month. Per-size arithmetic for every label is on the [pricing page](/pricing). ### [Practical setup notes](#practical-setup-notes)Copy Anchor Link Service images need an aarch64 manifest. The official Postgres, MySQL, Redis, and RabbitMQ images publish `linux/arm64` alongside `linux/amd64`, and the supported architectures for each official image are listed in its library file ([docker-library/official-images](https://github.com/docker-library/official-images/blob/master/library/postgres)). For any other tag, run `docker manifest inspect :` once and check for `linux/arm64` before you pin it. Containers started from inside the job, including [Testcontainers](https://github.com/testcontainers) suites, follow the same rule. Ubuntu 24.04 ARM64 runners set the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/`, so replace hardcoded paths with `${{ github.workspace }}` and `$GITHUB_WORKSPACE` before the first run ([cloud runners documentation](/docs/ci/cloud-runners)). Jobs that need `/dev/kvm`, such as Android emulator tests, stay on an x86-64 runner with the `nested-virtualization.enabled=true` label. Two parts of the wider product surface earn their keep on a long integration job. Snapshot runners capture a runner mid-workflow and boot later jobs from that snapshot, and they support Linux ARM64, which suits suites that spend minutes seeding a database before the first assertion ([snapshot runners documentation](https://www.warpbuild.com/docs/ci/features/snapshot-runners)). The Action Debugger opens a shell on the running job, which is the fastest route through an aarch64-only failure you cannot reproduce locally. ## [Related Questions](#related-questions)Copy Anchor Link **Do service containers work on ARM64 runners?** Yes, as long as the image publishes a `linux/arm64` manifest. The official Postgres, MySQL, Redis, and RabbitMQ images do. Run `docker manifest inspect` on any other tag before you pin it, because a single-arch tag fails to start on an aarch64 runner. The [service containers guide](/guides/service-containers-github-actions) covers health checks and port mapping in more depth. **Which ARM64 size should an integration suite run on?** Budget one vCPU for each parallel test worker plus one for each service container, then pick the next size up. A suite with four workers and two services fits `warp-ubuntu-latest-arm64-8x` at $0.012 per minute. The full ladder from 2 vCPU to 32 vCPU is in the [Linux ARM64 runner catalog](/runners/linux-arm64). **Can I keep unit tests on x64 and run integration tests on ARM64?** Yes. `runs-on` is set per job, so one workflow can send the unit test job to `warp-ubuntu-latest-x64-4x` and the integration job to `warp-ubuntu-latest-arm64-8x`, with artifacts passed between them. [Running ARM64 and x64 jobs in one workflow](/answers/can-i-run-arm64-and-x64-jobs-in-the-same-workflow) walks through the matrix and `needs` shapes. Start with the labels in the [Linux ARM64 runner catalog](/runners/linux-arm64), price the change against your own job minutes on the [pricing page](/pricing), and keep dependency restore fast with the [caching documentation](/docs/ci/features/caching). --- # How Do I Run Browser Tests Headless in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-run-browser-tests-headless-in-github-actions Description: Run browser tests headless in GitHub Actions by installing the browser system libraries, restoring the browser cache, and sizing worker count to runner memory. # How Do I Run Browser Tests Headless in GitHub Actions? Run browser tests headless in GitHub Actions by installing the browser system libraries, restoring the browser cache, and sizing worker count to runner memory. Last verified: 2026-08-13 Run browser tests headless in GitHub Actions by launching the browser in its headless mode with the system libraries it links against already installed, then giving the job enough memory for the worker count you set. Browser workers are memory hungry, so the runner size decides how many can run at once, and a job that fits four workers into a 4 vCPU machine with no memory headroom is the job that dies mid-suite with a closed target. ## [Answer](#answer)Copy Anchor Link Three things have to be true before a headless run is reliable. The browser has to be in headless mode. Playwright runs headless by default and only opens a window when `headless: false` or `--headed` is set, so the usual work here is making sure nobody left a headed override in the config. Tools that still require a display, such as some Electron-based runners, work under `xvfb-run` and need no other change. The system libraries have to be present. A browser binary links against fonts, audio, graphics, and X client libraries that a plain runner image does not carry for every browser build. `npx playwright install --with-deps chromium` installs both the browser and its apt dependencies, and the [Playwright continuous integration docs](https://playwright.dev/docs/ci) document the same step for every supported distribution. The job has to have memory for the workers. Each worker is a separate process driving its own browser, and a headless browser holds roughly the same memory as a headed one. This is the constraint most teams get wrong, because CPU shows up as a slow suite while memory shows up as a crash. Here is the memory model against the Linux x64 ladder. Reserve about 4 GB for the operating system, the Node process, and any service the tests talk to inside the job, then divide what is left by the per-worker footprint. Measure your own footprint before trusting the number; the table below uses 1.5 GB per worker as a conservative placeholder. | Runner label | vCPU | Memory | Left after a 4 GB reserve | Workers at 1.5 GB each | Cap at 0.75 workers per vCPU | Setting | | -------------------------- | ---- | ------ | ------------------------- | ---------------------- | ---------------------------- | ------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 4GB | 2 | 1 | \--workers=1 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 12GB | 8 | 3 | \--workers=3 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 28GB | 18 | 6 | \--workers=6 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 60GB | 40 | 12 | \--workers=12 | Shapes and storage come from the [cloud runners documentation](/docs/ci/cloud-runners), where every Linux size carries a 150GB SSD. Browser suites belong on Linux x64 because the browser builds and their system libraries are best supported there. Read the table as two columns competing for the smaller answer. At 1.5 GB per worker, CPU binds first at every row, which is the shape you want. Raise the footprint to 4 GB for tests that drive a heavy single-page application and the 4 vCPU row drops to 3 workers, where memory and CPU bind together and the next size up becomes the honest answer. ## [Detail](#detail)Copy Anchor Link ### [A workflow that installs, restores, and sizes](#a-workflow-that-installs-restores-and-sizes)Copy Anchor Link This job installs the system libraries, restores the browser binaries from cache, installs the fonts the application needs, and sets a worker count that matches the 8 vCPU label it runs on. Copy Text ``` name: browser-tests on: pull_request: jobs: headless: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci - name: Read the Playwright version id: pw run: | echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" - name: Restore browser binaries id: browsers uses: WarpBuilds/cache@v1 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-browsers-${{ steps.pw.outputs.version }} - name: Install browsers and system libraries if: steps.browsers.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium - name: Install system libraries only if: steps.browsers.outputs.cache-hit == 'true' run: npx playwright install-deps chromium - name: Install the fonts the application renders with run: | sudo apt-get update sudo apt-get install -y fonts-liberation fonts-noto-color-emoji - run: npx playwright test --workers=6 ``` Two details carry the run. The cache key hashes the installed Playwright version because browser builds are pinned to the release, and a cache hit still needs `install-deps` because the apt packages live in system directories outside `~/.cache/ms-playwright`. [WarpBuilds/cache](https://github.com/WarpBuilds/cache) is a drop-in replacement for `actions/cache@v4` and is enabled by default on WarpBuild runners, and the [caching documentation](/docs/ci/features/caching) covers the scoping rules: entries are keyed by key, version, and branch, and expire 7 days after last use. The key-by-version pattern is worked through in full on [caching Playwright browsers in GitHub Actions](/answers/how-do-i-cache-playwright-browsers-in-github-actions). One container-only caveat. Headless Chromium uses shared memory heavily, and a container with the default 64 MB `/dev/shm` crashes tabs under load. On a runner virtual machine `/dev/shm` is sized from RAM, so the flag `--disable-dev-shm-usage` belongs only in jobs that run the browser inside a container. ### [The two flaky sources in headless runs](#the-two-flaky-sources-in-headless-runs)Copy Anchor Link **Viewport differences.** A headed browser inherits the size of whatever window you had open, while a headless run takes the tool default, and Playwright's default viewport is 1280 by 720 for every project unless the [viewport option](https://playwright.dev/docs/api/class-testoptions#test-options-viewport) is set. A layout that breaks below 1280 pixels never appears on a maximized laptop and fails on every GitHub Actions run, and a test that scrolls to an element off the bottom of a 720 pixel viewport fails the same way. Set `viewport` explicitly per project, and set `deviceScaleFactor` too when screenshots are compared, so the local run and the GitHub Actions run describe the same window. **Missing fonts.** The Linux x64 runner images carry the same tooling as GitHub-hosted runners, and the installed software lists for those images are published per release in the [actions/runner-images repository](https://github.com/actions/runner-images). Any font outside that list falls back to whatever fontconfig picks, which moves text metrics by a few pixels, changes where a word wraps, and fails a screenshot comparison or a click at a computed coordinate. Emoji are the common case, since a run without an emoji font renders empty boxes. Install the exact font packages in the workflow, as the job above does, or vendor the font files into the repository and point fontconfig at them. ### [What the size costs](#what-the-size-costs)Copy Anchor Link Sizing for memory usually means moving up a label, so price the move before making it. Take a browser suite that runs 600 times a month as one job of 12 minutes. On `warp-ubuntu-latest-x64-16x` that is 7,200 minutes at $0.032 per minute, or $230.40 a month. The same minutes on the 16-core Linux larger runner at $0.042 per minute come to $302.40, a difference of $72.00. Stated as list-price arithmetic: `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13 at the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The [guide to sizing runners for Node test suites](/guides/sizing-runners-for-node-test-suites) covers the same arithmetic for the rest of a Node pipeline. [Observability](/docs/ci/features/observability) reports max memory utilization per job, which turns the placeholder in the table above into your number. The full sharding, reporting, and bottleneck playbook lives on [Playwright test suites on GitHub Actions](/solutions/playwright-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link ### [Do I need xvfb to run browser tests in GitHub Actions?](#do-i-need-xvfb-to-run-browser-tests-in-github-actions)Copy Anchor Link Headless Chromium and Firefox builds render without an X server, so xvfb is only needed by tools that still demand a display, such as Electron-based test runners. Wrap the command in `xvfb-run` for those, and leave it out everywhere else. What every browser does need is the apt dependency set, which `npx playwright install --with-deps` installs per the [Playwright continuous integration docs](https://playwright.dev/docs/ci). ### [How many browser workers fit on a GitHub Actions runner?](#how-many-browser-workers-fit-on-a-github-actions-runner)Copy Anchor Link Subtract a 4 GB reserve from runner memory, divide by the per-worker footprint you measured, then cap the result at about 0.75 workers per vCPU. On `warp-ubuntu-latest-x64-8x` with 8 vCPU and 32 GB that lands at 6 workers. Sizes and memory for every label are in the [cloud runners documentation](/docs/ci/cloud-runners), and the sharding side of the decision is on [Playwright test suites on GitHub Actions](/solutions/playwright-github-actions). ### [Why do headless browser tests pass locally and fail in GitHub Actions?](#why-do-headless-browser-tests-pass-locally-and-fail-in-github-actions)Copy Anchor Link Viewport and fonts are the two usual causes. The headless default viewport differs from the desktop window you test against locally, and the runner image ships a limited font set, so text metrics shift and screenshot comparisons fail. Pin the viewport in the config and install the font packages in the workflow. A third cause worth ruling out early is a cold browser cache adding a download to every job, which [caching Playwright browsers in GitHub Actions](/answers/how-do-i-cache-playwright-browsers-in-github-actions) removes. --- # How Do I Run Database Services in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-run-database-services-in-github-actions Description: Declare the database as a service container, gate the first step on its health check, and size the runner for the database and test workers together. # How Do I Run Database Services in GitHub Actions? Declare the database as a service container, gate the first step on its health check, and size the runner for the database and test workers together. Last verified: 2026-08-13 Run a database in GitHub Actions by declaring it as a service container under the job's `services` key, gating the first step on the container health check, and sizing the runner so the database engine and the test workers both fit in memory. GitHub [creates a fresh Docker container for each service and destroys it when the job completes](https://docs.github.com/en/actions/tutorials/use-containerized-services/use-docker-service-containers), so the database starts empty on every run and the migration and seed time is charged on every run. ## [Answer](#answer)Copy Anchor Link A service container is the supported way to attach Postgres, MySQL, Redis, or a message broker to a job. GitHub's documentation sets three constraints that decide most of the configuration. The first is the platform. Jobs that use service containers, job containers, or Docker container actions [must run on a Linux runner](https://docs.github.com/en/actions/tutorials/use-containerized-services/use-docker-service-containers). This workload belongs on the Linux labels in the [cloud runners documentation](/docs/ci/cloud-runners). The second is networking. A job that runs directly on the runner machine reaches a service at `localhost:` and has to publish the container port with the `ports` keyword. A job that sets the `container` key joins the same user-defined bridge network, reaches the service at its service label as a hostname, and skips port mapping entirely. Both forms are documented in the [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax). The third is readiness. The container image starts before the database accepts connections, so the `options` block carries the health check that makes the runner wait. Here is the shape that covers most test suites, running directly on the runner machine with two services and the connection variables in job-level `env`. Copy Text ``` name: integration-tests on: pull_request: branches: [main] jobs: tests: runs-on: warp-ubuntu-latest-x64-4x services: postgres: image: postgres:16 env: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app_test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 redis: image: redis:7 ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 env: DATABASE_URL: postgres://app:app@localhost:5432/app_test REDIS_URL: redis://localhost:6379 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run db:migrate - run: npm run db:seed - run: npm test ``` Move that job to a container by adding `container: node:22-bookworm-slim`, deleting both `ports` blocks, and changing the host in `DATABASE_URL` from `localhost` to `postgres`. ## [Detail](#detail)Copy Anchor Link ### [Gate on the health check instead of a sleep](#gate-on-the-health-check-instead-of-a-sleep)Copy Anchor Link A `sleep 15` before the migration step fails in two directions. It adds fifteen billed seconds to every green run, and it still loses the race on the run where the image pull ran long. The health options solve both cases: the runner holds the first step until the container reports healthy, then starts immediately. Match the probe to the engine. `pg_isready` for Postgres, `mysqladmin ping` for MySQL, `redis-cli ping` for Redis. A probe that only checks the TCP port answers before the engine has finished recovery on startup, which produces the same connection errors a `sleep` produces. Retries multiply against the interval. The block above allows 5 retries at a 10 second interval, so a service that takes longer than about 50 seconds to come up fails the job rather than hanging it. Raise `--health-retries` for engines that replay a write-ahead log at boot. ### [Size the runner for the database and the test workers together](#size-the-runner-for-the-database-and-the-test-workers-together)Copy Anchor Link The common sizing mistake is budgeting for the test process alone. The database engine, its page cache, any broker or search node, and every parallel test worker share one machine. Plan one vCPU per parallel worker plus one for each service, then add the memory ceilings of the containers to the peak of the test process. | Services on the job | Parallel test workers | Memory to plan for | Runner label | Shape | Rate | | ------------------------------------------------- | --------------------- | ------------------ | -------------------------- | -------------- | ---------- | | Postgres only | Serial suite | 2 GB | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004/min | | Postgres and Redis | 4 | 6 GB | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008/min | | Postgres, Redis, and a message broker | 8 | 12 GB | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016/min | | Postgres and a JVM search node | 8 | 14 GB | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016/min | | Two database engines, a broker, and a search node | 16 | 28 GB | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032/min | Labels, shapes, and rates come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing). The memory column is a planning budget rather than a measurement: set a container memory limit per service, run the suite once, and replace these figures with what the job reports. Every Linux size carries a [150GB SSD](/docs/ci/cloud-runners), which matters because the database volume, the pulled images, and any dump file all land on the same disk. A 20GB fixture dump plus a few database images stays well inside that, and a job that restores several large dumps in a matrix does not. At the size where most multi-service suites land, `warp-ubuntu-latest-x64-8x` costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner at the same 8 vCPU, 32 GB shape: 27 percent lower list price. GitHub list price checked on 2026-08-13 against the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). ### [What seeding costs, and when a prepared machine is cheaper](#what-seeding-costs-and-when-a-prepared-machine-is-cheaper)Copy Anchor Link Migrations and fixtures run against an empty database on every job, so seed time is a recurring line on the invoice. Take a suite that runs 500 times a month on `warp-ubuntu-latest-x64-8x` at $0.016 per minute. | Seed duration per run | Billed minutes per month | Monthly cost of seeding | | --------------------- | ------------------------ | ----------------------- | | 1 minute | 500 | $8.00 | | 5 minutes | 2,500 | $40.00 | | 12 minutes | 6,000 | $96.00 | The alternative is a prepared machine. A snapshot captures the runner VM, so it carries the pulled service images and any host directory the job wrote. Point the service container's data directory at a host path with the `volumes` keyword, seed it once on `main`, save the snapshot, and later jobs boot with that data already on disk. Add `;snapshot.key=integration-db` to the `runs-on` label to boot from it, as described in the [snapshot runners documentation](/docs/ci/features/snapshot-runners). The snapshot path has its own bill, priced on the [pricing page](/pricing). Snapshot restore is $0.04 per job, which is $20.00 across 500 runs. Snapshot storage is $0.025 per snapshot-hour, so one alias held through a 30 day month is 720 snapshot-hours, or $18.00\. Assume the restored machine spends 30 seconds getting the seeded volume ready: 250 minutes at $0.016 adds $4.00\. That is $42.00 a month whatever the original seed took, which puts the crossover near a 5 minute seed at this run volume, with the wall-clock saving on top. Two limits shape the decision, both covered in the [snapshot runners documentation](/docs/ci/features/snapshot-runners). Snapshots are deleted after 15 days, so the alias needs rewriting on a schedule. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only, and the labels are silently ignored on BYOC, Windows, and macOS runners. When the stack grows past three or four services, the `services` key stops being the right tool and a compose file takes over. That path, including health-gated startup and a separate image pull step, is covered on [Docker Compose integration tests on GitHub Actions](/solutions/docker-compose-integration-tests-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link ### [Do I have to map ports for a database service container?](#do-i-have-to-map-ports-for-a-database-service-container)Copy Anchor Link Only when the job runs directly on the runner machine. GitHub documents that a job on the runner reaches a service at `localhost:` or `127.0.0.1:` and needs the `ports` keyword to publish the container port to the host. A job that sets the `container` key joins the same Docker network, reaches the service at its service label as a hostname, and needs no `ports` mapping at all. The [service container documentation](https://docs.github.com/en/actions/tutorials/use-containerized-services/use-docker-service-containers) covers both forms, and [service containers in GitHub Actions](/guides/service-containers-github-actions) walks through the configuration end to end. Once the port list runs past three or four services, move the stack into a compose file and follow [Docker Compose integration tests on GitHub Actions](/solutions/docker-compose-integration-tests-github-actions). ### [Why does my test suite fail with connection refused on the first step?](#why-does-my-test-suite-fail-with-connection-refused-on-the-first-step)Copy Anchor Link The service container started before it was ready to accept connections. Give the service an `options` block with `--health-cmd`, `--health-interval`, `--health-timeout`, and `--health-retries` so the runner waits for a healthy container before the first step runs. For Postgres that is `--health-cmd pg_isready`, and for Redis it is `--health-cmd "redis-cli ping"`. A migration step that fails intermittently after the health check passes usually points at the migration itself, which [running database migrations on GitHub Actions](/solutions/database-migrations-github-actions) covers. ### [Should I seed the database on every GitHub Actions run?](#should-i-seed-the-database-on-every-github-actions-run)Copy Anchor Link Seed on every run while the seed is short. At 500 runs a month on `warp-ubuntu-latest-x64-8x` at $0.016 per minute, a 1 minute seed costs $8.00 a month and a 12 minute seed costs $96.00\. A snapshot runner that boots with the seeded data directory already on disk costs about $42.00 a month at that run volume, counting $0.04 per restore, $0.025 per snapshot-hour of storage, and the restored boot time. The crossover sits near a 5 minute seed, and [keeping a database warm between GitHub Actions jobs](/answers/can-i-keep-a-database-warm-between-github-actions-jobs) covers the reuse patterns in more detail. --- # How Do I Run Go Race Detector Tests in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-run-go-race-detector-tests-in-github-actions Description: Run go test -race on a job sized for the overhead, since race detection raises memory and run time. Split a fast unit job from a slower race job. # How Do I Run Go Race Detector Tests in GitHub Actions? Run go test -race on a job sized for the overhead, since race detection raises memory and run time. Split a fast unit job from a slower race job. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Run the suite with `go test -race ./...` on a job sized for the overhead, because race detection multiplies both memory use and run time. The [Go race detector documentation](https://go.dev/doc/articles/race%5Fdetector) states that for a typical program memory usage may increase by 5x to 10x and execution time by 2x to 20x, which means the same tests that fit a 4 vCPU runner without the flag can exhaust it with the flag on. That is why the working shape is two jobs rather than one. A fast job runs `go test ./...` and gates pull requests, and a second job runs the race suite on a larger label without holding merges hostage to the slower run. Copy Text ``` name: test on: pull_request: push: branches: [main] schedule: - cron: "0 6 * * *" jobs: unit: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-go@v6 with: go-version: "1.24" cache-dependency-path: "**/go.sum" - run: go test ./... race: if: github.event_name != 'pull_request' runs-on: warp-ubuntu-latest-x64-16x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-go@v6 with: go-version: "1.24" cache: false - uses: WarpBuilds/cache@v1 with: path: | ~/go/pkg/mod ~/.cache/go-build key: ${{ runner.os }}-go-race-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-race- - run: go test -race -p 8 -timeout 30m ./... env: GORACE: halt_on_error=1 ``` Teams that want race coverage before merge can drop the `if` condition and instead leave the race job out of the required status checks, so it reports on the pull request without blocking it. `WarpBuilds/setup-go@v6` installs Go and caches modules and build outputs, with caching on by default keyed on the hash of `go.mod` ([setup actions documentation](/docs/ci/features/caching/setup-actions)). The race job sets `cache: false` and takes explicit control of the cache instead, for the reason in the cache section below. ## [Detail](#detail)Copy Anchor Link ### [Sizing the race job against the Linux ladder](#sizing-the-race-job-against-the-linux-ladder)Copy Anchor Link `go test` runs the test binaries of different packages in parallel, bounded by `-p`, which defaults to `GOMAXPROCS` and therefore to the vCPU count. Every one of those binaries carries the race multiplier at the same time, so peak memory is the per-binary peak times the parallelism, and a wide package graph is where out-of-memory kills come from. Here is the memory budget per parallel test binary at the default `-p` on the Linux x64 catalog, with rates from the [pricing page](/pricing) and shapes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13: | Runner label | vCPU | RAM | Default \-p | RAM per parallel binary | | -------------------------- | ---- | ------ | ----------- | ----------------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 4 | 4 GB | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 8 | 4 GB | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 16 | 4 GB | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 32 | 4 GB | The last column is the point. The catalog holds a constant 4 GB of RAM per vCPU at every size, so moving up the ladder buys cores and leaves the per-slot memory budget where it was. A package whose uninstrumented test binary peaks at 700 MB lands between 3.5 GB and 7 GB under the multiplier above, which overflows a 4 GB slot on any of these labels. So size the race job in two moves. Pick the label from how wide the package graph is, then set `-p` from memory: `-p 8` on `warp-ubuntu-latest-x64-16x` gives each binary 8 GB and still keeps eight compiles and test runs in flight. Repositories with a narrow package graph get nothing from the wider label and should stay on 8x with `-p 4`. Raise `-timeout` on the same job. `go test` uses a default timeout of 10 minutes per test binary, and a package that finished in 90 seconds uninstrumented can cross that line once instrumented, which surfaces as a panic with a full goroutine dump rather than a test failure. `-timeout 30m` on the race job is the usual first setting. ### [The race build is a separate build cache entry](#the-race-build-is-a-separate-build-cache-entry)Copy Anchor Link The Go build cache keys entries on the inputs that produced them, and the build flags are part of those inputs. `-race` therefore produces its own set of compiled package artifacts, and a race job never reuses the objects the ordinary build wrote. Three consequences follow. First, the first race run after a cold cache pays a full compile of the dependency graph, including the standard library packages the code imports, since the [Go 1.20 release notes](https://go.dev/doc/go1.20) confirm the toolchain stopped shipping precompiled standard library archives and now builds and caches the standard library the same way as any other package. Second, plan for a GOCACHE roughly twice the size of a single-mode cache once both jobs are warm. The module cache is unaffected: GOMODCACHE holds downloaded source, which does not change with build flags, so both jobs restore the same module set. Third, give the two jobs different cache keys, as the workflow above does with the `go-race-` prefix. Sharing one key makes each job save an entry that mixes its own objects with whatever the other job restored, and the entry churns on every run without either job getting a clean hit. The [caching documentation](/docs/ci/features/caching) covers key, version and branch scoping, and [how to cache Go modules in GitHub Actions](/answers/how-do-i-cache-go-modules-in-github-actions) covers the module half in detail. ### [What the race job costs](#what-the-race-job-costs)Copy Anchor Link Race jobs run longer, so the per-minute rate carries more weight than it does on the unit job. `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13 against the [GitHub Actions per-minute rate list](https://docs.github.com/en/billing/reference/actions-minute-multipliers), with shapes from the [GitHub runner specs page](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). A worked month, with assumptions stated so you can substitute your own. Take 600 pull request runs and 120 merges to `main` plus nightly runs: | Job | Label | Rate per minute | Minutes per run | Runs per month | Monthly cost | | ---- | -------------------------- | --------------- | --------------- | -------------- | ------------ | | Unit | warp-ubuntu-latest-x64-4x | $0.008 | 3 | 600 | $14.40 | | Race | warp-ubuntu-latest-x64-16x | $0.032 | 10 | 120 | $38.40 | That is $52.80 per month. Running the race suite on all 600 pull request runs instead moves the race line to $192.00 and the total to $206.40, so gating the race job on merges and a nightly schedule is the single largest cost lever on the workflow. ### [Debugging a race the detector catches once](#debugging-a-race-the-detector-catches-once)Copy Anchor Link The detector reports only races that actually occur during the run, so a report from a nightly job often refuses to reproduce locally. `GORACE=halt_on_error=1` stops at the first report, which keeps the log short and the failing run pinned. `GORACE=history_size=N` widens the per-goroutine memory access history so the report can name an older conflicting access, at the cost of more memory, which is the one setting to raise carefully on a job already near its limit. Everything past the log is where the runner platform helps. The [Action Debugger](/docs/ci/tools/action-debugger) fits this case: it gives an interactive session on the machine that hit the report, with the workspace and the instrumented binary still in place. Race detection also needs a C toolchain, since the detector is built on a C runtime, so a job that sets `CGO_ENABLED=0` anywhere in its environment fails to build with `-race` before any test runs. ## [Related Questions](#related-questions)Copy Anchor Link ### [Why does go test -race need a bigger runner than the same tests without the flag?](#why-does-go-test--race-need-a-bigger-runner-than-the-same-tests-without-the-flag)Copy Anchor Link The race detector instruments every memory access and keeps a shadow history per goroutine. The Go documentation puts the cost for a typical program at 5x to 10x memory and 2x to 20x execution time, so a test binary that peaked at 700 MB can land between 3.5 GB and 7 GB. On WarpBuild Linux x64 runners every size carries 4 GB of RAM per vCPU, so the fix is bounding `go test -p` rather than only moving up the ladder. [Right-sizing GitHub Actions runners](/guides/runner-right-sizing-github-actions) walks through the same decision for other workloads. ### [Does the race build reuse the build cache from the normal build?](#does-the-race-build-reuse-the-build-cache-from-the-normal-build)Copy Anchor Link No. The Go build cache keys entries on the build inputs, and `-race` is one of them, so race binaries and ordinary binaries occupy separate entries. Plan for a GOCACHE roughly twice the size of a single-mode cache, and give the race job its own cache key so the two jobs stop overwriting each other. [How to cache Go modules in GitHub Actions](/answers/how-do-i-cache-go-modules-in-github-actions) covers the key and restore-key shapes. ### [Can I run the race detector on ARM64, macOS and Windows runners?](#can-i-run-the-race-detector-on-arm64-macos-and-windows-runners)Copy Anchor Link Yes. The [Go race detector documentation](https://go.dev/doc/articles/race%5Fdetector) lists linux/amd64, linux/arm64, darwin/arm64 and windows/amd64 among the supported platforms. The detector is built on a C runtime, so the job needs `CGO_ENABLED=1` and a working C toolchain. [Go builds on WarpBuild runners](/solutions/go-github-actions) covers the label choice per platform, and the [pricing page](/pricing) lists the per-minute rate for each one. --- # How Do I Run Jobs on a Schedule? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-run-jobs-on-a-schedule Description: Add a schedule trigger with a cron expression. Scheduled runs fire on the default branch, in UTC, and GitHub gives no guarantee of an exact start minute. # How Do I Run Jobs on a Schedule? Add a schedule trigger with a cron expression. Scheduled runs fire on the default branch, in UTC, and GitHub gives no guarantee of an exact start minute. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Add a `schedule` trigger with a cron expression to the workflow file, and put `workflow_dispatch` next to it so the same jobs can start by hand. Three behaviors decide whether that schedule does what you meant: GitHub evaluates the cron in UTC, the run fires against the latest commit on the default branch, and the start time is best effort, since GitHub documents that the `schedule` event can be delayed during periods of high load ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule), checked on 2026-08-13). Copy Text ``` name: nightly on: schedule: - cron: "17 3 * * *" workflow_dispatch: jobs: integration: runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: suite: [api, worker, web, migrations] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run test:integration -- --suite=${{ matrix.suite }} env: RUN_REASON: ${{ github.event_name }} ``` The cron string is POSIX five-field syntax, read left to right. | Field | Range | Value in the example | Meaning | | ------------ | ------------------- | -------------------- | ------------------------- | | Minute | 0 to 59 | 17 | 17 minutes past the hour | | Hour | 0 to 23, UTC | 3 | 03:00 UTC | | Day of month | 1 to 31 | \* | every day | | Month | 1 to 12 | \* | every month | | Day of week | 0 to 6, Sunday is 0 | \* | every weekday and weekend | Two limits are worth knowing before you write the expression. The shortest interval GitHub accepts is once every 5 minutes, and a workflow can carry more than one `cron` entry under the same `schedule` key, which is how a weekday cadence and a weekend cadence live in one file ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule), checked on 2026-08-13). ## [Detail](#detail)Copy Anchor Link ### [The three surprises](#the-three-surprises)Copy Anchor Link **UTC, with no daylight saving adjustment.** The cron is evaluated in UTC and stays there all year. A team in Berlin that writes `0 2 * * *` expecting a 02:00 local run gets 04:00 local in summer and 03:00 local in winter. Convert the local time you actually want into UTC once, then write that number and leave it alone. Where the run has to land at a fixed local hour year round, use two cron entries with month ranges, or drive it from an external scheduler that calls the workflow dispatch API. **Default branch only.** The `schedule` event always runs the version of the workflow on the latest commit of the default branch. Adding a cron on a feature branch produces no runs at all, and there is no branch-scoped preview of a schedule change. That is the practical reason `workflow_dispatch` belongs in the same file: it runs from any ref, so you can prove the jobs work before the cron ever fires. Branching on `github.event_name` inside a step lets the manual run skip a notification or a deploy that only the nightly path should do. **Delayed and dropped runs.** GitHub names the start of every hour as a high load window and recommends scheduling away from it, which is why the example above uses minute 17 rather than 0\. Under sustained load a scheduled run can start minutes late or be skipped for that interval, so a job that has to run exactly 288 times a day at exactly 5 minute spacing needs a scheduler outside GitHub Actions. One more schedule-specific detail: for a public repository, GitHub disables scheduled workflows automatically after 60 days with no repository activity, and failure notifications for a scheduled run go to whoever last edited the cron expression rather than to the whole team ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule), checked on 2026-08-13). ### [What a nightly job costs](#what-a-nightly-job-costs)Copy Anchor Link The workflow above runs a 4-leg matrix once a night. Assume each leg takes 22 minutes, which is 88 billed minutes per night, and 2,640 minutes across a 30 day month on `warp-ubuntu-latest-x64-4x` at $0.008 per minute from the [cloud runners catalog](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13\. That is $21.12 a month. | Cadence | Cron | Runs per 30 days | Billed minutes per run | Total minutes | Runner and rate | Monthly cost | | ---------------------------------------- | ---------------- | ---------------- | ---------------------- | ------------- | ------------------------------------- | ------------ | | Nightly, 4-leg matrix | 17 3 \* \* \* | 30 | 88 | 2,640 | warp-ubuntu-latest-x64-4x, $0.008/min | $21.12 | | Weekdays only, same matrix | 17 3 \* \* 1-5 | 22 | 88 | 1,936 | warp-ubuntu-latest-x64-4x, $0.008/min | $15.49 | | Hourly smoke test | 23 \* \* \* \* | 720 | 4 | 2,880 | warp-ubuntu-latest-x64-2x, $0.004/min | $11.52 | | Every 5 minutes, the floor GitHub allows | \*/5 \* \* \* \* | 8,640 | 2 | 17,280 | warp-ubuntu-latest-x64-2x, $0.004/min | $69.12 | The last row is the one that surprises people. A 2 minute health check at the shortest interval GitHub permits costs more per month than a 22 minute nightly integration matrix, because frequency multiplies faster than duration. For the nightly row, the same 2,640 minutes on the nearest GitHub-hosted shape come to $31.68\. `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price, checked on 2026-08-13 against the [GitHub Actions per-minute rate list](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Over a year that difference is $126.72 on one nightly workflow. ### [Confirming the schedule actually ran](#confirming-the-schedule-actually-ran)Copy Anchor Link A cron that quietly stopped firing looks identical to a cron that never existed, so check the record rather than the file. The [reports documentation](/docs/ci/features/reports) covers the Jobs section, which carries one row per repository, workflow, and job name with duration P75 and P90 next to queue time percentiles. Filter it to the scheduled workflow name over a 30 day window and the run count tells you whether every interval landed, while the duration percentiles tell you whether the nightly job is drifting longer as the suite grows. Every table exports to CSV with the current filters applied, which makes the run count easy to compare against the number of intervals you expected. ### [Picking the runner for a scheduled job](#picking-the-runner-for-a-scheduled-job)Copy Anchor Link Scheduled jobs are usually the least time-critical work in a repository and the easiest place to buy back wall-clock time, since nobody is watching a nightly run at 03:17 UTC. A nightly matrix can pin its iOS leg to macOS and its service tests to Linux inside one workflow, with labels listed in the [cloud runners catalog](/docs/ci/cloud-runners). The [guide to speeding up GitHub Actions](/guides/speed-up-github-actions) covers the sizing and caching levers that apply to scheduled jobs the same way they apply to pull request jobs, and the [nightly builds guide](/guides/nightly-builds-github-actions) walks through the full workflow shape. ## [Related Questions](#related-questions)Copy Anchor Link ### [What time zone does a GitHub Actions cron schedule use?](#what-time-zone-does-a-github-actions-cron-schedule-use)Copy Anchor Link UTC, always. A cron of `0 9 * * *` fires at 09:00 UTC every day of the year, so the local clock time of that run moves by an hour when your region enters or leaves daylight saving. Convert the local time you want into UTC once and write that number down in a comment above the cron. The [workflow event definition](/glossary/workflow-event) covers how `schedule` sits alongside the other triggers. ### [Why did my scheduled workflow start late or skip a run?](#why-did-my-scheduled-workflow-start-late-or-skip-a-run)Copy Anchor Link GitHub documents that the `schedule` event can be delayed during periods of high load and names the start of every hour as one of those windows ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule), checked on 2026-08-13). Move the cron minute away from 0, spread multiple schedules across different minutes, and treat the time as a best-effort start. For public repositories, also check whether 60 days of repository inactivity disabled the schedule. ### [Can a scheduled workflow run on a branch other than the default?](#can-a-scheduled-workflow-run-on-a-branch-other-than-the-default)Copy Anchor Link No. The `schedule` event runs the workflow as it exists on the latest commit of the default branch, so a cron added on a feature branch produces no runs until it merges. Keep `workflow_dispatch` in the same file to run the jobs from any ref while you are still iterating, and branch on `github.event_name` for the steps that only the scheduled path should perform. The [base image update workflow guide](/guides/base-image-update-workflow-github-actions) uses exactly that pairing to open pull requests on a cron. --- # How Do I Run PowerShell Scripts in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-run-powershell-scripts-in-github-actions Description: Set shell: pwsh on the step. GitHub Actions defaults to pwsh on Windows runners and bash on Linux, and pwsh and powershell are two different interpreters. # How Do I Run PowerShell Scripts in GitHub Actions? Set shell: pwsh on the step. GitHub Actions defaults to pwsh on Windows runners and bash on Linux, and pwsh and powershell are two different interpreters. Set the shell on the step with `shell: pwsh` and let the script exit non-zero when the work fails. GitHub Actions runs `pwsh` by default on Windows runners and `bash -e` by default on Linux and macOS runners, and the `pwsh` and `powershell` keywords start two different interpreters, so a `run` block that leaves the shell unset behaves differently depending on which runner picked the job up. ## [Answer](#answer)Copy Anchor Link Three settings decide whether a PowerShell step does what you expect: which shell keyword the step uses, what the runner prepends and appends around your script, and whether your script surfaces failures as a non-zero process exit. The `shell` keyword table below is transcribed from the [GitHub Actions workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstepsshell), checked on 2026-08-13. | shell value | Command the runner executes | Where it works | | ----------- | --------------------------------------------------------- | ------------------------------------------- | | unset | pwsh -command ". '{0}'" on Windows, bash -e {0} elsewhere | all runners | | pwsh | pwsh -command ". '{0}'" | Windows, Linux, macOS | | powershell | powershell -command ". '{0}'" (PowerShell Desktop) | Windows only | | bash | bash --noprofile --norc -eo pipefail {0} | all runners, via Git for Windows on Windows | | sh | sh -e {0} | Linux and macOS | | cmd | %ComSpec% /D /E:ON /V:OFF /S /C "CALL "{0}"" | Windows only | | python | python {0} | all runners | Two rows in that table cause most of the confusion. An unset shell on a Linux job sends your PowerShell through `bash -e`, where the first `$ErrorActionPreference` line fails as a shell syntax error. And `powershell` is PowerShell Desktop, which is Windows PowerShell 5.1 and exists only on Windows images, so a workflow that uses it cannot move to a Linux runner without an edit. For the `pwsh` and `powershell` keywords, the runner prepends `$ErrorActionPreference = 'stop'` to your script and appends `if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }` so the step status follows the last exit code, per the [exit codes and error action preference section](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#exit-codes-and-error-action-preference) of the same reference. Replacing the keyword with a custom shell string such as `pwsh -File {0}` drops both, which is the usual reason a migrated workflow stops failing on errors. A working Windows step looks like this: Copy Text ``` name: package on: push: branches: [main] jobs: build: runs-on: warp-windows-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Build the release package shell: pwsh run: | $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true ./scripts/package.ps1 -Configuration Release - name: Fail when nothing was packaged shell: pwsh run: | $ErrorActionPreference = 'Stop' $packages = Get-ChildItem -Path ./dist -Filter *.nupkg if ($packages.Count -lt 1) { Write-Host "::error::no packages were produced in ./dist" exit 1 } Write-Host "packaged $($packages.Count) files" ``` Setting `$ErrorActionPreference` inside the block is redundant with the built-in keyword and worth keeping anyway, because the line survives if someone later switches the step to a custom shell string. The `exit 1` is what actually fails the step: the appended `exit $LASTEXITCODE` reports whatever process exit code PowerShell ends on. This workflow moves to WarpBuild by changing `runs-on`. The Windows x86-64 images carry the same tooling as GitHub-hosted runners, per the [cloud runners documentation](/docs/ci/cloud-runners), so `shell: pwsh` resolves to the same interpreter after the label change. Labels and sizes are listed on the [Windows runners page](/runners/windows). ## [Detail](#detail)Copy Anchor Link ### [The silent failure: a script that returns zero after an internal error](#the-silent-failure-a-script-that-returns-zero-after-an-internal-error)Copy Anchor Link `$ErrorActionPreference = 'Stop'` promotes non-terminating cmdlet errors into terminating ones. It does nothing about native commands. A `.exe` that exits 3 sets `$LASTEXITCODE` to 3 and execution continues to the next line, and if that next line is another native command that succeeds, `$LASTEXITCODE` is reset to 0\. The appended exit then reports 0 and the step turns green over a failed test run. Copy Text ``` - name: Integration tests and coverage report shell: pwsh run: | $ErrorActionPreference = 'Stop' ./build/run-tests.exe --suite integration & dotnet tool run reportgenerator -reports:./TestResults/*.xml -targetdir:./coverage ``` If `run-tests.exe` exits 3 and `reportgenerator` exits 0, the job passes. The guard is either an explicit check after each native call, or the preference variable that makes native exit codes obey `$ErrorActionPreference`: Copy Text ``` - name: Integration tests and coverage report shell: pwsh run: | $ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $true ./build/run-tests.exe --suite integration if ($LASTEXITCODE -ne 0) { throw "run-tests.exe exited with $LASTEXITCODE" } & dotnet tool run reportgenerator -reports:./TestResults/*.xml -targetdir:./coverage ``` `$PSNativeCommandUseErrorActionPreference` defaults to `$false`, per the [PowerShell preference variables reference](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about%5Fpreference%5Fvariables), so set it in the script rather than relying on the interpreter version. It exists in PowerShell 7 only, which is another reason to prefer `shell: pwsh` over `shell: powershell`. For tools that use non-zero exit codes to report information, such as `robocopy`, Microsoft's reference shows setting the variable back to `$false` inside a script block and checking `$LASTEXITCODE` against the tool's own thresholds. ### [Set the shell once instead of on every step](#set-the-shell-once-instead-of-on-every-step)Copy Anchor Link Per-step `shell` keys drift. A workflow-level default keeps every `run` block on one interpreter: Copy Text ``` defaults: run: shell: pwsh jobs: test: runs-on: warp-windows-latest-x64-8x defaults: run: shell: pwsh working-directory: ./src steps: - uses: actions/checkout@v4 - run: dotnet test --configuration Release --logger trx ``` Job-level defaults override workflow-level defaults, and a step-level `shell` overrides both, as documented under [defaults.run.shell](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defaultsrunshell). ### [PowerShell on Linux, macOS, and BYOC runners](#powershell-on-linux-macos-and-byoc-runners)Copy Anchor Link `pwsh` is cross platform, so a PowerShell step runs on Linux and macOS labels too. Image contents decide whether it is already there. The WarpBuild Linux x86-64 and macOS images match the GitHub-hosted tooling lists linked from the [preinstalled software documentation](/docs/ci/preinstalled-software), and the Ubuntu 24.04 and 26.04 ARM64 images are compatible with GitHub's ARM64 runner images per the same documentation, so `pwsh` is already on the image without an extra install step. On BYOC with a custom Windows VM image, WarpBuild runs `.ps1` startup scripts through `pwsh -command` and falls back to `powershell -command`, per the [custom VM images documentation](/docs/ci/byoc/custom-vm-images). GitHub's reference describes the same fallback for self-hosted Windows runners without PowerShell Core installed, so a custom image that ships only Windows PowerShell 5.1 will silently run `shell: pwsh` steps under PowerShell Desktop. If the job never starts at all, the shell is not the problem. Runner group access, bot permissions, and workflow path restrictions are covered in [common issues](/docs/ci/common-issues). ### [What the step costs](#what-the-step-costs)Copy Anchor Link Windows runner shapes and rates, checked on 2026-08-13 against the [pricing page](/pricing): | Runner label | Image | vCPU | Memory | Per minute | | --------------------------- | ------------------- | ---- | ------ | ---------- | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16GB | $0.016 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32GB | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64GB | $0.064 | | warp-windows-latest-x64-32x | Windows Server 2022 | 32 | 128GB | $0.128 | The `warp-windows-2025-x64-` labels and the `warp-windows-2025-vs2026-x64-` labels carry the same four shapes at the same rates, with Windows Server 2025 and Visual Studio 2026 respectively. A PowerShell packaging job that runs 4 minutes on `warp-windows-latest-x64-4x` bills 4 x $0.016 = $0.064. When a PowerShell step fails without a useful log line, the surrounding tooling matters more than the script. ## [Related Questions](#related-questions)Copy Anchor Link ### [Should I use shell: pwsh or shell: powershell?](#should-i-use-shell-pwsh-or-shell-powershell)Copy Anchor Link Use `pwsh` unless the script needs a Windows PowerShell 5.1 module. The `pwsh` keyword runs PowerShell Core and works on Windows, Linux, and macOS runners, while `powershell` runs PowerShell Desktop and exists only on Windows. Both keywords get `$ErrorActionPreference = 'stop'` prepended by the runner, and only `pwsh` has `$PSNativeCommandUseErrorActionPreference`. Windows labels and sizes are on the [WarpBuild Windows runners page](/runners/windows). ### [Why does my PowerShell step pass when the script clearly failed?](#why-does-my-powershell-step-pass-when-the-script-clearly-failed)Copy Anchor Link The runner appends an exit that reports `$LASTEXITCODE`, and `$LASTEXITCODE` holds the code of the last native command that ran. If a failing `.exe` is followed by a command that succeeds, the variable is reset to zero and the step turns green. Check `$LASTEXITCODE` right after each native call and throw, or set `$PSNativeCommandUseErrorActionPreference` to `$true`. Once the step fails correctly, [retrying a failed step in GitHub Actions](/answers/how-do-i-retry-a-failed-step-in-github-actions) covers what to do about flaky ones. ### [How do I split a large PowerShell or .NET test suite across runners?](#how-do-i-split-a-large-powershell-or-net-test-suite-across-runners)Copy Anchor Link Shard the suite with a matrix so each leg runs a slice on its own runner, and give every leg the same shell setting through `defaults.run.shell` so an error-handling difference does not show up on one leg only. The [guide to .NET test parallelism on GitHub Actions](/guides/dotnet-test-parallelism-github-actions) covers shard sizing and result merging. --- # How Do I See Cost per Runner Label? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-see-cost-per-runner-label Description: The CI tab of the WarpBuild Billing report groups cost by runner label, so an oversized label doing small work shows up as minutes billed at a high rate. # How Do I See Cost per Runner Label? The CI tab of the WarpBuild Billing report groups cost by runner label, so an oversized label doing small work shows up as minutes billed at a high rate. Last verified: 2026-08-13 Cost per runner label lives in the CI tab of the [Billing report](/docs/ci/features/reports), where the daily chart groups spend by runner label and every table row is one job execution stamped with the label it ran on. Filter that table by label, read the cost total against the billed minutes total, and an oversized label doing small work shows up as a high rate paid for many minutes. ## [Answer](#answer)Copy Anchor Link The CI tab of the Billing report carries three surfaces, and each one answers a different part of the question. The daily stacked bar chart plots cost per day grouped by repository or by runner label. The summary cards give total cost, runner cost, snapshot cost, and total jobs for the selected period. The table underneath writes one row per job execution, and the filters cover repository, runner label, stack, job name, and snapshot usage. Every tab exports CSV containing all rows matching the current filters and sort order rather than the visible page ([reports documentation](/docs/ci/features/reports)). Here is what each field in a row does for a per-label question. | Billing row field | What it answers | | -------------------------------- | ---------------------------------------------------------------------- | | Runner label | Which catalog entry the job ran on, and the grouping key for the chart | | Repository and job name | Which workflow owns the spend on that label | | Stack | Which runner configuration served the job, for teams running several | | Execution time | Wall clock the job took | | Billed time | Minutes the cost was computed from | | Cost split, runner plus snapshot | Machine rate separated from snapshot restore cost | | Snapshot usage | Whether the row includes a snapshot at all | Reading minutes against the rate is one division. Filter the table to a single label, sum the cost column and the billed time column, and divide. The result should match the published per-minute rate for that label. Current Linux x64 rates from the [pricing page](/pricing) are as follows. | Label | Shape | Rate | | -------------------------- | --------------- | ---------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004/min | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008/min | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016/min | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032/min | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064/min | An effective rate above the published one means snapshot cost is inside the row, which the cost split column separates. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so cost per label reduces to billed minutes multiplied by that label's rate and nothing else. ## [Detail](#detail)Copy Anchor Link ### [Put the label cost next to the utilization for the same job](#put-the-label-cost-next-to-the-utilization-for-the-same-job)Copy Anchor Link Cost per label tells you where the money went. It does not say whether the machine was busy, so pair it with the Jobs section of the same report, which aggregates per repository, workflow, and job name and reports run count, success rate, duration P75 and P90, queue time P75 and P90, CPU P75 and P90, and memory P75 and P90\. CPU and memory columns need [Observability](/docs/ci/features/observability) enabled; jobs without telemetry show a dash. The threshold that separates a busy label from an oversized one is fixed. The Recommendations view marks an instance as under-provisioned at max sustained CPU, max memory utilization, or max filesystem utilization of 80 percent or higher, or max disk I/O at 80 percent of supported throughput. A job holding CPU in the low tens for a month of runs sits at the other end of that scale. ### [A worked example: a 16x label using two cores](#a-worked-example-a-16x-label-using-two-cores)Copy Anchor Link Take a `unit-tests` job in one repository, running on `warp-ubuntu-latest-x64-16x`. The billing filter for that label over a month returns 1,000 job executions and 6,000 billed minutes. The Jobs row for the same job shows CPU P90 at 13 percent and memory P90 at 19 percent. | Reading | Value | | ----------------------------------- | ------------------------------------ | | Billed minutes for the label | 6,000 | | Rate for warp-ubuntu-latest-x64-16x | $0.032/min | | Monthly cost | $192.00 | | CPU P90 | 13 percent of 16 vCPU, about 2 cores | | Memory P90 | 19 percent of 64 GB, about 12.2 GB | Two cores of a sixteen core machine is the signature the question was looking for. For reference on the same shape, `warp-ubuntu-latest-x64-16x` costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner at 16 vCPU and 64 GB: 24 percent lower list price ([GitHub pricing](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked 2026-08-13). Those same 6,000 minutes would bill $252.00 there. On a label this oversized, the size change moves more money than the rate difference does. ### [Move the label down and check the next month](#move-the-label-down-and-check-the-next-month)Copy Anchor Link Memory sets the floor here. The 12.2 GB peak rules out `warp-ubuntu-latest-x64-2x` at 8 GB, so the stop is `warp-ubuntu-latest-x64-4x` at 4 vCPU and 16 GB, which still leaves two idle cores against the measured peak. Copy Text ``` jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/test.sh ``` At the same 6,000 billed minutes, $0.008 per minute bills $48.00 against $192.00, a difference of $144.00 a month. Assume the job stretches 20 percent to 7,200 minutes on fewer cores: $57.60, still $134.40 below the starting point. Re-run the same filter after a week and confirm both numbers, the label total and the CPU P90, before touching the next label. The [right-sizing guide](/guides/runner-right-sizing-github-actions) covers the ladder in full, and [detecting an undersized runner](/answers/how-do-i-detect-an-undersized-runner) covers the move in the other direction. ### [What the label view does not cover](#what-the-label-view-does-not-cover)Copy Anchor Link Queue time is tracked separately, in the Queue Timings section, per runner label and stack with run count and queue time P75 and P90\. A label can be correctly sized and still cost engineering time waiting. Docker Builder and Cache spend sit in their own tabs, per session and per cache entry, so a label total is runner and snapshot cost only. For splitting any of these across teams rather than across labels, [cost attribution](/guides/github-actions-cost-attribution) covers the label and job name conventions that make the CSV export a chargeback ledger. Signup includes $10 free credits, which is enough to run a real workflow on two labels and compare the rows before the first invoice. ## [Related Questions](#related-questions)Copy Anchor Link ### [Where does cost per runner label live in WarpBuild?](#where-does-cost-per-runner-label-live-in-warpbuild)Copy Anchor Link In the CI tab of the Billing report. The daily stacked bar chart groups cost by repository or by runner label, and the table below it writes one row per job execution carrying repository, job name, runner label, stack, snapshot usage, execution time, billed time, and the cost split between runner and snapshot. Filter by runner label to get that label's total for the period, then export the filtered view as CSV ([reports documentation](/docs/ci/features/reports)). ### [How do I turn cost per label into a per-minute rate I can check?](#how-do-i-turn-cost-per-label-into-a-per-minute-rate-i-can-check)Copy Anchor Link Divide the filtered cost total by the billed minutes total. The result should match the catalog rate for that label, for example $0.032 per minute for `warp-ubuntu-latest-x64-16x` on the [pricing page](/pricing). A higher effective rate means snapshot cost is riding in the row, which the report splits into its own column. ### [What tells me a label is oversized rather than busy?](#what-tells-me-a-label-is-oversized-rather-than-busy)Copy Anchor Link The utilization for the same job. The Jobs report shows CPU P75 and P90 and memory P75 and P90 per repository, workflow, and job name, and the [Recommendations view](/docs/ci/features/observability) flags an instance as under-provisioned only at 80 percent or higher. CPU in the low tens across a month of runs is the oversized signature, and [detecting an undersized runner](/answers/how-do-i-detect-an-undersized-runner) reads the same table for the opposite case. ### [What does moving a label down a size save?](#what-does-moving-a-label-down-a-size-save)Copy Anchor Link Each Linux x64 step halves the rate. Dropping a job from `warp-ubuntu-latest-x64-16x` at $0.032 per minute to `warp-ubuntu-latest-x64-4x` at $0.008 per minute takes 6,000 monthly billed minutes from $192.00 to $48.00 at the same duration. The full rate list is on the [pricing page](/pricing), and the [right-sizing guide](/guides/runner-right-sizing-github-actions) works through the sizes where the trade turns. --- # How Do I See Cost per Workflow? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-see-cost-per-workflow Description: Cost is recorded per job execution, so cost per workflow is a grouping job: price the CI billing rows, attach the workflow from the run id, then sum. # How Do I See Cost per Workflow? Cost is recorded per job execution, so cost per workflow is a grouping job: price the CI billing rows, attach the workflow from the run id, then sum. Cost is recorded per job execution, so cost per workflow is a grouping question: take the per-job rows from the CI tab of the [Billing report](/docs/ci/features/reports), attach the workflow each row belongs to, and sum the cost column by workflow. Two routes do the attaching: the Jobs section of the same report already aggregates by repository, workflow, and job name, so it tells you which job names a workflow owns, and the API returns billing rows carrying the GitHub Actions run id, which you can filter on directly or resolve to a workflow name. ## [Answer](#answer)Copy Anchor Link The cost fields and the workflow name live in different reports, and that is the whole shape of the problem. | Report | One row is | Cost fields | Workflow name | Export | | --------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------ | ------------------------- | | Billing, CI tab ([GET /reports/billing/ci](/docs/api/reports/GetCIBilling)) | one job execution | runner\_cost, snapshot\_cost, total\_cost, billed\_time, execution\_time | absent, carries run\_id and vcs\_job\_id instead | CSV button, or format=csv | | Jobs ([GET /reports/jobs](/docs/api/reports/GetJobsReport)) | one repository, workflow, and job name combination | none | workflow\_name, plus a workflows filter | CSV button, or format=csv | | Daywise costs ([GET /jobs/daywise-costs](/docs/api/jobs/GetDaywiseCosts)) | one calendar day, whole organization | amount, cumulative\_amount | absent | JSON | So the recipe is three steps. 1. Pull CI billing rows for the period. Each row carries `repo`, `job_name`, `runner_label`, `billed_time`, `total_cost`, and the workflow run id. 2. Attach a workflow to each row. The exact join is the run id: the CI billing endpoint documents `run_ids` as the GitHub Actions workflow run id, and the same id resolves to a workflow `name` and `path` through the GitHub REST API ([workflow runs](https://docs.github.com/en/rest/actions/workflow-runs), checked on 2026-08-13). The cheaper join is `repo` plus `job_name` against the Jobs report, which already knows the workflow for each job name. 3. Sum `total_cost` per workflow. Here is what the result looks like for one repository over a month. The minute counts are an example fleet; every rate comes from the runner catalog on the [pricing page](/pricing), checked on 2026-08-13. | Workflow | Jobs in the workflow | Runner label | Billed minutes | Rate | Cost | | ----------- | -------------------- | -------------------------- | -------------- | ---------- | ------ | | ci.yml | unit-tests, lint | warp-ubuntu-latest-x64-4x | 3,200 | $0.008/min | $25.60 | | nightly.yml | integration | warp-ubuntu-latest-x64-8x | 1,500 | $0.016/min | $24.00 | | release.yml | ios-build | warp-macos-latest-arm64-6x | 900 | $0.08/min | $72.00 | | Total | 5,600 | $121.60 | | | | That table is the reason the grouping is worth doing. `release.yml` holds 16 percent of the billed minutes and carries 59 percent of the cost, because one macOS minute at $0.08 prices the same as ten minutes on `warp-ubuntu-latest-x64-4x` at $0.008\. A per-repository view averages that away. A per-workflow view puts it on the first row you read. ## [Detail](#detail)Copy Anchor Link ### [The fields that carry billed minutes](#the-fields-that-carry-billed-minutes)Copy Anchor Link Each CI billing row separates two time numbers. `execution_time` is how long the job ran, and `billed_time` is the figure the charge was computed from and the one to reconcile against an invoice. CI runners are billed on a per-minute basis ([pricing page](/pricing)), so summing `billed_time` per workflow next to `total_cost` gives you both halves of every per-workflow number: how many minutes the workflow bought, and what those minutes cost at the labels it used. Cost itself splits into `runner_cost` and `snapshot_cost` on the same row, with a `snapshot` flag marking whether the job restored a snapshot. Keep both columns in the grouping so a workflow that leans on snapshot restores does not read as a runner rate problem. The Jobs report supplies the other half of the join: one row per repository, workflow, and job name, with `run_count`, `success_rate`, and p75 and p90 for duration and queue time. CPU and memory percentiles appear on the same row when [CI observability](/docs/ci/features/observability) is enabled, which is how you tell a workflow that is expensive from volume apart from one paying for vCPU it never touches. ([cloud runners docs](/docs/ci/cloud-runners)), and every platform writes the same row shape, so one grouping covers a mixed fleet rather than needing a separate method per platform. ### [Doing the grouping through the API](#doing-the-grouping-through-the-api)Copy Anchor Link Start with the daily organization total, which is the number your per-workflow sums have to add up to. Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/jobs/daywise-costs' \ -H 'Authorization: Bearer wkey-xxxx' \ -H 'Accept: application/json' \ --data-urlencode 'start_date=2026-08-01T00:00:00Z' \ --data-urlencode 'end_date=2026-08-13T00:00:00Z' ``` The response is an array of daily points, each with the day's spend and the running total across the requested range. Copy Text ``` [ { "date": "2026-08-01", "amount": 41.28, "cumulative_amount": 41.28 }, { "date": "2026-08-02", "amount": 12.04, "cumulative_amount": 53.32 }, { "date": "2026-08-03", "amount": 96.7, "cumulative_amount": 150.02 } ] ``` Then pull the rows that carry the join keys. The CI billing endpoint takes the same RFC3339 bounds, pages at up to 200 rows, and accepts `run_ids` when you already know which runs a workflow produced, which turns "what did last night's release run cost" into one request. Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/reports/billing/ci' \ -H 'Authorization: Bearer wkey-xxxx' \ -H 'Accept: application/json' \ --data-urlencode 'start_date=2026-08-01T00:00:00Z' \ --data-urlencode 'end_date=2026-08-13T00:00:00Z' \ --data-urlencode 'repos=acme/api' \ --data-urlencode 'per_page=200' ``` Each element of `jobs.items` carries `repo`, `job_name`, `runner_label`, `run_id`, `vcs_job_id`, `billed_time`, `execution_time`, `runner_cost`, `snapshot_cost`, and `total_cost`. Group on the workflow you resolved from `run_id`, sum `total_cost`, and check the result against the daywise total for the same window before anyone builds a budget on it. The endpoints are annotated alpha and every response carries an `X-WarpBuild-API-Stability` header ([API reference](/docs/api)), so pin your script to documented fields and log that header. ### [The spreadsheet route](#the-spreadsheet-route)Copy Anchor Link Teams that would rather not write the join in code can do it with two downloads. Export the CI billing table to CSV from the download button, export the Jobs table the same way for the same date range, then look up `repo` plus `job_name` from the billing sheet against the jobs sheet to add a workflow column and pivot cost on it. Each export contains all rows matching the current filters and sort order rather than the visible page, so the two sheets line up without paging. One caveat applies to the job-name join. If two workflows in the same repository both define a job called `build`, the lookup collapses them into one line. Rename the jobs, prefix them per workflow, or fall back to the run id join, which is exact. ### [What the per-workflow number leaves out](#what-the-per-workflow-number-leaves-out)Copy Anchor Link A per-workflow runner total covers runner and snapshot cost. Remote Docker builder sessions bill separately, per session and grouped by profile or architecture, and cache storage and operations bill on a third tab. A workflow that offloads image builds to a builder profile needs those session costs added before its number is comparable to a workflow that builds on the runner. Nothing else attaches to the total. Pricing is purely usage based ([pricing page](/pricing)). There is no base subscription fee, no platform fee, and no seat fee, so the per-workflow figure moves only when minutes, rates, or job counts move, and adding a repository or an engineer changes no line on the invoice. Signup includes $10 free credits, which is enough to run a real workflow on two labels and read the rows before the first bill. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can I filter the billing report by workflow name?](#can-i-filter-the-billing-report-by-workflow-name)Copy Anchor Link The CI billing report filters on repository, runner label, stack, job name, and snapshot usage, and the API adds `run_ids` and `vcs_job_ids`. There is no workflow filter, so the workflow arrives from the join described above. For the same money cut by machine size instead of by workflow, see [cost per runner label](/answers/how-do-i-see-cost-per-runner-label). ### [How do I get cost per workflow into a spreadsheet?](#how-do-i-get-cost-per-workflow-into-a-spreadsheet)Copy Anchor Link Download the CI billing CSV and the Jobs CSV for the same date range, join them on repository plus job name, and pivot `total_cost` by workflow. The API takes `format=csv` on both endpoints when you want the same two files on a schedule. The [cost attribution guide](/guides/github-actions-cost-attribution) covers the job naming and label conventions that keep those pivots stable across repositories. ### [Do Docker builder sessions show up in the per-workflow number?](#do-docker-builder-sessions-show-up-in-the-per-workflow-number)Copy Anchor Link No. Docker builder cost is a separate billing tab, billed per session and grouped by profile or architecture. Add those sessions to the workflow total by hand when a workflow builds images remotely, and price them from the builder profiles on the [pricing page](/pricing). ### [What is the smallest unit worth measuring?](#what-is-the-smallest-unit-worth-measuring)Copy Anchor Link Below the workflow sits the pull request, which is the unit developers actually feel, and the same billing rows group that way through the run id. [CI cost per pull request](/guides/ci-cost-per-pull-request) works through that calculation with the rows and the arithmetic written out. Start from the [reports documentation](/docs/ci/features/reports) for the dashboard views, price the runner labels your workflows use on the [pricing page](/pricing), and script the exports with the [automation documentation](/docs/ci/api-keys/automation). --- # How Do I See CPU and Memory Usage for a Job? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-see-cpu-and-memory-usage-for-a-github-actions-job Description: The runner agent emits system metrics in OpenTelemetry format, and the observability Usage view draws them against the same job's GitHub Actions log timeline. # How Do I See CPU and Memory Usage for a Job? The runner agent emits system metrics in OpenTelemetry format, and the observability Usage view draws them against the same job's GitHub Actions log timeline. ## [Answer](#answer)Copy Anchor Link CPU and memory for a GitHub Actions job come from an agent running on the runner machine: the WarpBuild agent emits system metrics in OpenTelemetry format over port `33931`, and the observability Usage view draws those metrics against the same job's GitHub Actions log timeline so a spike and the step that caused it sit on one screen. GitHub Actions by itself reports run duration, queue time, job counts, and conclusion ([GitHub Actions metrics](https://docs.github.com/en/actions/concepts/metrics)), which is why the question of whether a machine ran out of RAM has to be answered somewhere other than the run page. There are three places to read the numbers, and they answer different questions. | Surface | What it shows | Read it when | | -------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------- | | Usage view | Per instance utilization charts, system logs, and the GitHub Actions logs for the job on one timeline | You have one named job that behaved badly | | Recommendations view | Peak CPU, memory, filesystem, disk I/O, and network per instance, rolled up across the fleet | You want the list of jobs that are mis-sized | | Jobs report | CPU P75 and P90, memory P75 and P90, per unique repository plus workflow plus job | One run is not enough evidence | All three are documented in the [observability reference](/docs/ci/features/observability) and the [reports reference](/docs/ci/features/reports). CI observability is one part of the WarpBuild product surface, alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger. ## [Detail](#detail)Copy Anchor Link ### [What the agent collects](#what-the-agent-collects)Copy Anchor Link The agent sends three streams: utilization metrics from the machine, system logs from the machine, and the GitHub Actions logs for the job. The third stream is what makes the first two readable, because it puts workflow execution and system behavior on one timeline instead of in two browser tabs. Five metrics are recorded per instance, and the collection method differs by metric. | Metric | Recorded as | | ---------------------- | ------------------------------------------------------------------------------ | | CPU utilization | Maximum rolling average over the last 30 seconds | | Memory utilization | Maximum | | Filesystem utilization | Maximum | | Disk I/O | Maximum rolling average of read plus write throughput over the last 30 seconds | | Network utilization | Maximum rolling average of read plus write throughput over the last 30 seconds | The rolling average matters when you read a chart. A 5 second burst of full CPU does not move a 30 second rolling average much, so a chart that never crosses 50 percent can still contain short spikes that stall a step. ### [The hierarchy the numbers are grouped under](#the-hierarchy-the-numbers-are-grouped-under)Copy Anchor Link Metrics are aggregated in four levels, and each level is a filter in the Recommendations view. | Level | Example | | ------------- | ------------------------- | | Repository | acme/api | | Workflow | ci.yml | | Job | integration-tests | | Instance type | warp-ubuntu-latest-x64-4x | That ordering is what turns a single bad run into a pattern. One job at 95 percent memory is an anecdote; the same job at 95 percent memory across every instance of `warp-ubuntu-latest-x64-4x` in `ci.yml` is a sizing decision. WarpBuild flags an instance as under provisioned when max sustained CPU, max memory utilization, or max filesystem utilization reaches 80 percent, or when max disk I/O reaches 80 percent of supported throughput. ### [A worked read: memory flat at the ceiling, CPU idle](#a-worked-read-memory-flat-at-the-ceiling-cpu-idle)Copy Anchor Link Round numbers below stand in for your own readings. The shape is what you are looking for, on a job running `warp-ubuntu-latest-x64-4x` with 4 vCPU and 16 GB of RAM. | Point in the job | CPU | Memory | Log line beside it | | ---------------------- | ---------------- | ---------------- | ---------------------------------------------------- | | Checkout and install | 30 percent | 15 percent | Dependency install | | Fixtures load | 20 percent | 45 percent | Test suite starts | | First integration test | 18 percent | 94 percent | Suite begins | | Remainder of the job | 15 to 20 percent | 95 percent, flat | Suite grinds on, or the step dies with exit code 137 | A memory line that climbs and then flattens is memory that has nowhere left to go, and flat CPU underneath it says the vCPUs are waiting rather than working. Adding parallelism here makes the job worse, because every extra worker needs its own copy of the fixtures. The move is one step up the same family. The ladder below is Linux x64 because vCPU and RAM scale together on it at a flat $0.002 per vCPU minute. Rates come from the [pricing page](/pricing), checked on 2026-08-13. | Label | vCPU | RAM | Per minute | | -------------------------- | ---- | ----- | ---------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | Work the arithmetic before you move. A 22 minute job at 400 runs per month is 8,800 minutes. * On `warp-ubuntu-latest-x64-4x` at $0.008 per minute: $70.40 per month. * On `warp-ubuntu-latest-x64-8x` at $0.016 per minute: $140.80 per month. * On `warp-ubuntu-latest-x64-2x` at $0.004 per minute: $35.20 per month. The charts decide which of those three lines you are buying. Memory pinned at 95 percent with exit code 137 in the log makes the $70.40 increase a fix. CPU at 12 percent and memory at 22 percent across every run makes the $35.20 line the correct answer and the current spend a refund waiting to happen. Reading the charts costs no runner minutes either way. ### [More than one run](#more-than-one-run)Copy Anchor Link One run is a sample. The Jobs report aggregates every run of a unique repository, workflow, and job name in the selected period and reports CPU P75, CPU P90, memory P75, and memory P90 beside run count, success rate, duration percentiles, and queue time percentiles. A P90 memory value at 95 percent with a P75 at 60 percent describes a job that only sometimes runs out of room, which is a different problem from one that always does. Every report tab exports CSV with all rows matching the current filters, so the comparison can happen in a spreadsheet. ### [When there is nothing to read](#when-there-is-nothing-to-read)Copy Anchor Link Four rules explain an empty chart. * Observability collects metrics and logs only for jobs longer than about 1 minute, so a job that fails in 20 seconds has no series. * Collection can be paused. When paused, no telemetry is collected from your runners, including system logs and GitHub Actions logs. That control is how a job that must not emit metrics stays silent. * On pooled instances, data can appear in the UI before a job is allocated. The telemetry agent initializes even while collection is paused, and no data is collected until allocation. * Port `33931` carries the data to the platform. A restrictive egress policy on a self hosted network path blocks the metrics rather than the job. No sensitive data is collected through telemetry, all observability data is encrypted in transit, and none of it is used for training. The full list of what the agent reads is in the [observability reference](/docs/ci/features/observability). ### [What looking costs](#what-looking-costs)Copy Anchor Link Full rates by runner type are on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does GitHub Actions report CPU and memory for a job on its own?](#does-github-actions-report-cpu-and-memory-for-a-job-on-its-own)Copy Anchor Link No. The run page and the metrics GitHub publishes cover run duration, queue time, job counts, and conclusion, and none of them read the machine ([GitHub Actions metrics](https://docs.github.com/en/actions/concepts/metrics)). CPU and memory come from an agent on the runner itself. WarpBuild collects them over OpenTelemetry on port `33931` and draws them against the same job's log timeline in the Usage view. The fleet wide version of that picture is [GitHub Actions observability](/solutions/github-actions-observability). ### [Why do the CPU and memory columns show a dash for some of my jobs?](#why-do-the-cpu-and-memory-columns-show-a-dash-for-some-of-my-jobs)Copy Anchor Link Observability collects metrics and logs only for jobs longer than about 1 minute, so a short job has no series to average. The Jobs report also needs Observability enabled for the runner, and it renders a dash in the CPU and memory columns when telemetry is missing while still reporting run count, success rate, duration, and queue time. The column list is in the [reports reference](/docs/ci/features/reports), and the [guide to runner level metrics](/guides/github-actions-runner-metrics) walks the same tables end to end. ### [What does a memory line flat at the ceiling with idle CPU mean?](#what-does-a-memory-line-flat-at-the-ceiling-with-idle-cpu-mean)Copy Anchor Link The job is bound by RAM and the vCPUs are waiting on it. Sizes in the WarpBuild catalog scale vCPU and RAM together, so the move is one step up the same family: `warp-ubuntu-latest-x64-4x` at 16 GB to `warp-ubuntu-latest-x64-8x` at 32 GB, which takes the list price from $0.008 to $0.016 per minute. The other four readings, and the threshold that separates a real ceiling from a busy minute, are in the answer on [detecting an undersized runner](/answers/how-do-i-detect-an-undersized-runner). The term the thresholds apply to is defined in [runner utilization](/glossary/runner-utilization). --- # How Do I Select an Xcode Version in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-select-an-xcode-version-in-github-actions Description: Pick the runner image that carries the Xcode version you want, then point the job at that toolchain with DEVELOPER_DIR or xcode-select and log xcodebuild. # How Do I Select an Xcode Version in GitHub Actions? Pick the runner image that carries the Xcode version you want, then point the job at that toolchain with DEVELOPER\_DIR or xcode-select and log xcodebuild. Selecting an Xcode version in GitHub Actions is two decisions. Pick the runner image that carries the Xcode version you want, because the image decides which toolchains exist on disk, then point the job at that toolchain by setting `DEVELOPER_DIR` at job level or running `sudo xcode-select -s` on the bundle path, and print `xcodebuild -version` as the first step so the log records which toolchain compiled the build. This page covers the label-to-Xcode mapping you choose from, the workflow syntax for both selection mechanisms, and the failure that wastes the most macOS minutes: pinning a version the image does not carry. ## [Answer](#answer)Copy Anchor Link The image is the menu and the job makes the pick. `runs-on` selects the image, so it decides which Xcode bundles are under `/Applications` before any step runs. Nothing inside the workflow installs a new Xcode in a reasonable amount of time, so a version that is absent from the image is a label change rather than a step change. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The macOS line is ARM64 and covers three images across five labels. Every row below comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Runner label | macOS image | vCPU / memory | Xcode versions on the image | Per minute | | ----------------------- | ----------- | ------------- | ------------------------------------------------------------- | ---------- | | warp-macos-26-arm64-6x | macOS 26 | 6 / 14GB | Upstream GitHub macOS 26 set, plus Xcode 27.0 (build 27A266a) | $0.08 | | warp-macos-26-arm64-12x | macOS 26 | 12 / 28GB | Upstream GitHub macOS 26 set, plus Xcode 27.0 (build 27A266a) | $0.16 | | warp-macos-15-arm64-6x | macOS 15 | 6 / 14GB | Upstream GitHub macOS 15 set | $0.08 | | warp-macos-15-arm64-12x | macOS 15 | 12 / 28GB | Upstream GitHub macOS 15 set | $0.16 | | warp-macos-14-arm64-6x | macOS 14 | 6 / 14GB | The Xcode 15 line, with 15.4 as the default | $0.08 | The published bundle inventory for each image lives in GitHub's runner-images repository: [macOS 14 ARM64](https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md), [macOS 15 ARM64](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-arm64-Readme.md), and [macOS 26 ARM64](https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md). WarpBuild images carry the same tooling as the matching GitHub-hosted image, and the readme link for each one is collected in the [preinstalled software documentation](/docs/ci/preinstalled-software). Those readmes are regenerated on every image release, so an exact patch version such as 16.2 belongs there rather than in a page that refreshes on a fixed cadence. Two labels are aliases. `warp-macos-latest-arm64-6x` and `warp-macos-latest-arm64-12x` resolve to the macOS 15 image, in sync with GitHub's `macos-latest` tag, so an Xcode-sensitive job should name the versioned label. macOS 13 runners were removed on June 8, 2026, and a workflow still naming a macOS 13 label queues without a match ([cloud runners documentation](/docs/ci/cloud-runners)). With the label chosen, this is the selection inside the job: Copy Text ``` name: ios-pr on: pull_request: branches: [main] jobs: build-and-test: runs-on: warp-macos-26-arm64-6x env: DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer steps: - name: Guard the toolchain run: | ls -1 /Applications | grep -i '^Xcode' xcodebuild -version xcrun simctl list runtimes | grep iOS - uses: actions/checkout@v4 - name: Resolve dependencies run: xcodebuild -resolvePackageDependencies -project App.xcodeproj - name: Build and test run: | xcodebuild test \ -project App.xcodeproj \ -scheme App \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=27.0' \ -resultBundlePath TestResults.xcresult - uses: actions/upload-artifact@v4 if: always() with: name: xcresult path: TestResults.xcresult ``` `DEVELOPER_DIR` set under `jobs..env` applies to every step in that job, including `xcodebuild`, `swift`, and every `xcrun` call, and job-level `env` is standard GitHub Actions syntax ([GitHub Actions workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax-for-github-actions)). The guard step runs before checkout so the toolchain question is settled while the job has done no real work. ## [Detail](#detail)Copy Anchor Link ### [Three mechanisms, one decision](#three-mechanisms-one-decision)Copy Anchor Link | Mechanism | Scope | Needs sudo | What the log shows | | ------------------------------------- | ----------------------------------- | ---------- | ---------------------------------------------------- | | DEVELOPER\_DIR under job-level env | every step in the job | no | the resolved version from xcodebuild -version | | sudo xcode-select -s as a step | the machine for the rest of the job | yes | the switch in that step, inherited by later steps | | maxim-lobanov/setup-xcode action | steps after the action runs | no | the version the action selected, from its own output | `DEVELOPER_DIR` is the smallest change and the easiest to read in review, because the path sits next to `runs-on` in the same job block. `sudo xcode-select -s /Applications/Xcode_27.0.app/Contents/Developer` is the right choice when a later step shells out to a tool that ignores the environment and reads the active developer directory instead. The [setup-xcode action](https://github.com/maxim-lobanov/setup-xcode) is a public OSS action that accepts a version spec such as `16.x` and resolves it against the bundles present on the image, which suits a matrix where each leg names a version range rather than an exact bundle. Whichever mechanism you pick, use one of them per job. A `DEVELOPER_DIR` value and an `xcode-select` call that disagree resolve differently depending on which tool reads which, and the resulting mismatch is invisible until a compile behaves differently from the local build. ### [Bundle paths are set by the image](#bundle-paths-are-set-by-the-image)Copy Anchor Link Xcode installs on the runner images live at `/Applications/Xcode_.app`, with `/Applications/Xcode.app` as the image default. The version fragment matches the bundle list in the image readme, so `Xcode_27.0.app` on macOS 26 and the versioned bundles on macOS 15 come straight from that inventory. Run `ls -1 /Applications | grep -i '^Xcode'` once on the label you plan to use and pin the exact name that prints. The macOS 26 labels are the one place where the WarpBuild image adds a bundle. `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` ship Xcode 27.0 (build 27A266a) on top of the Xcode versions in the upstream GitHub macOS 26 image, with the iOS 27.0 (24A434), tvOS 27.0 (24J360), watchOS 27.0 (24R362), and visionOS 27.0 (24M362) simulator runtimes bundled with it ([macOS 26 tooling](/docs/ci/cloud-runners#macos-26-tooling)). That addition exists while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released, so a job pinning the Xcode 27.0 path takes one edit at that switch. The per-image matrix is on the [Xcode image reference](/runners/macos/xcode-images). macOS 14 is the narrowest of the three. GitHub removed Xcode 14 and Xcode 16 from its macOS 14 images on November 4, 2024, leaving the Xcode 15 line ([actions/runner-images issue 10703](https://github.com/actions/runner-images/issues/10703)), and the default on that image points at 15.4 to match GitHub-hosted behavior ([March 2025 changelog](/docs/ci/changelog/2025-march)). A job that needs Xcode 16 or later moves up an image. ### [The failure mode: pinning a version the image does not carry](#the-failure-mode-pinning-a-version-the-image-does-not-carry)Copy Anchor Link This is the common way an Xcode pin goes wrong. The workflow names `Xcode_16.4.app`, the image carries a different patch release, and the path resolves to nothing. `xcode-select` reports an invalid developer directory for that path, and any `xcodebuild` or `xcrun` call that inherits the bad `DEVELOPER_DIR` fails the same way. A simulator pin fails in a related manner: `-destination` with `OS=27.0` finds no matching device when the runtime is absent, even though the compile succeeded. The guard step is what keeps that cheap. `ls -1 /Applications | grep -i '^Xcode'`, `xcodebuild -version`, and `xcrun simctl list runtimes` run in the first ten seconds of the job, before checkout, before dependency resolution, and before a compile that would otherwise have burned macOS minutes at $0.08 per minute on a 6 vCPU label ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). The log then carries three facts: the bundles present, the toolchain selected, and the runtimes installed. A red job with those three lines in it is a two-minute fix. A red job forty minutes into a build with a confusing compiler error is an afternoon. Two habits make the guard hold over time. Keep the guard step in the workflow permanently rather than deleting it after the first green run, since image updates are the event that invalidates a pin, and runner image changes are published in the [WarpBuild changelog](/docs/ci/changelog). Second, when the log leaves you guessing about the runner state, the Action Debugger opens an SSH session into a running workflow so you can list `/Applications` yourself ([what is WarpBuild](/docs/ci/what-is-warpbuild)). ## [Related Questions](#related-questions)Copy Anchor Link **What is the difference between DEVELOPER\_DIR and xcode-select?** `DEVELOPER_DIR` is an environment variable read by `xcodebuild`, `swift`, and `xcrun`, so setting it at job level scopes the choice to that job with no `sudo`. `sudo xcode-select -s` changes the active developer directory on the machine for the rest of the job, which affects every later step whether or not it wanted the switch. The pipeline-wide pattern, including matrix legs and release jobs, is in the [Xcode version pinning guide](/guides/xcode-version-pinning-github-actions). **Which Xcode versions can I select on a given runner label?** The ones installed on the image that label boots. Each macOS image carries the Xcode bundles of the upstream GitHub image it is built from, and the macOS 26 labels additionally ship Xcode 27.0 (build 27A266a) with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes. The per-image matrix is on the [Xcode image reference](/runners/macos/xcode-images), and the version-by-version breakdown is in [which Xcode versions GitHub Actions runners carry](/answers/what-xcode-versions-are-available-on-github-actions-runners). **Does `warp-macos-latest-arm64-6x` give me the newest Xcode?** No. The latest alias resolves to the macOS 15 image, in sync with GitHub's `macos-latest` tag, so a job on that alias gets the macOS 15 Xcode set. Pin `warp-macos-26-arm64-6x` when the job needs the macOS 26 image and Xcode 27.0\. Every label with its image, size, and rate is on the [macOS runner hub](/runners/macos). Pick a label on the [macOS runner hub](/runners/macos), confirm the Xcode set for that image on the [Xcode image reference](/runners/macos/xcode-images), then add the `DEVELOPER_DIR` line and the guard step to the job. --- # How Do I Set a Timeout on a Job? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-set-a-timeout-on-a-github-actions-job Description: Set timeout-minutes on the job and a tighter one on any step that can hang, so a stuck GitHub Actions job fails in minutes instead of billing for hours. # How Do I Set a Timeout on a Job? Set timeout-minutes on the job and a tighter one on any step that can hang, so a stuck GitHub Actions job fails in minutes instead of billing for hours. Last verified: 2026-08-13 Set `timeout-minutes` on the job as a backstop, then set a tighter `timeout-minutes` on any step that can block: a service wait, a network fetch, an integration suite. With neither key present GitHub applies a default of 360 minutes, so one hung step holds a runner for six hours and bills every minute of it. ## [Answer](#answer)Copy Anchor Link The key lives at two levels of workflow YAML, and GitHub documents each one separately. At the job level, [jobs..timeout-minutes](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidtimeout-minutes) is the maximum number of minutes to let a job run before GitHub automatically cancels it, with a default of 360\. At the step level, [jobs..steps\[\*\].timeout-minutes](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstepstimeout-minutes) is the maximum number of minutes to run the step before the process is killed, with a documented maximum of 360 on both GitHub-hosted and self-hosted runners. Both keys take positive integers (checked on 2026-08-13). This workflow uses both. The job value covers checkout, install, and anything else that stalls; the two step values sit on the parts that talk to a service. Copy Text ``` name: integration on: pull_request: jobs: integration: runs-on: warp-ubuntu-latest-x64-8x timeout-minutes: 25 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - name: Wait for the service to accept connections timeout-minutes: 4 run: ./scripts/wait-for-service.sh - name: Integration suite timeout-minutes: 12 run: npm run test:integration - name: Collect service logs if: always() run: docker compose logs > service.log - uses: actions/upload-artifact@v4 if: always() with: name: service-logs path: service.log ``` Checkout and install take about 3 minutes here. A service that never becomes ready kills the wait step at minute 7 of the job rather than at minute 360\. The two `if: always()` steps still run while the job is failing, so the logs that explain the hang land as an artifact instead of dying with the machine. Runner labels are the `warp-` tags from the [cloud runners documentation](/docs/ci/cloud-runners); the timeout keys behave identically on GitHub-hosted labels, since both keys are GitHub Actions features rather than runner features. ## [Detail](#detail)Copy Anchor Link ### [What the default costs on each runner size](#what-the-default-costs-on-each-runner-size)Copy Anchor Link A job with no `timeout-minutes` anywhere runs to the 360 minute default. Runner minutes bill on execution time, so the machine cost of one hung job is the rate multiplied by the minutes it was allowed to sit there. The per minute rates below come from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Runner label | Shape | Rate | No timeout (360 min) | Job timeout-minutes: 25 | Step timeout, stops at 7 min | | --------------------------- | ------------- | ---------- | -------------------- | ----------------------- | ---------------------------- | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008/min | $2.88 | $0.20 | $0.056 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016/min | $5.76 | $0.40 | $0.112 | | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | $0.012/min | $4.32 | $0.30 | $0.084 | | warp-windows-latest-x64-8x | 8 vCPU, 32 GB | $0.032/min | $11.52 | $0.80 | $0.224 | | warp-macos-15-arm64-6x | 6 vCPU, 14 GB | $0.08/min | $28.80 | $2.00 | $0.56 | The macOS row is the one that changes behavior on most teams. A single hung iOS job at the default bills $28.80, which is more than the 360 minute default costs on all four of the other labels combined. Now scale it. Assume 12 jobs a month hang on the integration step, on `warp-ubuntu-latest-x64-8x`: * At the 360 minute default: 12 x 360 = 4,320 minutes, or $69.12 a month for zero passing builds. * With the job backstop at 25 minutes: 12 x 25 = 300 minutes, or $4.80. * With the step timeout at 4 minutes on the wait: 12 x 7 = 84 minutes, or $1.34. The step timeout removes $67.78 a month against the default on that one job, and it removes 4,236 minutes of a developer waiting on a check that was never going to turn green. The same 4,320 minutes bill $95.04 on GitHub's 8-core Linux larger runner. `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), GitHub list price checked on 2026-08-13). The timeout is still the larger lever, since it changes the minute count rather than the rate. ### [Choosing the two values](#choosing-the-two-values)Copy Anchor Link Set the step value from the observed p95 duration of that step plus headroom, and set the job value above the sum of the steps plus a few minutes for provisioning and checkout. A value chosen from the average kills healthy slow runs, and a retried run costs more machine time than the timeout saved. Three rules cover the rest: 1. Every job needs its own key. There is no workflow level timeout, so a workflow with six jobs needs six values. 2. Matrix legs inherit the job value independently. One leg hitting the limit is cancelled on its own while the siblings continue. 3. A value above the platform ceiling has no effect past that ceiling. GitHub's [limits reference](https://docs.github.com/en/actions/reference/limits) puts job execution at 6 hours on GitHub-hosted runners and 5 days on self-hosted runners, so `timeout-minutes: 600` on a GitHub-hosted runner still ends at six hours. ### [What a timeout does not fix](#what-a-timeout-does-not-fix)Copy Anchor Link A timeout ends work. It says nothing about why the work hung, and it does nothing for a job that was slow rather than stuck. If jobs are queued instead of running, the clock has not started and no timeout applies; the [common issues documentation](/docs/ci/common-issues) covers runner group access, bot permissions, and workflow restrictions, which are the usual reasons a job sits unclaimed. For work that legitimately runs for hours, a tight timeout converts a long job into a failed job. Split it instead, following [long running jobs on GitHub Actions](/guides/long-running-jobs-github-actions). For the ordinary case of jobs that are simply slower than they need to be, start with the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions), which covers caching, runner sizing, and parallelism. The definitions and the full ceiling table sit in the [job timeout glossary entry](/glossary/job-timeout). ## [Related Questions](#related-questions)Copy Anchor Link ### [What is the default timeout for a GitHub Actions job?](#what-is-the-default-timeout-for-a-github-actions-job)Copy Anchor Link 360 minutes. GitHub's [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidtimeout-minutes) documents 360 as the default for `jobs..timeout-minutes`, so a job with no `timeout-minutes` key holds its machine for six hours before the platform cancels it. On `warp-ubuntu-latest-x64-8x` at $0.016 per minute that single hung job bills $5.76. ### [Can I set one timeout for a whole workflow?](#can-i-set-one-timeout-for-a-whole-workflow)Copy Anchor Link No. GitHub exposes `timeout-minutes` at the job level and the [step level](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstepstimeout-minutes) only, so a workflow with six jobs needs the key on all six. Matrix legs each inherit the job value independently, which means one hung leg is cancelled at the limit while its siblings keep running. The [job timeout glossary entry](/glossary/job-timeout) lists the platform ceilings that apply around both keys. ### [Does a job that hits its timeout still get billed?](#does-a-job-that-hits-its-timeout-still-get-billed)Copy Anchor Link Yes. Runner minutes bill on execution time, so a job cancelled at minute 25 bills 25 minutes and a job cancelled at the 360 minute default bills 360\. The timeout is the lever because it decides how many of those minutes exist. The same mechanic applies to every red run, as covered in [whether a failed GitHub Actions job is billed](/answers/am-i-billed-when-a-github-actions-job-fails). --- # How Do I Set Permissions for GITHUB_TOKEN? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-set-permissions-for-the-github-token Description: Declare a permissions block at workflow level with the minimum scopes every job needs, then widen it inside the one job that needs write access. # How Do I Set Permissions for GITHUB\_TOKEN? Declare a permissions block at workflow level with the minimum scopes every job needs, then widen it inside the one job that needs write access. ## [Answer](#answer)Copy Anchor Link Declare a `permissions` block at workflow level holding the minimum scopes every job needs, usually `contents: read`, then widen it with a job level block inside the single job that needs more. Both levels use the same map of scope name to `read`, `write`, or `none`, documented in [GitHub's workflow syntax reference](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#permissions), checked on 2026-08-13. One rule drives the whole design. Naming any scope in a block sets every scope left out of that block to `none`, and a job level block replaces the workflow level set outright rather than merging with it. A narrow default at the top plus one widened job therefore gives you a workflow where the publish step holds write access and the twelve test jobs around it hold none. The scope model itself is covered in [workflow permissions](/glossary/workflow-permissions). Copy Text ``` name: build on: pull_request: push: branches: [main] permissions: contents: read jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - run: npm ci - run: npm test publish: needs: test if: github.ref == 'refs/heads/main' runs-on: warp-ubuntu-latest-x64-4x permissions: contents: read packages: write steps: - uses: actions/checkout@v5 - name: Log in to the container registry run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - name: Push the image run: | docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} . docker push ghcr.io/${{ github.repository }}:${{ github.sha }} ``` The `publish` job restates `contents: read` because its own block replaced the workflow level set, so dropping that line would break `actions/checkout` inside that job while leaving the rest of the run untouched. ## [Detail](#detail)Copy Anchor Link ### [The scopes GitHub Actions jobs usually need](#the-scopes-github-actions-jobs-usually-need)Copy Anchor Link Most workflows touch four or five of the scopes below. Every level in this table comes from the [workflow syntax permissions reference](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#permissions) and the [automatic token authentication guide](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication), both checked on 2026-08-13. | Operation in the job | Scope and level | Where it appears | | ---------------------------------------------- | ----------------------------------- | --------------------------------------- | | Clone the repository with actions/checkout | contents: read | Every job that checks out code | | Push a commit or tag, create a release | contents: write | Release and version bump jobs | | Push an image to ghcr.io or publish a package | packages: write plus contents: read | The publish job only | | Post a commit status through the REST API | statuses: write | Deployment gates and external reporters | | Create or update a check run | checks: write | Test result annotators | | Comment on a pull request | pull-requests: write | Coverage and size report bots | | Request an OIDC token for cloud authentication | id-token: write | Deploy jobs assuming a cloud role | | Upload SARIF results to code scanning | security-events: write | Scanner jobs | Package publishing needs `packages: write` on top of the checkout grant, per GitHub's [publishing with GitHub Actions](https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions) documentation, checked on 2026-08-13\. Status and check writes are separate scopes, so a job that posts a commit status and also annotates a check run needs both lines. ### [The failure signature of a missing scope](#the-failure-signature-of-a-missing-scope)Copy Anchor Link A missing scope produces a 403 at the step that needed it rather than an error at the top of the run, which is why these failures often land on someone who edited nothing. The messages below map back to a single scope each. | What the failing step prints | Scope to add | | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | HTTP 403 with Resource not accessible by integration from a REST call | The scope that endpoint requires, such as pull-requests: write for a comment | | remote: Permission to owner/repo.git denied to github-actions\[bot\] followed by a 403 on push | contents: write | | A denied error from ghcr.io on docker push | packages: write | | Unable to get ACTIONS\_ID\_TOKEN\_REQUEST\_URL env variable | id-token: write | The fastest confirmation is the run log itself. Expand the `Set up job` step of the failing run and read the `GITHUB_TOKEN Permissions` list it prints, which shows the scopes that run actually received after the block, the repository default, and any organization pin resolved together. If a scope you expected reads `none`, the cause is almost always another block naming a different scope. The [OIDC hardening guide](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) covers the last row, where the token request fails before any cloud call is made. ### [What sits above the workflow file](#what-sits-above-the-workflow-file)Copy Anchor Link A workflow with no `permissions` key inherits the repository default, or the organization value when an administrator has pinned one. GitHub documents two settings for that default: permissive, which grants write on most scopes, and restricted, which grants read on `contents` and `packages` only. Writing an explicit block at workflow level removes the dependence on that setting, so the same file behaves identically in a fork, a new repository, and an organization that later tightens its default. Two limits hold whatever the file asks for. A run triggered by a pull request from a fork receives a read only token and no repository secrets, per GitHub's [secrets documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets), checked on 2026-08-13\. And a block can only narrow what the repository default already allows, so a `permissions` key never grants access an administrator has turned off. Secrets that outlive a single job belong in the patterns described in [managing secrets in GitHub Actions](/guides/github-actions-secrets-management). ### [Where the runner fits](#where-the-runner-fits)Copy Anchor Link The token is minted by GitHub Actions for the run and reaches the machine as `secrets.GITHUB_TOKEN` with the scopes already resolved, so the runner platform sits outside the decision. Moving the jobs above onto WarpBuild changes the `runs-on` value and nothing else in the file. The token behaves the same way on all four. WarpBuild does not access or store any build secrets. Secrets are stored in your source code repository and are only accessible to your runner environment, which runs in its own virtual machine created on demand and destroyed after the build, per the [runner security reference](/docs/ci/security). Public repositories need one setting change before managed runners accept their jobs: check `Allow public repositories` on the Default runner group in your GitHub organization settings, described in the [public repositories documentation](/docs/ci/public-repos). Fork pull requests on those repositories keep the read only token described above, so a narrow `permissions` block and a per job virtual machine cover the same risk from two directions. Splitting a monolithic job into narrow parallel jobs is also one of the levers in [speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link **What is the smallest permissions block a build and test workflow needs?** `permissions: contents: read` at workflow level. That covers `actions/checkout`, which is the only GitHub API access a build and test job usually makes. Jobs that call the REST API, publish a package, or request an OIDC token add their own block with the scopes from the table above. A job whose steps stay off the GitHub API entirely can take `permissions: {}`, which grants `none` on every scope, per the [workflow syntax reference](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#permissions). **Why does a step fail with Resource not accessible by integration?** The token is missing the scope that API call requires. Naming any scope sets every scope left out of that block to `none`, so adding one grant often removes another a later step depended on. Compare the API calls in the failing step against the scopes in the block that applies to that job, and read the `GITHUB_TOKEN Permissions` list in the `Set up job` step to see what the run received. The replacement rule is worked through in [workflow permissions](/glossary/workflow-permissions). **Does moving a job to a managed runner change what the token can do?** No. GitHub Actions mints `GITHUB_TOKEN` for the run and attaches the resolved scopes before any runner picks the job up, so the credential described in [GITHUB\_TOKEN](/glossary/github-token) arrives with the same grants it had on GitHub-hosted runners. The only workflow edit a runner move needs is the `runs-on` value. **Should long lived tokens replace GITHUB\_TOKEN when a scope is missing?** Usually no. A missing scope is a one line fix in the block, while a personal access token stored as a secret carries broader access, an expiry to track, and a rotation owner. Reach for OIDC with `id-token: write` when a job needs cloud credentials, and see [managing secrets in GitHub Actions](/guides/github-actions-secrets-management) for the cases where a stored credential is genuinely required. --- # How Do I Skip a Workflow for a Commit? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-skip-ci-on-a-commit Description: Put [skip ci] in the commit message to skip the push and pull request runs that commit would trigger, or gate one job with an if expression on the message. # How Do I Skip a Workflow for a Commit? Put \[skip ci\] in the commit message to skip the push and pull request runs that commit would trigger, or gate one job with an if expression on the message. ## [Answer](#answer)Copy Anchor Link Put one of five documented markers anywhere in the commit message and the runs that commit would have triggered never start: `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]`, or `[actions skip]` ([GitHub skip workflow runs documentation](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/skip-workflow-runs), checked on 2026-08-13). When only part of the pipeline should stop, leave the run alone and gate the individual job with an `if` expression on the commit message, which keeps the run present so required checks still report. The marker form covers the whole trigger. There is a second syntax for the same effect, a `skip-checks` trailer placed at the end of the message after two blank lines, written as either `skip-checks: true` or `skip-checks:true`. Git strips consecutive blank lines from a message by default, so the trailer form needs `git commit --cleanup=verbatim` to survive ([GitHub skip workflow runs documentation](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/skip-workflow-runs), checked on 2026-08-13). Copy Text ``` git commit -m "docs: fix broken link in README [skip ci]" ``` A skipped run starts no machine, so it consumes no runner minutes and reaches no invoice. WarpBuild rates are per minute on the [pricing page](/pricing), and a run that never starts bills nothing on any of them. ## [Detail](#detail)Copy Anchor Link ### [Which triggers the marker actually covers](#which-triggers-the-marker-actually-covers)Copy Anchor Link Skip instructions apply to the workflow runs that the commit containing them would trigger, and to nothing else. GitHub scopes them to two events. | Trigger | Skipped by a marker in the commit message | | ----------------------------------------------------- | ----------------------------------------- | | push | Yes | | pull\_request | Yes | | pull\_request\_target | No | | schedule | No | | workflow\_dispatch | No | | workflow\_run, repository\_dispatch, and other events | No | Two consequences follow from the scoping rule. A nightly `schedule` workflow runs on the same code the next morning whatever the last commit message said, so a marker never hides a build from a cron job. And a workflow on `pull_request_target` keeps running, which matters because that trigger is the one that carries write-scoped credentials on fork pull requests ([GitHub skip workflow runs documentation](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/skip-workflow-runs), checked on 2026-08-13). The marker also travels with the commit. GitHub scans the message of the commit that triggers the run, so rebasing a branch, squashing it, or cherry-picking a commit carries the marker text into whatever message the new commit ends up with ([GitHub skip workflow runs documentation](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/skip-workflow-runs), checked on 2026-08-13). That is the source of most surprise skips on a default branch. ### [Gating one job instead of the whole run](#gating-one-job-instead-of-the-whole-run)Copy Anchor Link The whole-run skip is blunt. Most teams that reach for it want one specific stage to stop, usually an end-to-end suite or a deploy, while lint and unit tests keep reporting. Read the message once in a cheap guard job and let the expensive jobs depend on its output. Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: guard: runs-on: warp-ubuntu-latest-x64-2x outputs: run_e2e: ${{ steps.check.outputs.run_e2e }} steps: - id: check env: PUSH_MESSAGE: ${{ github.event.head_commit.message }} PR_TITLE: ${{ github.event.pull_request.title }} run: | MESSAGE="${PUSH_MESSAGE:-$PR_TITLE}" if [[ "$MESSAGE" == *"[skip e2e]"* ]]; then echo "run_e2e=false" >> "$GITHUB_OUTPUT" else echo "run_e2e=true" >> "$GITHUB_OUTPUT" fi unit: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - run: npm ci - run: npm test e2e: needs: [guard, unit] if: needs.guard.outputs.run_e2e == 'true' runs-on: warp-ubuntu-latest-x64-16x steps: - uses: actions/checkout@v5 - run: npm ci - run: npm run test:e2e ``` Three details in that file are worth copying. The commit message goes into an environment variable before bash touches it, because interpolating an author-controlled string straight into a `run:` block is the script injection shape GitHub warns about ([secure use reference](https://docs.github.com/en/actions/reference/security/secure-use), checked on 2026-08-13). The guard job holds `warp-ubuntu-latest-x64-2x`, the smallest Linux size at $0.004 per minute ([cloud runners documentation](/docs/ci/cloud-runners)), so the check itself is close to free. And `github.event.head_commit` exists only on `push` events, which is why the script falls back to the pull request title. For a workflow that only ever runs on `push`, the one-line form is enough: Copy Text ``` deploy: if: ${{ !contains(github.event.head_commit.message, '[skip deploy]') }} ``` `contains` casts its arguments to strings and matches case-insensitively ([expressions reference](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions), checked on 2026-08-13), so `[SKIP DEPLOY]` matches too, and an absent `head_commit` reads as an empty string and lets the job run. The third option is a path filter. `paths-ignore` on the trigger keeps the marker out of commit messages entirely and decides from the diff instead, which is the durable answer for docs-only changes. [Running a job only when files change](/answers/how-do-i-run-a-job-only-when-files-change) covers that path, including the file-count and rename edge cases. ### [What a skipped run is worth](#what-a-skipped-run-is-worth)Copy Anchor Link Markers pay off on repositories where a large share of commits touch only documentation, changelogs, or generated fixtures. Take a pipeline of four jobs at 7 minutes each, so 28 runner minutes per push, and 60 docs-only pushes a month. Rates come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13. | Runner label | Shape | Per minute | 1,680 minutes a month | | -------------------------- | ------------- | ---------- | --------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | $6.72 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $26.88 | | warp-windows-latest-x64-8x | 8 vCPU, 32 GB | $0.032 | $53.76 | | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08 | $134.40 | The spread across that column is the whole decision. Skipping 60 pushes on a small Linux runner returns $6.72 a month, which is not worth a governance argument. Skipping the same pushes on a macOS pipeline returns $134.40 a month, and a matrix that fans one push across Linux, Windows, and macOS returns the sum. Price the marker against the platform the skipped jobs actually hold rather than against the cheapest row. [Working out GitHub Actions cost per pull request](/guides/ci-cost-per-pull-request) turns the same arithmetic into a per-pull-request figure, and [re-run storms and what they cost](/guides/rerun-storms-github-actions) covers the opposite problem, where the same commit runs many times. ### [The commit that needed the checks](#the-commit-that-needed-the-checks)Copy Anchor Link The failure mode is a commit that carries a marker and should not have. Someone tags a hotfix `[skip ci]` at 2am to get past a flaky test, a rebase drags a marker from a docs commit onto a code commit, or a squash merge composes its message from branch commits that each carried one. No run is created, so nothing appears in the Actions tab for that commit and no alert fires on a failure that never happened. The rule that closes it is branch protection rather than review discipline. Require a pull request before merging and require the status checks to pass. A workflow skipped by a commit message leaves its check in a pending state, and a pending required check blocks the merge until a commit without the marker pushes the workflow through ([troubleshooting required status checks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks), checked on 2026-08-13). The marker then costs an extra commit rather than an unverified merge. Direct pushes to the protected branch are the gap that matters, because a `[skip ci]` commit landing outside a pull request faces no check at all. GitHub's own guidance on the same page is to avoid requiring workflows that can be skipped. In practice that means keeping the required check in a workflow with no path filters and no marker handling, and letting that workflow fan out into the jobs you are willing to gate, which is exactly what the guard pattern above does. One diagnostic note. A skipped run and a stuck run look different. A marker means no run exists at all. A run that exists with a job sitting at queued is a runner availability problem, usually bot repository access or a runner group restriction, and the [common issues documentation](/docs/ci/common-issues) walks through both checks. `git log --grep='\[skip ci\]' --since=90.days` on the default branch is the audit that tells you how often the marker is reaching code that needed the checks. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does a skip marker stop every workflow on the commit?](#does-a-skip-marker-stop-every-workflow-on-the-commit)Copy Anchor Link No. It stops the runs that the commit would trigger through `push` and `pull_request` events. Workflows listening on `pull_request_target`, `schedule`, `workflow_dispatch`, and every other event still run ([GitHub skip workflow runs documentation](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/skip-workflow-runs), checked on 2026-08-13). The scope is also per commit: the next commit without a marker triggers everything normally. ### [Why is my pull request stuck after I pushed a commit with a skip marker?](#why-is-my-pull-request-stuck-after-i-pushed-a-commit-with-a-skip-marker)Copy Anchor Link Because the required check never reported. A workflow skipped by a commit message leaves its check pending, and a pending required check blocks the merge ([troubleshooting required status checks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks), checked on 2026-08-13). Push any follow-up commit whose message carries no marker, or amend the message and force-push the branch, and the workflow runs. ### [How do I skip one job instead of the whole run?](#how-do-i-skip-one-job-instead-of-the-whole-run)Copy Anchor Link Let the run start and gate the job. A one-line `if` expression with `contains` works when the workflow only listens on `push`; the guard job above covers `pull_request` as well by falling back to the pull request title. Either way the run exists, so required checks report and the merge is not blocked. Where the decision depends on the diff rather than the message, [running a job only when files change](/answers/how-do-i-run-a-job-only-when-files-change) is the better mechanism. Skip markers cut runs that should never have started, and everything left over is a speed problem. [Speeding up GitHub Actions](/guides/speed-up-github-actions) covers the rest of the levers, from runner size to caching. --- # How Do I Stop a Workflow From Blocking Deploys? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-stop-a-workflow-from-blocking-deploys Description: Give deploys a concurrency group of their own and leave cancel-in-progress off it, so test runs cannot queue ahead of a release or cancel one mid-flight. # How Do I Stop a Workflow From Blocking Deploys? Give deploys a concurrency group of their own and leave cancel-in-progress off it, so test runs cannot queue ahead of a release or cancel one mid-flight. ## [Answer](#answer)Copy Anchor Link Give deploys a concurrency group of their own, named for the deployment target rather than for the branch, and leave `cancel-in-progress` off that group so it keeps its default of `false`. Checks then throttle inside their own group, a release stops waiting behind a test run that happened to resolve the same group string, and a merge that lands during a deploy can no longer cancel the deploy that is already applying changes. The group name is the whole mechanism. GitHub matches on the resolved string across the repository rather than per workflow file, and the name is case insensitive, per the [concurrency section of the workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency), checked on 2026-08-13\. A checks workflow with `group: ${{ github.ref }}` and a deploy workflow with the same expression are one lane, which is how a release ends up parked behind a test run nobody was waiting on. The [concurrency group glossary entry](/glossary/concurrency-group) covers the term itself. Two files, two lanes: Copy Text ``` # .github/workflows/ci.yml name: checks on: pull_request: push: branches: [main] concurrency: group: checks-${{ github.workflow }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: test: runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/12 ``` Copy Text ``` # .github/workflows/deploy.yml name: deploy on: push: branches: [main] concurrency: group: deploy-production cancel-in-progress: false queue: max jobs: deploy: runs-on: warp-ubuntu-latest-x64-8x environment: name: production url: https://app.example.com permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.DEPLOY_ROLE_ARN }} aws-region: us-east-1 - run: terraform init -input=false - run: terraform apply -auto-approve -input=false ``` Three keys carry the behavior. `group: deploy-production` is a fixed string, so every merge to `main` lines up in one release lane no matter which branch it came from. `cancel-in-progress: false` keeps a running apply alive when the next merge arrives. `queue: max` lets up to 100 runs wait in order rather than dropping all but the newest, and it cannot be combined with `cancel-in-progress: true`. Under the default `queue: single` the lane holds one running item and one pending item, and a third arrival cancels the pending run, which is the right shape when you only ever want the newest commit deployed. The `environment` key does the second half of the work. A job bound to an environment stays in a waiting state until every protection rule on that environment passes, and GitHub dispatches it to a runner only after that, per the [environments documentation](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments), checked on 2026-08-13\. Required reviewers accept up to 6 users or teams and one approval releases the job, while a wait timer accepts 1 to 43,200 minutes. Both holds happen before the job takes a machine, so an approval that sits overnight accrues no runner minutes. The [deployment environment glossary entry](/glossary/deployment-environment) has the full rule table. ## [Detail](#detail)Copy Anchor Link ### [The failure this prevents](#the-failure-this-prevents)Copy Anchor Link Setting `cancel-in-progress: true` on a lane that contains a deploy job turns every merge into a chance to stop a release halfway. A cancelled job ends its running step where it stands, so a `terraform apply` or a schema migration leaves the target holding whatever it reached. | Time | Event | Result | | ----- | ----------------------------------------------------------------------- | ------------------------------------------------------------ | | 14:02 | Merge A to main starts deploy run 88, terraform apply begins | Run 88 running, 9 of 14 resources applied | | 14:05 | Merge B to main arrives in the same group with cancel-in-progress: true | Run 88 cancelled mid-apply, state file written by nobody | | 14:06 | Run 89 plans against that state | Plan shows drift it did not create, or blocks on a held lock | | 14:20 | An engineer reconciles state by hand | The release lands 18 minutes late with manual steps in it | The queued alternative costs the wait for run 88 to finish. That trade is why deploy lanes keep the default. ### [Three holds that look the same in the run view](#three-holds-that-look-the-same-in-the-run-view)Copy Anchor Link A deploy that has not started produces almost the same screen in every case. The distinguishing detail is where the run stops. | What you see | Hold responsible | Fix | | ---------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------- | | A Waiting banner naming a concurrency group | Group string shared with another workflow | Rename the deploy group to a fixed target name | | A queued deploy flips to Canceled when a newer merge lands | queue: single with a full lane, or cancel-in-progress: true | Set queue: max, leave cancel-in-progress at false | | A Review pending or Waiting banner naming an environment | Required reviewers or a wait timer | Expected behavior, no runner is held | | Queued with no runner assigned and no banner | Runner capacity behind the runs-on label | Add capacity or move the label | | Queued forever with no logs | Labels match zero online runners | Check the label spelling against the catalog | The last two rows are capacity rather than YAML, and editing the `concurrency` key does nothing for them. The guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) walks each symptom to its mechanism. ### [When the block is capacity](#when-the-block-is-capacity)Copy Anchor Link On GitHub-hosted runners the concurrent job ceiling is account wide, from 20 jobs on Free to 500 on Enterprise ([GitHub usage limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). A 12-shard test matrix in one repository therefore delays the deploy job in another repository on the same account, and no concurrency group in either file changes that. Jobs on `warp-` labels queue on pool capacity instead. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a checks matrix fanning out does not take the slot the deploy job needs. The scope for that statement sits in the [cloud runners documentation](/docs/ci/cloud-runners): features that are Generally Available support unlimited concurrency on Linux and Windows runners. Deploy jobs also hold production credentials, which is why the isolation model matters as much as the queue. Each runner runs in its own virtual machine, created on demand and destroyed after the build, and build secrets stay in the repository rather than with the runner provider ([security documentation](/docs/ci/security)). ### [What the two lanes cost per merge](#what-the-two-lanes-cost-per-merge)Copy Anchor Link Rates and shapes below come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13, and match the labels in the workflow files above. | runs-on label | OS | vCPU | RAM | Storage | Rate per minute | | ------------------------- | ------------ | ---- | ---- | --------- | --------------- | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32GB | 150GB SSD | $0.016 | Assume 12 checks shards at 6 minutes each and one deploy job at 9 minutes, with an approval that sits for 40 minutes before someone clicks it. | Item | Minutes | Rate | Cost | | ---------------------------------------------- | --------- | ------------------ | ------ | | Checks, 12 shards on warp-ubuntu-latest-x64-4x | 72 | $0.008 | $0.576 | | Deploy, 1 job on warp-ubuntu-latest-x64-8x | 9 | $0.016 | $0.144 | | Approval wait before dispatch | 40 | no runner assigned | $0.00 | | Total per merge | 81 billed | $0.72 | | At 120 merges to `main` a month that is $86.40\. Every deploy cancelled mid-flight and re-run adds another $0.144 in billed minutes plus whatever the manual reconciliation costs, which is the part that does not show up on an invoice. Per-minute rates for every runner type are on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [Should a deploy workflow set cancel-in-progress?](#should-a-deploy-workflow-set-cancel-in-progress)Copy Anchor Link Leave it at the default of `false` on any group that contains a deploy job. Cancelling a run that is halfway through applying infrastructure or migrating a database stops the step where it stands and leaves the target in whatever state it reached, which costs more to reconcile than a queued release costs to wait for. Use `queue: max` when every commit should ship in order, and the default `queue: single` when only the newest commit matters. The [guide to deployment jobs on GitHub Actions](/guides/deployment-jobs-github-actions) shows the full job shape. ### [Why does my deploy job wait when no other deploy is running?](#why-does-my-deploy-job-wait-when-no-other-deploy-is-running)Copy Anchor Link Three holds look identical in the run view. A concurrency group whose resolved string matches another workflow parks the run as pending, an environment protection rule holds the job before GitHub dispatches it, and a busy runner pool leaves the job queued with no runner to take it. Check the group name first, then the environment, then queue wait for the label. The mapping from symptom to mechanism is in the guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits). ### [Does a concurrency group make a deploy start sooner?](#does-a-concurrency-group-make-a-deploy-start-sooner)Copy Anchor Link No. A group only holds work back or cancels it, as the [concurrency group glossary entry](/glossary/concurrency-group) sets out. Giving deploys their own group removes the wait caused by sharing a lane with checks, and time spent waiting because every runner is busy is a separate problem with a separate fix. Protection rules on a [deployment environment](/glossary/deployment-environment) add a deliberate hold that no group setting overrides. Split the lanes, then price the deploy job against the per-minute rates on the [pricing page](/pricing). --- # How Do I Tag Runner Resources for Cost Allocation? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-tag-byoc-resources-for-cost-allocation Description: A BYOC stack tags every resource it creates, and those default keys plus custom stack tags make GitHub Actions runner spend separable in your own cloud bill. # How Do I Tag Runner Resources for Cost Allocation? A BYOC stack tags every resource it creates, and those default keys plus custom stack tags make GitHub Actions runner spend separable in your own cloud bill. ## [Answer](#answer)Copy Anchor Link Tag them at the stack. A BYOC stack applies a documented set of tags to every resource it creates, accepts custom tags that are added alongside them, and follows fixed naming patterns, so runner spend separates in your own cloud bill once you activate those keys as cost allocation tags. The [AWS BYOC configuration docs](/docs/ci/byoc/aws/config) list both the default keys and the naming conventions. These keys land on every resource a stack creates. | Key | Value | | ----------------------- | ------------------------------------- | | warpbuild-managed-by | warpbuild | | Name | {runner-id} | | warpbuild-github-org | {github-org} | | warpbuild-runner-labels | {runner-label1}, {runner-label2}, ... | | warpbuild-runner-id | {runner-id} | | warpbuild-stack-id | {stack-id} | | warpbuild-stack-name | {stack-name} | WarpBuild also adds a `managed-by: warpbuild` tag to provisioned resources, which doubles as a key for [AWS IAM tag-based access control](https://docs.aws.amazon.com/IAM/latest/UserGuide/access%5Ftags.html). On stack resources that tagging starts at CloudFormation template version 1.3 in create mode; runner instances are tagged in all cases. Names are the backstop for anything a tag filter misses. | Resource type | Naming pattern | | ---------------- | -------------- | | S3 buckets | warpbuild-\* | | EC2 instances | warp-\* | | EBS volumes | warp-\* | | Launch templates | tmpl-warp-\* | One step happens outside WarpBuild. AWS requires user-defined tag keys to be activated as cost allocation tags in the Billing console before they appear in cost reports, and states that activation can take up to 24 hours ([AWS cost allocation tags](https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/activating-tags.html), checked 2026-08-13). Activated keys show up in the Cost and Usage Report with a `user:` prefix, so `warpbuild-runner-labels` becomes `user:warpbuild-runner-labels` there. BYOC runs on AWS, GCP, and Azure. Custom resource tags are available on AWS today, and the GCP and Azure BYOC configuration docs list resource tagging under coming soon, so plan cloud-side attribution on those two around project and subscription boundaries for now ([feature matrix](/docs/ci/feature-matrix), checked 2026-08-13). ## [Detail](#detail)Copy Anchor Link ### [What varies per instance and what varies per stack](#what-varies-per-instance-and-what-varies-per-stack)Copy Anchor Link Custom tags are a stack-level setting, so their values are identical on every resource that stack creates. The keys that change per job are the default ones: `Name`, `warpbuild-runner-id`, and `warpbuild-runner-labels`. That split decides the whole scheme. A dimension that changes inside one stack has to ride on the runner configuration; a dimension that needs its own tag key and value needs its own stack. `warpbuild-runner-labels` holds a comma-joined list, so a runner configuration carrying three labels produces one tag value with three tokens in it, and grouping in Cost Explorer treats that string as a single value. Keep one label per custom runner if you intend to group on this key. ### [A tag scheme that survives a shared account](#a-tag-scheme-that-survives-a-shared-account)Copy Anchor Link | Dimension | Where it comes from | How you read it | | ---------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------- | | Team | Team name inside the custom runner name, for example warp-custom-platform-use1-8x | Group EC2 and EBS on warpbuild-runner-labels or Name | | Cost center | Custom tag on the stack, for example cost-center: platform | Group on cost-center, one stack per cost center | | Region and environment | Stack, since region is fixed at creation | Group on warpbuild-stack-name | | Runner class | Instance size in the runner name, for example \-8x versus \-16x | Group on warpbuild-runner-labels | | Repository | Not present in the cloud tags | Read the repository column in the WarpBuild CI billing report | The last row is the one that catches teams out. A runner instance is created before GitHub assigns it a job, so no cloud tag on that instance can carry the repository the job came from. Per-repository allocation has two routes. Give a repository its own custom runner and the label becomes the proxy key, or take the repository split from the [reports](/docs/ci/features/reports), where every CI billing row carries repository, job name, runner label, stack, execution time, and billed time. Cache storage is the exception, because the stack bucket lays objects out by repository: `//artifact_cache/////`. Per-repository storage attribution comes from prefix reporting on that bucket rather than from a tag. A 7-day lifecycle policy is the documented recommendation, and it caps how large that line gets before you split it. ### [Reconciling the WarpBuild bill against the cloud bill](#reconciling-the-warpbuild-bill-against-the-cloud-bill)Copy Anchor Link Run the two sides on the same date range. On the WarpBuild side, filter the CI tab of the Billing report by stack and export the CSV, which contains every row matching the current filters rather than the visible page. On the cloud side, filter Cost Explorer on `warpbuild-stack-name` for the same stack. Take one stack that billed 150,000 runner minutes in a month. BYOC runners cost $0.002 per minute for Linux and $0.002 per minute for Windows ([pricing page](/pricing), checked 2026-08-13). | Line | Billed by | How to find it | Amount | | -------------------- | ------------------ | ----------------------------------------- | ----------------------- | | Runner minutes | WarpBuild | Billing report, CI tab, filtered by stack | 150,000 x $0.002 = $300 | | EC2 instance hours | Your cloud account | warpbuild-stack-name in Cost Explorer | Your instance rates | | EBS volumes | Your cloud account | Same tag, or warp-\* names | Your volume rates | | Standby disk volumes | Your cloud account | Same tag, no matching billed minute | Your volume rates | | NAT gateway and S3 | Your cloud account | Stack resources, shared across jobs | Your rates | Three lines will not match one to one, and knowing which ones saves the argument with finance. [Standby disk](/docs/ci/byoc/standby-disks) VMs are billable for about 90 seconds at boot, after which the VM stops and the network disk keeps billing, and WarpBuild does not charge for standby disks at all. NAT gateway processing and bucket storage serve every job in the stack, so they are allocated by share rather than measured per job. Instance time and billed job time diverge by the boot and teardown window around each job. macOS never appears in the cloud bill. MacOS runs on WarpBuild-hosted capacity, so those minutes stay on the WarpBuild invoice only. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, which keeps the WarpBuild side of the reconciliation to billed minutes multiplied by the per-minute rate. Signup includes $10 free credits, enough to run a tagged stack against one repository and check that both reports line up before the first invoice. The full walkthrough is on the [AWS BYOC page](/byoc/aws), and [cost allocation tags for BYOC runners](/byoc/aws/cost-allocation-tags) covers the console steps key by key. ## [Related Questions](#related-questions)Copy Anchor Link ### [Which tags does a BYOC stack apply by default?](#which-tags-does-a-byoc-stack-apply-by-default)Copy Anchor Link Every resource a stack creates carries `warpbuild-managed-by`, `Name` set to the runner id, `warpbuild-github-org`, `warpbuild-runner-labels`, `warpbuild-runner-id`, `warpbuild-stack-id`, and `warpbuild-stack-name`. WarpBuild also adds `managed-by: warpbuild`, usable as a key for IAM tag-based access control. Custom tags set on the stack are added to everything the stack creates, per the [AWS BYOC configuration docs](/docs/ci/byoc/aws/config). ### [How do I split runner spend by team in one shared AWS account?](#how-do-i-split-runner-spend-by-team-in-one-shared-aws-account)Copy Anchor Link Encode the team in the custom runner name, since the runner id becomes the `Name` tag and the label list becomes `warpbuild-runner-labels` on each instance. Activate that key as a cost allocation tag and Cost Explorer groups EC2 and EBS spend by runner label. Custom tag values are fixed per stack, so a tag key that must carry a per-team value needs one stack per team. The [cost attribution guide](/guides/github-actions-cost-attribution) pairs this with the report-side keys. ### [Why does my cloud bill not match the WarpBuild billed minutes?](#why-does-my-cloud-bill-not-match-the-warpbuild-billed-minutes)Copy Anchor Link Three lines have no matching billed minute. Standby disk VMs are billable for about 90 seconds at boot and the network disk keeps billing while the VM sits shut down, and WarpBuild does not charge for standby disks. NAT gateway processing and the stack bucket are shared across all jobs. Instance hours run on wall clock while WarpBuild bills the job, so boot and teardown sit outside the runner minutes. The [BYOC cost model](/byoc/cost-model) works the whole month through with those lines named. --- # How Do I Use include and exclude in a Matrix? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-use-a-matrix-with-include-and-exclude Description: exclude drops combinations from the matrix product and include merges keys into existing combinations or appends new ones. GitHub applies exclude first. # How Do I Use include and exclude in a Matrix? exclude drops combinations from the matrix product and include merges keys into existing combinations or appends new ones. GitHub applies exclude first. In a GitHub Actions matrix, `exclude` removes combinations from the product and `include` either merges extra keys into combinations that already exist or appends combinations the product never produced. GitHub applies every `exclude` entry first and every `include` entry afterwards, which is why an `include` entry can add back a combination that `exclude` just dropped ([GitHub Actions workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link Read the block in the order the runner evaluates it rather than the order it appears in the file. Five behaviors cover the whole feature, and three of them belong to `include`. | Key | What you write | Effect on the job set | | ----------------------------------------------------- | ---------------------------- | ----------------------------------------------------------- | | matrix. | A list of values | The product of every list is the starting set of jobs | | exclude | A partial combination | Drops every job whose values match all keys in the entry | | include entry with no original matrix keys | An object of extra keys | Merges those keys into every combination, count unchanged | | include entry whose original keys match a combination | An object of extra keys | Merges into the matching combinations only, count unchanged | | include entry that would overwrite an original value | An object naming a new value | Appended as one new combination, count plus one | The rule under the last two rows is a single sentence in the GitHub reference: an object is added to a combination when none of its key and value pairs overwrite an original matrix value, and an object that fits no combination becomes a new one. Values added by an earlier `include` entry can be overwritten by a later one, which is what makes default-then-override routing work. Here is one matrix that uses both keys. Copy Text ``` name: test on: pull_request: branches: [main] jobs: test: runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: suite: [unit, contract, browser] arch: [x64, arm64] exclude: - suite: browser arch: arm64 include: - runner: warp-ubuntu-latest-x64-4x - arch: arm64 runner: warp-ubuntu-latest-arm64-4x - suite: browser runner: warp-ubuntu-latest-x64-8x - suite: smoke arch: x64 runner: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - run: ./scripts/test.sh --suite ${{ matrix.suite }} ``` Three suites multiplied by two architectures is six combinations. The `exclude` entry matches `browser` on `arm64` and drops it, leaving five. The first `include` entry names no original key, so it adds `runner` to all five. The second matches the two `arm64` cells and overwrites the added value there. The third matches the one remaining `browser` cell and overwrites it again. The fourth names a suite value that no dimension holds, so it cannot merge anywhere and is appended. | suite | arch | runner | How the cell got there | | -------- | ----- | --------------------------- | ---------------------------------------------- | | unit | x64 | warp-ubuntu-latest-x64-4x | Product, default runner from include entry 1 | | unit | arm64 | warp-ubuntu-latest-arm64-4x | Product, runner overwritten by include entry 2 | | contract | x64 | warp-ubuntu-latest-x64-4x | Product, default runner from include entry 1 | | contract | arm64 | warp-ubuntu-latest-arm64-4x | Product, runner overwritten by include entry 2 | | browser | x64 | warp-ubuntu-latest-x64-8x | Product, runner overwritten by include entry 3 | | smoke | x64 | warp-ubuntu-latest-x64-2x | Appended by include entry 4 | | browser | arm64 | none | Removed by exclude | Six jobs run from a block that reads like five combinations plus some annotations. ## [Detail](#detail)Copy Anchor Link ### [Why an include entry sometimes adds a job](#why-an-include-entry-sometimes-adds-a-job)Copy Anchor Link Every surprise in that table comes from one test: can this object be merged without changing a value the product already fixed. `{ arch: arm64, runner: ... }` passes for the two `arm64` cells because `arch` already holds `arm64` there and `runner` is new. `{ suite: smoke, arch: x64, runner: ... }` fails everywhere because no cell has `suite` set to `smoke`, so it lands as a sixth job. Two habits keep the count predictable. Write the `include` entries that add keys before the ones meant to append combinations, since reading order then matches evaluation order. And keep appended entries complete: an appended combination only carries the keys its own object names, so a step that reads `${{ matrix.arch }}` gets an empty string when the appended entry forgot that key. The ceiling is fixed. A matrix generates a maximum of 256 jobs per workflow run, counted after `exclude` and `include` are applied ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). A generated dimension that grows past that fails the run instead of truncating, which the [matrix explosion guide](/guides/github-actions-matrix-explosion) covers. ### [Routing each combination to a runner label](#routing-each-combination-to-a-runner-label)Copy Anchor Link `runs-on` reads a matrix value like any other expression, so `include` is the cleanest place to attach a machine to a combination. The pattern above sets one default label for the whole grid and then overrides the cells that need something else, which keeps the dimensions describing work and the `include` block describing hardware. | Cell | Runner label | Shape | | ----------------------------- | --------------------------- | ------------------------ | | Default for every combination | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB, 150GB SSD | | Every arm64 cell | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB, 150GB SSD | | The browser cell | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB, 150GB SSD | | The appended smoke cell | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB, 150GB SSD | Shapes come from the [cloud runners documentation](/docs/ci/cloud-runners). The same pattern crosses platforms: an `include` entry can point one cell at `warp-macos-15-arm64-6x` and another at `warp-windows-latest-x64-4x` while the steps stay identical. Sizing each cell separately is one of the levers in the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions), and the [matrix builds guide](/guides/github-actions-matrix-builds) works through how wide the grid should be before you tune the labels. ### [Printing the resolved matrix before the jobs run](#printing-the-resolved-matrix-before-the-jobs-run)Copy Anchor Link Reason about the block once, then confirm it from the run. Add a step that prints the resolved values for the cell it runs in. Copy Text ``` - name: show this cell run: | echo 'matrix: ${{ toJSON(matrix) }}' echo 'cell ${{ strategy.job-index }} of ${{ strategy.job-total }}' ``` `toJSON(matrix)` prints every key the cell actually received, including the ones `include` merged in, and `strategy.job-total` prints the size of the resolved set ([GitHub Actions contexts](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts), checked on 2026-08-13). A cell whose `runner` key prints empty explains a job that queues forever, and a `job-total` that differs from your count points straight at an `include` entry that appended instead of merging. After the run, the Jobs report aggregates run count and duration percentiles per unique repository, workflow, and job name combination ([reports documentation](/docs/ci/features/reports)), so a cell that was supposed to disappear shows up as a row that kept collecting runs. ### [What the edit costs or saves](#what-the-edit-costs-or-saves)Copy Anchor Link Both keys move billed minutes. Assume the six cells above run at these durations. | Cell | Duration | Billed minutes per run | | ----------------- | -------------------- | ---------------------- | | unit \+ x64 | 5 minutes | 5 | | unit \+ arm64 | 5 minutes | 5 | | contract \+ x64 | 4 minutes | 4 | | contract \+ arm64 | 4 minutes | 4 | | browser \+ x64 | 9 minutes | 9 | | smoke \+ x64 | 2 minutes | 2 | | Total | wall clock 9 minutes | 29 | The single `exclude` entry keeps a seventh 9 minute cell out of every run, which is 4,500 minutes a month at 500 pull request runs. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does include run before or after exclude in a GitHub Actions matrix?](#does-include-run-before-or-after-exclude-in-a-github-actions-matrix)Copy Anchor Link GitHub processes every `exclude` entry first and every `include` entry afterwards, so an `include` entry can add back a combination that `exclude` removed. Count the product, subtract the exclusions, then apply the inclusions in the order they appear in the file. The [build matrix entry](/glossary/build-matrix) covers the surrounding keys, including `fail-fast` and `max-parallel`. ### [Why did my include entry create an extra job instead of adding a key?](#why-did-my-include-entry-create-an-extra-job-instead-of-adding-a-key)Copy Anchor Link An `include` object is merged into a combination only when none of its keys overwrite an original matrix value. An entry that names a value no dimension holds cannot merge into any combination, so it is appended as one new job. Print `${{ toJSON(matrix) }}` in a step to see which keys each cell received, and check `strategy.job-total` against your own count. ### [How do I send each matrix combination to a different runner label?](#how-do-i-send-each-matrix-combination-to-a-different-runner-label)Copy Anchor Link Put a `runner` key in the `include` entries and set `runs-on: ${{ matrix.runner }}`. A bare `include` object with no original matrix keys sets the default label for every combination, and a later entry that matches a subset overwrites that added value for those cells only. Labels and shapes are listed in the [cloud runners documentation](/docs/ci/cloud-runners), and the [matrix explosion guide](/guides/github-actions-matrix-explosion) covers what to do when the grid itself is the problem. --- # How Do I Use Docker Bake in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-use-docker-bake-in-github-actions Description: Define each image as a target in a bake file, then call the bake action once. One invocation builds every target against one builder and one layer cache. # How Do I Use Docker Bake in GitHub Actions? Define each image as a target in a bake file, then call the bake action once. One invocation builds every target against one builder and one layer cache. ## [Answer](#answer)Copy Anchor Link Define each image as a target in a `docker-bake.hcl` file, commit it, and call the bake action once from the workflow: one invocation resolves the file, expands the targets or the group you name, and builds all of them against one builder. On WarpBuild, that means swapping `docker/bake-action` for `Warpbuilds/bake-action` and adding a `profile-name` input that points the run at a remote Docker builder profile, which the [Docker builders documentation](/docs/ci/docker-builders) documents as a drop-in replacement. The reason to reach for bake instead of a matrix of build jobs is the count of round trips. A matrix runs one GitHub Actions job per image, and every one of those jobs checks out the repository, requests its own builder, waits for that builder to report ready, and runs its own post-job steps. One bake invocation does that once for the whole set. [Docker Bake, defined](/glossary/docker-bake) covers the file format itself: targets, groups, variables, and the `inherits` attribute that keeps shared fields in one place. The builder that bake dispatches to is a separate resource from the runner that drives it. [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) is the wider pattern this page sits inside. ## [Detail](#detail)Copy Anchor Link ### [A bake file with two targets sharing arguments and cache settings](#a-bake-file-with-two-targets-sharing-arguments-and-cache-settings)Copy Anchor Link Both images below build from the same repository root and the same language toolchain version, so those fields live on a `shared` target that nothing builds directly. Docker documents the attributes and the inheritance rules in the [bake file reference](https://docs.docker.com/build/bake/reference/). Copy Text ``` variable "REGISTRY" { default = "ghcr.io/example/platform" } variable "TAG" { default = "dev" } variable "CACHE_REF" { default = "ghcr.io/example/platform/buildcache" } group "default" { targets = ["gateway", "indexer"] } target "shared" { context = "." platforms = ["linux/amd64"] args = { GO_VERSION = "1.24" } cache-from = ["type=registry,ref=${CACHE_REF}"] cache-to = ["type=registry,ref=${CACHE_REF},mode=max"] } target "gateway" { inherits = ["shared"] dockerfile = "services/gateway/Dockerfile" tags = ["${REGISTRY}/gateway:${TAG}"] } target "indexer" { inherits = ["shared"] dockerfile = "services/indexer/Dockerfile" tags = ["${REGISTRY}/indexer:${TAG}"] } ``` `docker buildx bake --print` renders the resolved definition as JSON without building anything, which is the fastest way to check that `TAG` and `inherits` expanded the way you expected before a workflow run proves otherwise. The two cache attributes are worth reading closely, because they are the lines that come out once a remote builder runs these targets. The [Docker builders documentation](/docs/ci/docker-builders) states that `cache-to` and `cache-from` are not required with WarpBuild builders: the builder caches the layers on its own disk and reuses them on later builds. Leaving a registry export in place pays to push and pull layers the builder is already holding. When part of your build set still runs on a runner's local BuildKit, keep the attributes behind a bake variable so the same file serves both paths. ### [The workflow step](#the-workflow-step)Copy Anchor Link Copy Text ``` name: images on: push: branches: [main] pull_request: jobs: images: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build every target uses: Warpbuilds/bake-action@v6 env: TAG: ${{ github.sha }} with: files: ./docker-bake.hcl targets: default push: ${{ github.event_name == 'push' }} profile-name: "platform-builder" timeout: 600000 ``` Three inputs carry the WarpBuild-specific behavior. `profile-name` selects the builder profile, which is one dedicated builder virtual machine with its own layer cache. `timeout` is the wait in milliseconds for that builder to report ready and defaults to 10 minutes, per the [Docker builders documentation](/docs/ci/docker-builders). On a runner WarpBuild does not operate, including GitHub-hosted runners and a local terminal, add `api-key: ${{ secrets.WARPBUILD_API_KEY }}` so the action can request a builder assignment. The `docker/setup-buildx-action` step is absent on purpose. The [Docker builders documentation](/docs/ci/docker-builders) recommends removing it when its only role was setting up a builder, because the bake action sets one up for you. Building a single image on demand needs the same step with a different target name rather than a second workflow: Copy Text ``` - uses: Warpbuilds/bake-action@v6 with: files: ./docker-bake.hcl targets: gateway profile-name: "platform-builder" set: | gateway.platform=linux/amd64 ``` ### [When bake beats a matrix of separate build jobs](#when-bake-beats-a-matrix-of-separate-build-jobs)Copy Anchor Link Two behaviors documented on the [Docker builders documentation](/docs/ci/docker-builders) decide this. First, every job that calls a build action requests its own builder and waits for it to report ready, so the number of attach waits per pipeline run equals the number of jobs. Second, the cache on a builder profile is shared between concurrent builds but eventually consistent, so a layer produced by one in-flight build may not be visible to another build running at the same moment and becomes available to later builds after synchronization. Six matrix jobs that fire together on a cold profile can therefore each build the same shared base stage. One bake invocation resolves that stage once inside a single BuildKit run and reads it from cache for every later target. | Dimension | Matrix of six build jobs | One bake target group | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | | Builder attach waits per run | 6, one per job | 1 for the whole run | | Runner jobs billed | 6 | 1 | | Shared base stage on a cold profile | Concurrent jobs can each build it, because the profile cache is eventually consistent between in-flight builds | Built once in the run and reused by later targets | | Builder sessions | 1, shared, billed from the first job's builder start to the last job's completion | 1 | | Where tags and build args live | Repeated per matrix entry or per action input | In the bake file, inherited from a shared target | | Effect of one image failing | Remaining jobs keep going | The invocation fails, and a rerun rebuilds the group | That last row is the case for keeping a matrix. A repository where one flaky image should not block the other five is better served by separate jobs, and bake still helps inside each of them through `targets`. Here is the arithmetic on a full month. Assumptions are stated so you can substitute your own measurements: 300 pipeline runs per month, six images per run, a `warp-ubuntu-latest-x64-4x` runner at $0.008 per minute, and one builder profile at 32 vCPU, 64GB, 200GB disk at $0.12 per minute. Each matrix job holds its runner for 5 minutes across checkout, attach, dispatch, and post-job steps; the single bake job holds its runner for 8 minutes. The builder session runs 6 minutes per pipeline run in both shapes. Rates come from the [pricing page](/pricing) and the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13. | Line | Rate | Matrix of six jobs | One bake job | | --------------------------------- | ----------------- | ------------------ | ------------ | | Runner minutes per month | $0.008 per minute | 9,000 | 2,400 | | Runner cost per month | $72.00 | $19.20 | | | Builder session minutes per month | $0.12 per minute | 1,800 | 1,800 | | Builder cost per month | $216.00 | $216.00 | | | Total per month | $288.00 | $235.20 | | The builder line does not move, and that is the honest shape of this comparison: concurrent jobs on one profile already share a single session, so a matrix does not multiply builder time. The $52.80 monthly difference comes off the runner line, from five jobs that stop existing. Duplicate base-stage work on a cold profile sits on top of that and depends on how much your images share, which is why the [monorepo Docker builds guide](/guides/monorepo-docker-builds-github-actions) treats change detection and target selection as the next lever after bake itself. The [remote Docker builder catalog](/runners/docker-builders) lists every profile size with its rate, and the [caching documentation](/docs/ci/features/caching) covers the separate WarpBuild cache for dependency directories, which stays useful alongside a builder. ## [Related Questions](#related-questions)Copy Anchor Link ### [Do I still need docker/setup-buildx-action when I run bake?](#do-i-still-need-dockersetup-buildx-action-when-i-run-bake)Copy Anchor Link No, when the only job that step did was create a builder. The WarpBuild bake action sets up the remote builder for the run, so the setup step comes out of the workflow. Keep it only when you configure a driver or a builder that the action does not create for you. The [Docker builders documentation](/docs/ci/docker-builders) shows the diff for both the bake action and the build and push action. ### [Can one bake invocation build several architectures?](#can-one-bake-invocation-build-several-architectures)Copy Anchor Link Yes. Put the architectures in the `platforms` attribute of a target, and enable both on the builder profile first. Each architecture runs on a separate builder instance, so a multi-arch run opens one session per architecture and both are billed independently. arm64 and multi-arch profiles cap at 64 vCPU, so the largest profile sizes are amd64 only. A profile missing an architecture a target requests produces an `exec format error`, which is the first thing to check when an arm64 target fails on a profile that used to be amd64 only. The [remote Docker builder catalog](/runners/docker-builders) shows which sizes carry which architectures. ### [Should I keep cache-from and cache-to in the bake file?](#should-i-keep-cache-from-and-cache-to-in-the-bake-file)Copy Anchor Link Not for targets that run on a remote Docker builder. The builder holds the layers on its own disk and reuses them for later builds, so the cache export and import move layers the builder already has. Keep the attributes behind a bake variable when some targets still build on the runner's local BuildKit, so one file serves both paths. [Docker Bake, defined](/glossary/docker-bake) covers variables and overrides, and [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) covers the workflow patterns around them. --- # How Do I Write a Composite Action? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-write-a-composite-action Description: Create an action.yml that sets runs.using to composite, list the steps, then call it with uses. Full example, the shell rule, and when to pick one. # How Do I Write a Composite Action? Create an action.yml that sets runs.using to composite, list the steps, then call it with uses. Full example, the shell rule, and when to pick one. Create an `action.yml` file that sets `runs.using` to `composite` and lists your steps under `runs.steps`, then call it from a job with `uses` pointing at either a path inside the repository or an `owner/repo@ref` reference. Add a `shell` key to every step that sets `run`, because GitHub requires it in composite actions and a missing one stops the workflow before the first command executes. ## [Answer](#answer)Copy Anchor Link The action is a directory. Inside it sits a metadata file named `action.yml` or `action.yaml`, and GitHub names `action.yml` as the preferred spelling. The directory path is what a workflow references, so `.github/actions/go-build/action.yml` is addressed as `./.github/actions/go-build`. Six keys carry the whole format ([metadata syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax), checked on 2026-08-13). | Key | Required | What it does | | ---------------------- | ----------------------------- | ----------------------------------------------------------------------- | | name | Yes | Names the action and labels the collapsed step in the run log | | description | Yes | One line describing what the step sequence does | | inputs. | No | Declares a parameter, read inside the action as ${{ inputs. }} | | outputs..value | Yes for any declared output | Maps the output to a step output such as ${{ steps.test.outputs.path }} | | runs.using | Yes | Set to composite to select this action type | | runs.steps\[\*\].shell | Yes on any step that sets run | Picks the shell for that command | Three reference forms reach the finished action. `./path/to/action` reads it from the caller's checked out workspace, so `actions/checkout` has to run first. `owner/repo@ref` fetches it from another repository at a tag, branch, or commit SHA, and GitHub recommends pinning to a SHA. The `$/path/to/action` self repository form resolves to the running commit with no prior checkout and is unavailable on GitHub Enterprise Server ([workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#example-using-an-action-in-the-same-repository-as-the-workflow-at-the-running-commit-recommended), checked on 2026-08-13). One structural fact shapes everything you write inside `runs.steps`: the action executes on the machine the calling job already booked. ([cloud runners catalog](/docs/ci/cloud-runners)), and the same `action.yml` has to survive on each platform your workflows target, which is why the `shell` value deserves a moment of thought rather than a reflexive `bash` on every step. ## [Detail](#detail)Copy Anchor Link ### [A complete action.yml with inputs, outputs, and two run steps](#a-complete-actionyml-with-inputs-outputs-and-two-run-steps)Copy Anchor Link This action installs a Go toolchain, builds one package, and runs its tests. It lives at `.github/actions/go-build/action.yml`. Copy Text ``` name: Build and test a Go package description: Install Go with dependency caching, build one package, and run its tests inputs: go-version: description: Go toolchain version to install required: false default: "1.22" package-path: description: Directory holding the package to build and test required: true test-flags: description: Extra flags appended to go test required: false default: "-race" outputs: binary-path: description: Path to the compiled binary, relative to the workspace value: ${{ steps.build.outputs.binary-path }} coverage-file: description: Path to the coverage profile written by the test step value: ${{ steps.test.outputs.coverage-file }} runs: using: "composite" steps: - uses: WarpBuilds/setup-go@v6 with: go-version: ${{ inputs.go-version }} cache: true - name: Build id: build shell: bash working-directory: ${{ inputs.package-path }} run: | mkdir -p "$GITHUB_WORKSPACE/dist" go build -o "$GITHUB_WORKSPACE/dist/app" . echo "binary-path=dist/app" >> "$GITHUB_OUTPUT" - name: Test id: test shell: bash working-directory: ${{ inputs.package-path }} run: | go test ${{ inputs.test-flags }} -coverprofile="$GITHUB_WORKSPACE/coverage.out" ./... echo "coverage-file=coverage.out" >> "$GITHUB_OUTPUT" ``` Four wiring details in that file are worth naming. Inputs arrive through the `inputs` context and nothing else, since GitHub does not set the `INPUT_` environment variables for composite actions that Docker container and JavaScript actions receive ([metadata syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax), checked on 2026-08-13). Each output reaches the caller only because `value` maps it to a step `id`, and an output block without a `value` hands back an empty string. Both `run` steps write their results to `$GITHUB_OUTPUT` in `key=value` form, which is what makes those `steps.build.outputs` references resolve. And the first step is a `uses` step, so `runs.steps` can mix published actions with your own commands; here it pulls a cache-enabled toolchain action from the [setup actions documentation](/docs/ci/features/caching/setup-actions). ### [Calling it from a job](#calling-it-from-a-job)Copy Anchor Link Copy Text ``` name: build on: pull_request: jobs: api: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - id: go uses: ./.github/actions/go-build with: package-path: services/api test-flags: "-race -count=1" - name: Upload coverage uses: actions/upload-artifact@v4 with: name: coverage-api path: ${{ steps.go.outputs.coverage-file }} ``` The checkout step comes first because the `./` reference reads the action out of the workspace. The `id: go` line is what lets a later step read `steps.go.outputs.coverage-file`. Everything else about the job is unchanged, including the runner label: swapping `runs-on` is a one-line edit that the composite action never sees ([quick start](/docs/ci/quick-start)). That label decides both the machine and the rate. `warp-ubuntu-latest-x64-2x` is 2 vCPU and 8 GB at $0.004 per minute, and `warp-ubuntu-latest-x64-4x` is 4 vCPU and 16 GB at $0.008 per minute, from the [pricing page](/pricing), checked on 2026-08-13. ### [The shell key, and the three errors that follow it](#the-shell-key-and-the-three-errors-that-follow-it)Copy Anchor Link 1. A `run` step without `shell` fails validation. GitHub documents `runs.steps[*].shell` as required whenever `run` is set, and the job reports the missing required property before any command executes. Use `bash` on Linux and macOS runners and `pwsh` on Windows runners, or set `shell: bash` everywhere only after confirming every target platform in your matrix has it. 2. A `${{ secrets.NAME }}` expression inside `action.yml` resolves to an empty string. The secrets context is unavailable to composite actions, so the caller reads the secret and passes it in through `with`, and the action reads it as an ordinary input. 3. A relative path to a file shipped next to `action.yml` resolves against the job workspace. Address bundled scripts through `${{ github.action_path }}` or `$GITHUB_ACTION_PATH`, and remember that `working-directory` on a step defaults to the workspace rather than the action directory. 4. A declared output with no `value` key silently returns an empty string to the caller, which is the failure that looks like a bug in the consuming step. ### [When a composite action is the right unit](#when-a-composite-action-is-the-right-unit)Copy Anchor Link | Question | Composite action | Reusable workflow | | --------------------------- | ------------------------------------------ | ------------------------------------------------- | | What you package | A sequence of steps | One or more whole jobs | | Where the caller invokes it | A step, with uses in steps | A job, with uses at job level | | Runner selection | Inherits the caller job's machine | Declares its own runs-on, or takes it as an input | | Secrets | Passed as inputs with with | secrets: mapping or secrets: inherit | | Run log | Collapsed into one step | Every job and step logged separately | | Depth limit | Up to 10 composite actions in one workflow | Up to 4 levels of nested calls | Source: GitHub's comparison of the two mechanisms ([reusing workflow configurations](https://docs.github.com/en/actions/concepts/workflows-and-actions/reusing-workflow-configurations)) and the [limits reference](https://docs.github.com/en/actions/reference/limits), both checked on 2026-08-13. Read the runner row first. Shared logic that has to run somewhere the caller did not pick, such as a signing step that needs macOS while the caller sits on Linux, belongs in a reusable workflow. Shared logic that runs on the caller's machine and reuses its checkout, its cache, and its environment belongs in a composite action. The log row decides the rest: a composite action of twenty steps turns into one opaque line in the run view, so long sequences are easier to operate as a reusable workflow with real job boundaries. The [reusable workflows guide](/guides/reusable-workflows-at-scale-github-actions) covers versioning and rollout once shared configuration spans an organization. Cost enters through the caller, since the composite action bills as part of the job it runs in. For the wider set of levers on job duration, see [how to speed up GitHub Actions](/guides/speed-up-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does every step in a composite action need a shell key?](#does-every-step-in-a-composite-action-need-a-shell-key)Copy Anchor Link Every step that sets `run` needs one. GitHub documents `runs.steps[*].shell` as required when `run` is present, and an action missing it fails to parse, so the job stops before the first command executes. Steps that call another action with `uses` take no `shell` key. The [composite action definition](/glossary/composite-action) lists the rest of the per-step keys that behave the same way they do in a workflow file. ### [Can a composite action choose its own runner?](#can-a-composite-action-choose-its-own-runner)Copy Anchor Link No. It runs as one step inside the caller's job and inherits the machine that job picked with `runs-on`. That constraint is the main reason to reach for a reusable workflow instead, since a called workflow declares its own `runs-on` or accepts the label as an input. [How do I reuse a workflow across repositories](/answers/how-do-i-reuse-a-workflow-across-repositories) shows both sides of that call, including the input that lets each repository pick its own runner size. ### [How do I pass a secret into a composite action?](#how-do-i-pass-a-secret-into-a-composite-action)Copy Anchor Link Pass it as an input from the caller with `with`. The secrets context is unavailable inside a composite action, so a `${{ secrets.NAME }}` expression written in `action.yml` resolves to an empty string, and the value has to be read in the workflow file and handed over. Reusable workflows take the other route with a `secrets:` mapping or `secrets: inherit`, which the [reusable workflows guide](/guides/reusable-workflows-at-scale-github-actions) covers alongside the versioning contract. --- # How Do I Write a Good Cache Key in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-write-a-good-cache-key-in-github-actions Description: Build the key from the runner platform, the toolchain version, and a hash of the lockfile, then list shorter prefixes of that same string in restore-keys. # How Do I Write a Good Cache Key in GitHub Actions? Build the key from the runner platform, the toolchain version, and a hash of the lockfile, then list shorter prefixes of that same string in restore-keys. Build the key from three parts joined with hyphens, ordered from most general to most specific: the runner platform, the toolchain and its version, and a hash of the lockfile that decides what gets installed. Then list shorter prefixes of that same string in `restore-keys`, so a run whose lockfile changed still restores the closest previous entry instead of starting cold. ## [Answer](#answer)Copy Anchor Link The template, and one concrete instance of it for an npm download cache: Copy Text ``` ---- ${{ runner.os }}-${{ runner.arch }}-npm-node-22-${{ hashFiles('**/package-lock.json') }} ``` Each segment earns its place by answering one question: if this thing changes, is the stored tree still the tree I want? | Segment | Example value | Include it because | Drop it and | | ------------ | ------------------------------------------ | ----------------------------------------------------- | ----------------------------------------------------------------- | | Platform | ${{ runner.os }} renders Linux | Binaries and paths differ across operating systems | A macOS entry can be handed to a Linux job through a prefix match | | Architecture | ${{ runner.arch }} renders X64 or ARM64 | Compiled native modules are architecture specific | An x64 tree lands in an ARM64 job and fails at run time | | Ecosystem | npm | One repository caches several unrelated trees | The prefix ladder crosses between a pip cache and an npm cache | | Tool version | node-22 | A toolchain bump changes the layout of what is stored | A Node 22 job restores a tree built by Node 20 | | Content hash | ${{ hashFiles('\*\*/package-lock.json') }} | This is the segment that turns the entry over | The key stops tracking the contents entirely | `runner.os` and `runner.arch` come from the [GitHub contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts). `hashFiles` returns a single SHA-256 over the matched file set, and an empty string when the pattern matches nothing ([GitHub expressions reference](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#hashfiles)), which is why a cache step placed before `actions/checkout` produces a key ending in a bare hyphen that every run of the workflow shares. The ladder is the same string with the most specific segment removed, one rung at a time: Copy Text ``` key: ${{ runner.os }}-${{ runner.arch }}-npm-node-22-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-npm-node-22- ${{ runner.os }}-${{ runner.arch }}-npm-node- ``` GitHub documents the matching rule directly: "When a key doesn't match directly, the action searches for keys prefixed with the restore key. If there are multiple partial matches for a restore key, the action returns the most recently created cache" ([GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13). Rungs are searched in the order you write them, so the list runs from narrow to wide. Keys have a maximum of 512 characters and a longer key fails the step ([GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)), which the three-segment shape stays well inside. Two properties of the key are worth knowing before you tune one. The key is only half of an entry's identity, because the cache action also derives a version from the `path` list and the compression tool on the machine, so two jobs with identical key text and different paths address different entries ([WarpBuild caching documentation](/docs/ci/features/caching)). And `cache-hit` is `true` only on an exact primary-key match, so an install step guarded with `if: steps.cache.outputs.cache-hit != 'true'` still runs after a prefix restore, which is the behavior you want. [Cache key](/glossary/cache-key) covers both in full. ## [Detail](#detail)Copy Anchor Link ### [The two failure shapes](#the-two-failure-shapes)Copy Anchor Link A key fails in one of two directions, and they look nothing alike in the job log. **Too specific: the key never hits.** A key that ends in `${{ github.sha }}`, a timestamp, or `${{ github.run_id }}` changes on every run by construction. The restore step reports a miss, the install runs cold, and the save step writes an entry nobody will ever ask for again. The tell is a cache section in the log that shows a write on every run and a hit on none. **Too loose: the key restores a stale tree.** A key such as `${{ runner.os }}-npm` with no hash never changes, and cache entries are immutable, so the save step writes nothing once an entry exists under that key and version ([actions/cache README](https://github.com/actions/cache)). The first tree ever written is served to every run afterwards. The tell is a hit rate near 100 percent with dependency versions that do not match the lockfile, and a failure that reproduces in the pipeline and never locally. The same two shapes show up in the ladder rather than the primary key. A primary key carrying `runner.arch` with a `restore-keys` rung that drops it behaves correctly until the exact key misses, which is the run where the lockfile changed and the diff already looks large enough to explain anything. Both shapes have a price attached. WarpBuild bills cache storage at $0.20 per GB-month and $0.0001 per cache write or restore ([pricing page](/pricing), checked on 2026-08-13), and an entry expires 7 days after its last use ([WarpBuild caching documentation](/docs/ci/features/caching)). Take a repository running 1,200 workflow runs a month over a 0.8 GB dependency tree, with a lockfile that changes 8 times in the month: | Key shape | Distinct entries alive | Storage per month | Operations per month | What a restore does | | ------------------------------- | ---------------------- | ----------------- | -------------------- | ------------------------------------------------------------------------- | | Lockfile hash plus two prefixes | up to 8, so 6.4 GB | $1.28 | 1,208 ops, $0.12 | Exact hit while the lockfile holds, prefix hit on the run that changes it | | Commit SHA | about 280, so 224 GB | $44.80 | 2,400 ops, $0.24 | Never an exact hit, and every run pays a full cold install | | Static string, no hash | 1, so 0.8 GB | $0.16 | 1,200 ops, $0.12 | Always a hit, always the first tree that was ever written | The SHA row assumes the steady state a never-reused key reaches: about 40 runs a day, each entry sitting for its 7 days before it expires, so roughly 280 entries are resident at any moment. Storage is the smaller half of that bill. The bigger half is the install time every run repeats, which you can price from your own duration numbers at the per-minute rate for your runner label on the [pricing page](/pricing). ### [Split the restore and the save](#split-the-restore-and-the-save)Copy Anchor Link The single-action form saves in a post-job step, which runs after the job finishes and skips the save when an earlier step fails ([actions/cache save README](https://github.com/actions/cache/blob/main/save/README.md#always-save-cache)). Splitting the action into `restore` and `save` puts the write where you want it, which keeps a slow install from being repeated because a fast test failed: Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore npm cache id: npm-cache uses: WarpBuilds/cache/restore@v1 with: path: ~/.npm key: ${{ runner.os }}-${{ runner.arch }}-npm-node-22-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-npm-node-22- ${{ runner.os }}-${{ runner.arch }}-npm-node- - run: npm ci - name: Save npm cache if: always() && steps.npm-cache.outputs.cache-hit != 'true' uses: WarpBuilds/cache/save@v1 with: path: ~/.npm key: ${{ steps.npm-cache.outputs.cache-primary-key }} - run: npm test ``` Three details carry the behavior. The save step sits before `npm test`, so a failing test suite leaves a warm cache behind. `always()` keeps the save running when an earlier step failed, which suits a package download cache because the package manager refetches whatever is missing; for a cache of compiled output, guard the save on the build step succeeding instead. And `cache-primary-key` replays the exact key the restore step computed, so the entry is written under the key the next run will ask for, rather than under whichever prefix happened to match. `WarpBuilds/cache@v1` and its `restore` and `save` subactions are drop-in replacements for `actions/cache@v4` with the same `path`, `key`, and `restore-keys` inputs ([WarpBuild caching documentation](/docs/ci/features/caching)). Two platform facts constrain where this runs. WarpBuild caching is not supported on Windows runners ([WarpBuild caching documentation](/docs/ci/features/caching)), so a Windows leg of a matrix keeps `actions/cache@v4`. For toolchains and package managers, the cache-enabled [setup actions](/docs/ci/features/caching/setup-actions) for Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise compute the key themselves, which removes this whole problem for the paths they cover. A well-written key stops helping when the state you want back is machine state rather than a list of directories: a warm daemon, a container image store, an incremental build tree with its timestamps intact. The [guide to persistent caches on GitHub Actions](/guides/persistent-caches-github-actions) draws the boundary between the two mechanisms and shows when to run both. ## [Related Questions](#related-questions)Copy Anchor Link ### [How many restore-keys should I list?](#how-many-restore-keys-should-i-list)Copy Anchor Link Two is enough for most workflows and three is the practical ceiling. Every rung has to be a literal prefix of the primary key, and every rung has to be narrow enough that it can only match entries holding the same kind of tree. A rung that widens to the platform segment alone will happily restore a Python cache into a Node job. [What restore-keys are in GitHub Actions](/answers/what-are-restore-keys-in-github-actions) works through how competing prefixes resolve. ### [Should a cache key include the commit SHA?](#should-a-cache-key-include-the-commit-sha)Copy Anchor Link Only when the cached tree is incremental build output and the workflow lists prefixes underneath it. A SHA-keyed entry never produces an exact hit, so every run writes a new entry and pays storage for it until the entry expires 7 days after its last use. On a dependency cache, hash the lockfile instead. [Why a GitHub Actions cache misses every run](/answers/why-does-my-github-actions-cache-miss-every-run) covers the other causes of a permanent miss. ### [Do I need to write a cache key at all?](#do-i-need-to-write-a-cache-key-at-all)Copy Anchor Link Not for language toolchains and package-manager dependencies. The WarpBuild [setup actions](/docs/ci/features/caching/setup-actions) for Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise are drop-in replacements for their upstream counterparts and manage the key themselves, keyed on the lockfile the ecosystem already declares. Hand-written keys are for paths those actions do not cover, which the [guide to persistent caches on GitHub Actions](/guides/persistent-caches-github-actions) lists alongside the cases where a cache action is the wrong tool. [Cache key](/glossary/cache-key) has the underlying definition. --- # How Do I Write a GitHub Actions Job Summary? | WarpBuild URL: https://www.warpbuild.com/answers/how-do-i-write-a-job-summary Description: Append GitHub flavored markdown to the file named by GITHUB_STEP_SUMMARY and the run page renders it under that job. The table pattern and the limits. # How Do I Write a GitHub Actions Job Summary? Append GitHub flavored markdown to the file named by GITHUB\_STEP\_SUMMARY and the run page renders it under that job. The table pattern and the limits. Write a job summary by appending GitHub flavored markdown to the file whose path GitHub puts in the `GITHUB_STEP_SUMMARY` environment variable, and the workflow run page renders that markdown as a panel under the job. Any step that can append to a file can write one, so a `run` block with `>>`, a script, or a JavaScript action all work with no API call and no token. ## [Answer](#answer)Copy Anchor Link GitHub sets `GITHUB_STEP_SUMMARY` in the environment of every step, pointing at a file on the runner. Whatever the steps of a job append there is grouped when the job finishes and shown on the run summary page under that job ([GitHub workflow commands reference](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#adding-a-job-summary), checked on 2026-08-13). The file is per step, each step is capped at 1MiB, and a step that exceeds the cap loses its upload and gets an error annotation while the job conclusion stays unchanged. The useful shape is a table of results and timings written after the tests run: Copy Text ``` name: test on: pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - name: Run unit tests run: npm test -- --reporters=json --outputFile=results.json - name: Publish test summary if: always() run: | { echo "## Unit tests" echo "" echo "| Suite | Passed | Failed | Skipped | Seconds |" echo "| --- | --- | --- | --- | --- |" jq -r '.testResults[] | "| \(.name | split("/") | last) | \(.numPassingTests) | \(.numFailingTests) | \(.numPendingTests) | \(((.endTime - .startTime) / 1000) | floor) |"' \ results.json } >> "$GITHUB_STEP_SUMMARY" ``` That step puts this under `unit-tests` on the run page: | Suite | Passed | Failed | Skipped | Seconds | | ---------------- | ------ | ------ | ------- | ------- | | billing.test.ts | 118 | 0 | 2 | 41 | | runners.test.ts | 264 | 1 | 0 | 96 | | webhooks.test.ts | 30 | 0 | 0 | 12 | Four details decide whether the panel shows up at all. `if: always()` keeps the writing step alive after the test step fails. Without it the step is skipped on exactly the runs where a reader wants the counts. The braces group every `echo` into one redirection, so the block is appended once instead of opening the file per line. Quote the variable as `"$GITHUB_STEP_SUMMARY"`, since the path lives under a workspace directory that can contain spaces. The blank line after the heading is required. GitHub flavored markdown needs a table to begin its own block, and a table glued to the line above renders as plain text. Windows runners take the same idea through PowerShell: `Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "| Passed | 412 |"`, with `Set-Content` in place of `>` when a step wants to replace what it has written. One workflow can carry both forms across a matrix without a second reporting mechanism. The definition, the four documented file rules, and the `core.summary` builder from `@actions/core` are collected in [job summary, defined](/glossary/job-summary). ## [Detail](#detail)Copy Anchor Link ### [What belongs in the summary and what belongs in the log](#what-belongs-in-the-summary-and-what-belongs-in-the-log)Copy Anchor Link A summary is a report surface. It answers what happened and where to go next, and it holds no raw output. | Content | Where it goes | Why | | -------------------------------------- | ---------------------------- | -------------------------------------------------------------------- | | Pass, fail, and skip counts per suite | Summary | The number a reviewer wants before opening anything | | Stack traces for failing tests | Log, linked from the summary | Long, wraps badly, and reads better with the surrounding step output | | Coverage delta against the base branch | Summary | One line that decides whether the pull request is ready | | Full npm ci or compiler output | Log | Thousands of lines that would eat the 1MiB step budget | | Published image digest and tag | Summary | The identifier the next person has to copy | | Artifact and deployment URLs | Summary | Links are the point of a rendered panel | | Per-test stdout | Log | Only read when a specific test is already suspect | The 1MiB per-step cap turns this into a hard rule rather than a preference. A step that pipes a log tail into the file crosses the cap on the noisy run and loses the entire panel for that step, which is the run where the panel mattered. Keep the panel at report size and put the volume in the log or in an uploaded artifact. ### [Recording cache outcomes and job duration for later comparison](#recording-cache-outcomes-and-job-duration-for-later-comparison)Copy Anchor Link The same file is a good place to record two facts about the run itself, because they are what you compare when a workflow gets slower. This pattern writes the cache outcome and the wall-clock job duration as a row, and repeats the row as a single grep-able marker line in the log: Copy Text ``` steps: - name: Start timer run: echo "JOB_START=$(date +%s)" >> "$GITHUB_ENV" - uses: actions/checkout@v4 - name: Restore dependency cache id: deps uses: WarpBuilds/cache/restore@v1 with: path: | ~/.npm node_modules key: npm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/package-lock.json') }} - name: Install dependencies if: steps.deps.outputs.cache-hit != 'true' run: npm ci - run: npm test - name: Record run facts if: always() run: | duration=$(( $(date +%s) - JOB_START )) cache="${{ steps.deps.outputs.cache-hit }}" [ "$cache" = "true" ] || cache=false { echo "### Run facts" echo "" echo "| Fact | Value |" echo "| --- | --- |" echo "| Cache hit | $cache |" echo "| Job seconds | $duration |" echo "| Runner label | ${{ runner.os }}-${{ runner.arch }} |" } >> "$GITHUB_STEP_SUMMARY" echo "RUN_FACTS cache=$cache seconds=$duration label=${{ runner.os }}-${{ runner.arch }}" ``` The panel is for a person opening one run. The marker line on stdout is for the next question, which is what the numbers look like over fifty runs, and `gh run view --log | grep RUN_FACTS` reads it back. `cache-hit` is `true` only on an exact match against the primary key, so the row records exact matches and a `restore-keys` prefix restore shows as `false`; [how to measure cache hit rate in GitHub Actions](/answers/how-do-i-measure-cache-hit-rate-in-github-actions) covers the counting rules and the break-even arithmetic behind them. Two limits are worth knowing before this becomes the reporting plan for a repository. A summary is scoped to one run and nothing aggregates panels for you, and run logs are retained for 90 days by default ([GitHub log retention settings](https://docs.github.com/en/organizations/managing-organization-settings/configuring-the-retention-period-for-github-actions-artifacts-and-logs-in-your-organization), checked on 2026-08-13), which bounds how far back the marker lines go. Where the aggregate is the actual goal, the Jobs section of the [WarpBuild Reports page](/docs/ci/features/reports) already carries run count, success rate, duration P75 and P90, and queue time P75 and P90 per repository, workflow, and job name, with CSV export on every tab. Rolling per-run panels into one scheduled report is the subject of the [weekly GitHub Actions health report guide](/guides/weekly-ci-health-report-github-actions). ### [What the instrumentation costs](#what-the-instrumentation-costs)Copy Anchor Link Writing a summary makes no API call and consumes no cache operations, so the only cost is the runner seconds the step occupies. Price it directly: a `warp-ubuntu-latest-x64-4x` runner is $0.008 per minute ([pricing page](/pricing), checked on 2026-08-13), and a `jq` pass plus a handful of `echo` calls takes about 2 seconds. * 2 seconds per run is 0.0333 minutes, or $0.000267 per run. * At 1,000 runs a month that is 33.3 minutes, or $0.27. * At 10,000 runs a month it is 333 minutes, or $2.67. Pointing a first workflow at a managed runner is a one-line `runs-on` change, documented in the [quick start](/docs/ci/quick-start). Once the panel reports duration on every run, the numbers it collects feed the ordinary levers: runner size, cache warmth, and queue wait. The [guide to speeding up GitHub Actions builds](/guides/speed-up-github-actions) works through those in order, with the per-size rates to compare against. ## [Related Questions](#related-questions)Copy Anchor Link ### [Why is my job summary empty after the job fails?](#why-is-my-job-summary-empty-after-the-job-fails)Copy Anchor Link The summary step usually never ran. A step with no condition is skipped once an earlier step in the job has failed, so the step that writes the panel needs `if: always()`. The second cause is size. Each step is limited to 1MiB, and a step that adds more loses its upload and gets an error annotation while the job conclusion stays unchanged ([GitHub workflow commands reference](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#adding-a-job-summary), checked on 2026-08-13). The third is a matrix: twelve matrix jobs write twelve separate panels, so a missing one belongs to a job you have not scrolled to. [Job summary, defined](/glossary/job-summary) lists the file rules in full. ### [Should I put the full test log in the job summary?](#should-i-put-the-full-test-log-in-the-job-summary)Copy Anchor Link No. Results and links go in the summary, raw output stays in the log. The panel is read in a few seconds on the run page, and the 1MiB per-step cap means a log tail piped into the file loses the whole panel on the noisy run. Write counts, timings, a coverage delta, the image digest, and the artifact links, then let the reader open the log for the trace. When the panel exists mostly to explain why a run got slower, the fixes it points at are in the [guide to speeding up GitHub Actions builds](/guides/speed-up-github-actions). ### [How do I compare job summaries across runs?](#how-do-i-compare-job-summaries-across-runs)Copy Anchor Link A summary belongs to one run, so comparison needs a second surface. Either write a marker line to stdout beside the panel and read it back with `gh run view --log`, which works for as long as the log retention window allows, or use an aggregate. The Jobs section of the [WarpBuild Reports page](/docs/ci/features/reports) aggregates run count, success rate, duration P75 and P90, and queue time P75 and P90 per repository, workflow, and job name, with a time-series chart and CSV export. For a scheduled digest built from run data, follow the [weekly GitHub Actions health report guide](/guides/weekly-ci-health-report-github-actions), and for the cache side of the same row see [how to measure cache hit rate in GitHub Actions](/answers/how-do-i-measure-cache-hit-rate-in-github-actions). --- # How Does BYOC Billing Work? | WarpBuild URL: https://www.warpbuild.com/answers/how-does-byoc-billing-work Description: BYOC billing runs on two lines. WarpBuild charges $0.002 per runner minute, and the instances, disks, and data transfer land on your own cloud bill. # How Does BYOC Billing Work? BYOC billing runs on two lines. WarpBuild charges $0.002 per runner minute, and the instances, disks, and data transfer land on your own cloud bill. Last verified: 2026-08-13 BYOC billing has two lines. WarpBuild charges a per-minute platform fee of $0.002 per runner minute for BYOC runners, and the compute those runners use is billed by your own cloud account at whatever rate that account pays for the instance ([pricing page](/pricing) and [BYOC documentation](/docs/ci/byoc), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link The split follows the architecture. Runners launch inside your AWS, GCP, or Azure account against a WarpBuild stack that pins a region, a VPC, and an object storage bucket, per the [BYOC documentation](/docs/ci/byoc). WarpBuild meters the runner minutes and invoices the platform fee. Your cloud provider meters the instances, volumes, storage, and network traffic those runners consume and invoices them on your existing cloud bill. | Line | Who bills it | Rate | Where it lands | | --------------------------------------------- | ------------------ | --------------------------------------- | ----------------- | | BYOC Linux runners on AWS, GCP, or Azure | WarpBuild | $0.002 per runner minute | WarpBuild invoice | | BYOC Windows runners on AWS or Azure | WarpBuild | $0.002 per runner minute | WarpBuild invoice | | Instance compute for those runners | Your cloud account | your on-demand, spot, or committed rate | Your cloud bill | | Runner disks and standby disk volumes | Your cloud account | provider block storage rates | Your cloud bill | | Object storage for cache, logs, and artifacts | Your cloud account | provider object storage rates | Your cloud bill | | Data transfer and NAT gateway processing | Your cloud account | around $0.45 per GB on AWS | Your cloud bill | WarpBuild rates come from the [pricing page](/pricing), and the data transfer figures come from the [BYOC documentation](/docs/ci/byoc), both checked on 2026-08-13. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the WarpBuild line is runner minutes multiplied by $0.002 and nothing else. Signup includes $10 free credits, which covers 5,000 BYOC runner minutes of WarpBuild fees while you measure a real month. ## [Detail](#detail)Copy Anchor Link ### [What the $0.002 line covers](#what-the-0002-line-covers)Copy Anchor Link The fee is the same for every BYOC instance shape. A 4 vCPU runner and a 64 vCPU runner both bill $0.002 per runner minute to WarpBuild, because the size only changes what your cloud account charges for the instance. Cache storage and cache operations, which cost $0.20 per GB-month and $0.0001 per operation on hosted runners ([pricing page](/pricing)), are included on BYOC runners, since the cache lives in the object storage bucket the stack created in your account. BYOC runs on AWS, GCP, and Azure. The macOS fleet is hosted, so macOS jobs stay on hosted labels and price from the [pricing page](/pricing) rather than from the BYOC line. A worked month makes the shape obvious. Take 60,000 BYOC Linux runner minutes on an 8 vCPU shape: | Component | Volume | Rate | Cost | | -------------------------------------- | --------------------- | ----------------- | --------------- | | WarpBuild BYOC fee | 60,000 runner minutes | $0.002/min | $120.00 | | Cache storage and operations | included on BYOC | $0.00 | $0.00 | | Seats, base subscription, platform fee | 1 account | none | $0.00 | | EC2 instance hours | 1,000 instance hours | your account rate | your cloud bill | The first three rows are fixed by the price list. The fourth is the one you model, and it swings with instance family, region, spot ratio, and any committed-use discounts you already hold. The [BYOC cost model](/byoc/cost-model) walks through that half against a real month of workflow minutes. ### [What lands on the cloud bill besides compute](#what-lands-on-the-cloud-bill-besides-compute)Copy Anchor Link Four items sit alongside instance hours, and teams that only model compute underestimate the cloud half. Block storage is the first. Every runner carries a root volume, and standby disks add more. A standby disk is a VM of the runner's instance type that is booted once and then shut down so a job can start on warm networking and machine setup. WarpBuild does not charge for standby disks; the stopped VM stops billing compute in your account while its volume keeps billing storage, per the [BYOC documentation](/docs/ci/byoc). Object storage is the second. The bucket the stack creates holds the artifact cache under `//artifact_cache/...` and runner telemetry under `/runner/logs/...`, per the [AWS configuration guide](/docs/ci/byoc/aws/config). That guide recommends an S3 lifecycle policy with 7 days of retention, which is the single largest lever on the storage line. Data transfer is the third, and it is the item most likely to surprise. The [BYOC documentation](/docs/ci/byoc) puts outbound data transfer from a runner to the internet at around $0.45 per GB on AWS. Runners in public subnets, which is the default when static IPs are disabled, avoid the gateway path entirely and usually carry minimal egress for GitHub Actions workloads. Gateway processing is the fourth. Enabling static IPs moves runners into private subnets behind a NAT gateway, which adds NAT data processing fees of around $0.45 per GB for inbound and outbound traffic, plus any inter-region transfer between the subnets and the gateway. The [BYOC documentation](/docs/ci/byoc) recommends using static IPs sparingly for that reason. The [AWS configuration guide](/docs/ci/byoc/aws/config) also recommends an S3 gateway endpoint on the VPC so cache traffic to the bucket does not take a chargeable transfer path. ### [Spot instances and the compute half](#spot-instances-and-the-compute-half)Copy Anchor Link Spot changes the cloud line and leaves the WarpBuild line alone. BYOC runners cost $0.002 per minute in WarpBuild fees and the compute is billed by your own cloud account, where AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices ([AWS EC2 Spot Instances](https://aws.amazon.com/ec2/spot/), checked on 2026-08-13). That published ceiling is an AWS figure for its own capacity, so treat it as the range your compute rate moves within rather than a realized result. Two operational facts belong in the model. A reclaimed spot instance fails the job it was running, and the job restarts from its first step while the minutes already burned stay on both bills. Spot instances are also unsupported for standby disks, so a runner set gets either a warm pool or spot pricing. The [spot instances guide](/guides/spot-instances-github-actions-runners) covers which jobs suit that trade and how to configure fallback instance types so availability stops stranding queued jobs. ### [Reading both bills together](#reading-both-bills-together)Copy Anchor Link The WarpBuild side breaks down per job, per repository, and per runner label in the [billing reports](/docs/ci/features/reports), with execution time and billed time side by side. The cloud side breaks down through your provider's own cost tooling, which is where instance hours, volumes, storage, and transfer separate. Tagging runner resources at the stack level is what lets those two views reconcile against the same workflows. Architecture, IAM detail, and the AWS specifics of that setup are on the [BYOC on AWS page](/byoc/aws). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does WarpBuild charge for cache or networking on BYOC runners?](#does-warpbuild-charge-for-cache-or-networking-on-byoc-runners)Copy Anchor Link No. Cache storage and cache operations are included on BYOC runners, and the object storage bucket that holds the cache sits in your own cloud account, so your cloud provider bills that storage instead. The hosted-runner rates for the same items, $0.20 per GB-month and $0.0001 per operation, are on the [pricing page](/pricing). ### [Do spot instances change the WarpBuild BYOC fee?](#do-spot-instances-change-the-warpbuild-byoc-fee)Copy Anchor Link No. The WarpBuild fee stays $0.002 per runner minute whether the instance is on-demand or spot. AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices ([AWS EC2 Spot Instances](https://aws.amazon.com/ec2/spot/), checked on 2026-08-13), and that discount applies to the compute half of the bill in your own account. The [spot instances guide](/guides/spot-instances-github-actions-runners) covers the interruption behavior that comes with it. ### [Which clouds and platforms does the $0.002 BYOC fee cover?](#which-clouds-and-platforms-does-the-0002-byoc-fee-cover)Copy Anchor Link BYOC runs on AWS, GCP, and Azure. Linux runners are billed at $0.002 per minute on all three, Windows runners at $0.002 per minute on AWS and Azure, per the [pricing page](/pricing). macOS runners are hosted by WarpBuild and priced from the standard per-minute rate list. Setup detail for the largest of the three paths is on the [BYOC on AWS page](/byoc/aws), and the full model sits on the [BYOC cost model](/byoc/cost-model) page. --- # How Fast Does a Runner Start After Queueing? | WarpBuild URL: https://www.warpbuild.com/answers/how-fast-do-runners-start-after-a-job-is-queued Description: Start time is the gap between a job entering the queue and its first step running. Read it per runner label in the Queue Timings report, then cut it. # How Fast Does a Runner Start After Queueing? Start time is the gap between a job entering the queue and its first step running. Read it per runner label in the Queue Timings report, then cut it. Last verified: 2026-08-13 Start time is the gap between a job entering the GitHub Actions queue and the first step of that job running on a machine, and WarpBuild publishes it per runner label as queue time P75 and P90 in the Queue Timings report ([reports documentation](/docs/ci/features/reports)). The documented figures are about 15 seconds for a BYOC runner backed by a standby disk ([standby disks documentation](/docs/ci/byoc/standby-disks)) and 45 to 60 seconds for a snapshot runner booting from saved VM state ([snapshot runners documentation](/docs/ci/features/snapshot-runners)), and Braintrust reports consistent sub-minute runner provisioning on both ARM64 and x86 WarpBuild runners ([Braintrust customer page](/customers/braintrust)). ## [Answer](#answer)Copy Anchor Link Three timestamps bound the number. GitHub records the moment the job is queued, the runner platform records the moment a machine is assigned and finishes booting, and the job log records the first step of your own work. Start time covers the two gaps before that first step. Run time is everything after it. A single elapsed figure on the run page adds them together and hides which one moved. Here is what each path to a machine documents. | Path to a machine | Documented start time | Source | | -------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------- | | WarpBuild Cloud runner, ephemeral VM per job | Braintrust reports consistent sub-minute provisioning on ARM64 and x86 | [Braintrust customer page](/customers/braintrust) | | BYOC runner with a standby disk available | about 15 seconds | [standby disks documentation](/docs/ci/byoc/standby-disks) | | BYOC runner with no standby disk free | a new VM is created for the job, and the pool refills within about 1 minute | [standby disks documentation](/docs/ci/byoc/standby-disks) | | Snapshot runner booting from saved VM state | 45 to 60 seconds | [snapshot runners documentation](/docs/ci/features/snapshot-runners) | Every one of them is allocated as an ephemeral VM that is created when a job needs it and destroyed when the workflow completes ([cloud runners documentation](/docs/ci/cloud-runners)). That design is why the number above is a provisioning time rather than a wait for a free slot in a fixed pool. Those figures hold only while a machine is available to allocate. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a pull request that fans out to 30 shards does not turn the last shard's start time into a wait behind the first 29\. Where a fixed ceiling does exist, the wait lands in the same queue time column, and the [guide to GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) covers the ceilings that produce it. One shape of start time has nothing to do with capacity. A job whose `runs-on` label set matches no online runner stays queued until GitHub cancels it at 24 hours ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). A start time measured in hours is almost always that, and no amount of capacity changes it. ## [Detail](#detail)Copy Anchor Link ### [Reading start time per label, separated from run time](#reading-start-time-per-label-separated-from-run-time)Copy Anchor Link Two reports carry the split. 1. Open the [Queue Timings report](/docs/ci/features/reports) and set a date range. Each row is a unique runner label and stack combination with run count, queue time P75, and queue time P90\. The daily bar chart plots average queue time next to total job count per day. 2. Open the [Jobs report](/docs/ci/features/reports) for the same range. Each row is a unique repository, workflow, and job name combination with run count, success rate, duration P75 and P90, queue time P75 and P90, and CPU and memory percentiles. 3. Compare the two columns for the same job. Queue time moving while duration stays flat is a start time problem. Duration moving while queue time stays flat is a build problem, and nothing on this page applies to it. 4. Export both tables to CSV. The same export after a change is the before and after record, and the CSV includes every row matching the current filters rather than the current page only. Queue time is the only number here that a workflow file cannot change. The steps you write start counting after it ends. ### [What raises start time](#what-raises-start-time)Copy Anchor Link **Capacity that has to be created.** On BYOC, WarpBuild checks for an available standby disk when GitHub requests a runner. If one is free it is powered on and the job starts in about 15 seconds. If none is free, a new VM is created for the job and pays a full allocation and boot, and the control plane refills the pool within about a minute of reconciliation time ([standby disks documentation](/docs/ci/byoc/standby-disks)). The documented guidance is to size the pool from the number of jobs you expect to run concurrently on that runner type, so an eight shard matrix wants at least eight disks. Spot instances are not supported for standby disks, because a spot instance can be reclaimed at any time. **Large custom VM images.** A BYOC custom image is built in your own cloud account and the runner boots from it, so the image contents and the instance type it boots on set the floor for allocation and boot ([custom VM images documentation](/docs/ci/byoc/custom-vm-images)). Anything baked into the image is paid once at build time instead of once per job, which is the trade that makes a larger image worth a slower boot. **Cold image pulls.** A job using the `container:` key or a `services:` image pulls that image before your first step runs. Runner storage is ephemeral and is deleted when the runner terminates ([cloud runners documentation](/docs/ci/cloud-runners)), so the pull happens on every run. This one is worth calling out because it does not show up in the Queue Timings report at all: queue time stays flat and the first real step simply lands later. Read it from the container initialization step in the job log. **Snapshot boot.** Snapshot runners boot from saved VM state in 45 to 60 seconds, which is slower than a default runner boot ([snapshot runners documentation](/docs/ci/features/snapshot-runners)). The extra seconds buy a machine that already has the toolchain, dependencies, and prior build output on disk, so the comparison worth making is total time to the first useful step rather than time to the first step. ### [A worked example: eight shards on a BYOC runner type](#a-worked-example-eight-shards-on-a-byoc-runner-type)Copy Anchor Link Take one repository running 120 pull request workflows per weekday, each fanning out to eight test shards on a single BYOC runner type. Measure queue time P75 for that label in the Queue Timings report before changing anything, then compare against the documented standby figure. | Line | No standby disk free | Standby disk available | | ---------------------------------- | ------------------------- | ---------------------- | | Start time per shard | 90 seconds (measured P75) | about 15 seconds | | Front of a pull request run | 90 seconds | about 15 seconds | | Shard starts per weekday | 960 | 960 | | Aggregate start time per weekday | 24.0 hours | 4.0 hours | | Developer visible wait per weekday | 3.0 hours | 0.5 hours | The 90 second figure is a placeholder for your own P75\. The arithmetic is what carries over: 960 shard starts times 75 seconds saved is 72,000 seconds, or 20 hours of aggregate machine wait removed per weekday, and 120 runs times 75 seconds is 2.5 hours of wait that developers actually watch. Sizing the pool at eight disks is what makes the second column true for every shard rather than the first one only. WarpBuild does not charge for standby disks; your own cloud account pays for about 90 seconds of VM time while each disk initializes and for the network disk while the VM sits shut down. The workflow side of this needs no change at all. The label is the only lever a workflow file holds. Copy Text ``` name: ci on: pull_request: branches: [main] jobs: tests: runs-on: warp-ubuntu-latest-x64-4x timeout-minutes: 25 strategy: fail-fast: true matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/8 ``` Everything that sets the start time for this matrix is a property of the runner behind `warp-ubuntu-latest-x64-4x`, which is why the measurement belongs in the reports view and the fix belongs in the runner configuration. ### [What the wait costs](#what-the-wait-costs)Copy Anchor Link Start time is wall clock rather than runner configuration, and it compounds across every shard of every run. Execution minutes bill at the rate for the label: `warp-ubuntu-latest-x64-4x` is $0.008 per minute and `warp-ubuntu-latest-arm64-4x` is $0.006 per minute, with the full list on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [Where do I read start time for one runner label?](#where-do-i-read-start-time-for-one-runner-label)Copy Anchor Link The [Queue Timings report](/docs/ci/features/reports). Each row is a unique runner label and stack combination with run count, queue time P75, and queue time P90, and a daily bar chart shows average queue time next to job count per day. Every table exports to CSV, so the same label before and after a change is a two column comparison. The measurement itself is defined in [queue time](/glossary/queue-time). ### [Why do some GitHub Actions jobs start in seconds and others wait minutes?](#why-do-some-github-actions-jobs-start-in-seconds-and-others-wait-minutes)Copy Anchor Link Either a machine has to be built for the job, or no machine carrying every label in `runs-on` is free. Generally available WarpBuild Linux and Windows runners do not have plan-level concurrency caps, so check the labels and runner-group access when one of those jobs never finds a match. GitHub cancels a queued job after 24 hours ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). The [guide to GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) covers the ceilings that do apply. ### [Does a fast start remove the rest of the setup time?](#does-a-fast-start-remove-the-rest-of-the-setup-time)Copy Anchor Link No. Allocation and boot are the first two costs of a cold start, and toolchain install, dependency restore, and first-run compilation follow them inside your own steps. [Standby disks](/byoc/standby-disks) start a BYOC job in about 15 seconds and leave those three untouched, while a snapshot runner boots in 45 to 60 seconds with the toolchain and prior build output already on disk. The [guide to removing cold starts from GitHub Actions jobs](/guides/github-actions-cold-starts) measures all five parts separately and matches each lever to the parts it removes. --- # How Is a Cancelled GitHub Actions Job Billed? | WarpBuild URL: https://www.warpbuild.com/answers/how-is-a-cancelled-github-actions-job-billed Description: A cancelled GitHub Actions job bills the minutes it ran before termination and nothing after, which is why cancel-in-progress on a busy branch lowers the bill. # How Is a Cancelled GitHub Actions Job Billed? A cancelled GitHub Actions job bills the minutes it ran before termination and nothing after, which is why cancel-in-progress on a busy branch lowers the bill. ## [Answer](#answer)Copy Anchor Link A cancelled GitHub Actions job is billed for the minutes it ran before the runner stopped, and for nothing after that. GitHub applies the same rule to its own hosted minutes, where a run that stops early consumes only the time it used ([GitHub billing documentation](https://docs.github.com/en/billing/managing-billing-for-your-products/about-billing-for-github-actions), checked 2026-08-13), and WarpBuild bills cloud runners on a per-minute basis for the time the job holds the machine ([WarpBuild pricing](/pricing)). That single rule is the whole argument for `cancel-in-progress`. A run that is superseded by a newer commit keeps burning minutes until something stops it, so the cost of a push storm is set by how long each obsolete run survives. Stop run number three at minute two and you pay two minutes for it. Leave it alone and you pay its full length for work nobody will read. Per-minute granularity has one consequence worth knowing before you model anything: a job cancelled twenty seconds after it starts still bills its first minute. Savings come from cutting long tails rather than from shaving seconds. ## [Detail](#detail)Copy Anchor Link ### [What a cancelled job still bills](#what-a-cancelled-job-still-bills)Copy Anchor Link Cancellation travels to the runner as a message, and the runner needs a few seconds to act on it. GitHub documents the sequence in its [workflow cancellation reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-cancellation) (checked 2026-08-13), and every row below is billable time on the runner. | Stage | What happens | Billing effect | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | | Job conditions re-evaluated | The server re-checks if conditions on running jobs; a job whose condition evaluates to true is not cancelled | The job keeps running and keeps billing | | Cancellation message sent | The server notifies the runner machines holding the affected jobs | Billing continues until the runner acts | | Step conditions re-evaluated | Steps guarded by always() or cancelled() continue | Cleanup steps bill in full | | Signal escalation | SIGINT to the step process, then SIGTERM after 7.5 seconds, then a 2.5 second wait, then the process tree is terminated | Ten extra seconds per stubborn step | | Cancellation timeout | After five minutes the server forcibly terminates whatever is still running | A worst case of five extra billed minutes per job | A job with a long `if: always()` upload step therefore costs more to cancel than a job that exits on the first signal. If your cancelled runs consistently bill four or five minutes more than the moment you clicked cancel, the cleanup steps are the reason. ### [A push storm with and without cancel-in-progress](#a-push-storm-with-and-without-cancel-in-progress)Copy Anchor Link Take a pull request workflow with a six job matrix, eight minutes per job, all on `warp-ubuntu-latest-x64-4x` at $0.008 per minute ([cloud runners documentation](/docs/ci/cloud-runners)). Five commits land on the branch at minutes 0, 3, 6, 8, and 12. | Push | Without a concurrency group | Job-minutes | With cancel-in-progress | Job-minutes | | ----- | --------------------------- | ----------- | ------------------------ | ----------- | | 1 | Runs 0 to 8, completes | 48 | Cancelled at minute 3 | 18 | | 2 | Runs 3 to 11, completes | 48 | Cancelled at minute 6 | 18 | | 3 | Runs 6 to 14, completes | 48 | Cancelled at minute 8 | 12 | | 4 | Runs 8 to 16, completes | 48 | Cancelled at minute 12 | 24 | | 5 | Runs 12 to 20, completes | 48 | Runs 12 to 20, completes | 48 | | Total | 240 | 120 | | | At $0.008 per minute that is $1.92 for the storm without a concurrency group and $0.96 with one. A repository that sees 120 storms of this shape in a month bills 14,400 fewer job-minutes with `cancel-in-progress` enabled, or $115.20 less. Platform choice changes the scale of the same arithmetic. The identical timeline for a single macOS job of eight minutes on `warp-macos-latest-arm64-6x` at $0.08 per minute ([cloud runners documentation](/docs/ci/cloud-runners)) bills 40 minutes and $3.20 uncancelled against 20 minutes and $1.60 cancelled, a $1.60 saving on that one job. All four bill the same way for a job that stops early. ### [The workflow shape](#the-workflow-shape)Copy Anchor Link The [concurrency syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency) keys a [concurrency group](/glossary/concurrency-group) on an expression and cancels whatever is running in it. Keying on the ref gives one live run per branch. Copy Text ``` name: ci on: pull_request: branches: [main] concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: runs-on: warp-ubuntu-latest-x64-4x strategy: matrix: shard: [1, 2, 3, 4, 5, 6] steps: - uses: actions/checkout@v4 - run: ./scripts/test.sh --shard ${{ matrix.shard }} - name: Upload shard report if: always() timeout-minutes: 1 uses: actions/upload-artifact@v4 with: name: report-${{ matrix.shard }} path: reports/ ``` Two details matter for the bill. The group includes `github.workflow` so two different workflows on the same branch do not cancel each other. The `if: always()` upload carries `timeout-minutes: 1`, which caps the cleanup tail that keeps billing after cancellation instead of letting it run to the five minute limit. ### [When cancelling is the wrong call](#when-cancelling-is-the-wrong-call)Copy Anchor Link Cancellation is safe for work that only writes to the runner. It is unsafe for work that mutates state somewhere else. A `terraform apply` killed between the plan and the state write, a database migration killed halfway through, or a package publish killed after the upload but before the tag all leave a mess that costs more engineering time than the minutes ever saved. Give those jobs their own group and let the second run wait: Copy Text ``` concurrency: group: deploy-production cancel-in-progress: false ``` Note the queue behavior that comes with it: when a run is waiting in a group, a newer run arriving replaces the pending one, so the deploy that eventually runs is the newest commit rather than every commit in order. Release branches deserve the same treatment, which is why `cancel-in-progress: ${{ !contains(github.ref, 'release/') }}` is a common middle ground. ### [Reading it back in the reports](#reading-it-back-in-the-reports)Copy Anchor Link The CI tab of the billing report has one row per job execution with execution time, billed time, and cost, so a cancelled run appears as a short row rather than a missing one ([reports documentation](/docs/ci/features/reports)). Filter by runner label and sort by billed time to find the cancelled jobs whose cleanup steps ran long. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does cancelling a GitHub Actions run refund the minutes already used?](#does-cancelling-a-github-actions-run-refund-the-minutes-already-used)Copy Anchor Link No. The minutes that ran before the cancellation message reached the runner are billed, and the minutes that would have followed are not. Cancelling early is what saves money, so a superseded run should be stopped at the first commit that replaces it rather than at code review time. Per-minute rates for each label are on the [WarpBuild pricing page](/pricing). ### [How much does cancel-in-progress actually save on a busy branch?](#how-much-does-cancel-in-progress-actually-save-on-a-busy-branch)Copy Anchor Link On the six job matrix modeled above, five pushes in twelve minutes bill 240 job-minutes without a concurrency group and 120 job-minutes with one, which is $1.92 against $0.96 at $0.008 per minute. Multiply by the number of storms your busiest repository sees in a month to get the real figure, and see [how to cancel in-progress runs on a new push](/answers/how-do-i-cancel-in-progress-runs-on-a-new-push) for the configuration. ### [Which jobs should never be cancelled in progress?](#which-jobs-should-never-be-cancelled-in-progress)Copy Anchor Link Anything that mutates state outside the runner: deploys, database migrations, Terraform applies, and package publishes. Give those a separate concurrency group with `cancel-in-progress: false` so a new run queues behind the current one instead of interrupting it. Cleanup steps guarded by `always()` also keep billing after cancellation, so cap them with `timeout-minutes`. The [guide to re-run storms](/guides/rerun-storms-github-actions) covers the other repeat-work patterns that push the same line up, and the [concurrency group definition](/glossary/concurrency-group) covers the expression forms. --- # How Is a Remote Docker Builder Billed? | WarpBuild URL: https://www.warpbuild.com/answers/how-is-a-remote-docker-builder-billed Description: A remote Docker builder bills per minute for the session it stays up, from $0.06 per minute, separate from the GitHub Actions runner minutes a job spends. # How Is a Remote Docker Builder Billed? A remote Docker builder bills per minute for the session it stays up, from $0.06 per minute, separate from the GitHub Actions runner minutes a job spends. A remote Docker builder is billed per minute for the session it stays up, at the rate attached to the builder profile size you picked, from $0.06 per minute for a 16 vCPU profile to $0.88 per minute for a 192 vCPU profile with 2TB of disk. That charge is separate from the GitHub Actions runner minutes the job spends, so a job that offloads its image build to a builder produces two lines on the invoice instead of one. ## [Answer](#answer)Copy Anchor Link A session is measured from when the builder action starts until the job completes, per the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13\. The meter starts when `Warpbuilds/docker-configure`, `Warpbuilds/build-push-action`, or `Warpbuilds/bake-action` requests the builder, and it stops when the job finishes and the post-action steps complete. Nothing about the runner size changes that rate: the builder is a separate VM whose size comes from the profile you selected in the WarpBuild dashboard. These are the profile sizes and their per-minute rates, from the [pricing page](/pricing) and the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13. | Builder profile | Disk | Architectures | Price per minute | | ------------------- | ----- | ------------------- | ---------------- | | 16 vCPU, 32GB RAM | 100GB | amd64, arm64, multi | $0.06 | | 32 vCPU, 64GB RAM | 200GB | amd64, arm64, multi | $0.12 | | 64 vCPU, 128GB RAM | 200GB | amd64, arm64, multi | $0.24 | | 96 vCPU, 192GB RAM | 600GB | amd64 only | $0.36 | | 96 vCPU, 192GB RAM | 2TB | amd64 only | $0.52 | | 192 vCPU, 384GB RAM | 600GB | amd64 only | $0.72 | | 192 vCPU, 384GB RAM | 2TB | amd64 only | $0.88 | arm64 and multi-arch profiles support a maximum size of 64 vCPU, so the last four rows apply to amd64-only profiles. The disk column is the builder VM's own disk, which is where the persistent layer cache for that profile lives. The two-line point is worth stating plainly, because it surprises teams the first time they read an invoice. The runner is billed for the whole job, including the minutes it waits on the builder, and the builder is billed for the session it holds. Both lines are visible in the reports view: the [reports documentation](/docs/ci/features/reports) describes a Billing section with separate CI, Docker Builder, and Cache tabs, where the Docker Builder table gives one row per session showing profile, architecture, duration, and cost. The companion answer on [paying for both the runner and the Docker builder](/answers/do-i-pay-for-both-the-runner-and-the-docker-builder) covers that split in more depth. ## [Detail](#detail)Copy Anchor Link ### [A worked example with both lines](#a-worked-example-with-both-lines)Copy Anchor Link One job builds and pushes an image on a 4 vCPU Linux runner, using a 32 vCPU builder profile. The job runs for 9 minutes end to end. The checkout and setup steps take the first minute, so the builder session covers 8 of those minutes. Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/app:${{ github.sha }} profile-name: "app-images" ``` Rates for both lines come from the [pricing page](/pricing), checked on 2026-08-13. | Line | Resource | Duration | Rate | Cost | | --------------- | ----------------------------- | -------- | ---------- | ------ | | Runner minutes | warp-ubuntu-latest-x64-4x | 9 min | $0.008/min | $0.072 | | Builder session | 32 vCPU, 64GB RAM, 200GB disk | 8 min | $0.12/min | $0.960 | | Total | $1.032 | | | | At 400 merges a month that workflow costs $412.80, and the builder carries 93 percent of it. The runner line stays small because the per-minute runner rate, $0.008, sits far below the per-minute builder rate, $0.12, at this pairing. Rate-shopping the builder profile moves the total far more than rate-shopping the runner does. Sizing the builder is arithmetic on the same two numbers. Doubling the profile size doubles the per-minute rate, so a larger profile pays for itself only when it cuts session length by more than half. If a build takes 14 minutes on the 16 vCPU profile, it costs $0.84 of builder time; if the 32 vCPU profile finishes the same build in 8 minutes, it costs $0.96 and returns the image 6 minutes earlier. Both totals belong in the decision, along with the runner minutes that shrink alongside the session. ### [When many jobs share one session](#when-many-jobs-share-one-session)Copy Anchor Link Each builder profile maps to one dedicated builder VM, and jobs that name the same profile run on that VM in parallel. Overlapping jobs share one session, billed from the first job's builder start to the last job's completion. The documentation works through two jobs whose windows overlap and bills the pair as a single session spanning both. That changes how a fan-out workflow prices out. Ten parallel image jobs on one 32 vCPU profile, overlapping inside a 12 minute window, cost $1.44 of builder time in total rather than $1.44 each. The runner side does not consolidate: ten jobs on `warp-ubuntu-latest-x64-4x` for 12 minutes each bill 120 runner minutes at $0.008, or $0.96\. Splitting the same ten jobs across two profiles doubles the builder line and splits the layer cache, which is usually the wrong trade. Multi-architecture profiles work the other way. Each architecture runs on its own builder instance, so a multi-arch build opens one session per architecture and bills them independently, and those sessions stay alive until the post-action steps finish. Idle time is bounded when a builder runs through the GitHub Actions integration. Builders carry a built-in timeout so accounts are not charged for idle builders, and the documentation recommends invoking the configure action immediately before the build step. The CLI path is the exception. A builder assigned through the API bills until you call the session-complete endpoint, so a script that exits without completing the session keeps the meter running. ### [Where each line appears](#where-each-line-appears)Copy Anchor Link The [reports view](/docs/ci/features/reports) is where the two lines separate. The Docker Builder tab shows total cost and total session count for the period, a daily chart grouped by profile or architecture, and a table of individual sessions with profile, architecture, duration, and cost. The CI tab shows per-job runner cost with execution time and billed time side by side. Every tab filters by date range and exports to CSV, which is the practical way to attribute builder spend back to the repositories that trigger it. ### [What the bill never adds](#what-the-bill-never-adds)Copy Anchor Link Builders attach to jobs from any platform. Profile sizes and configuration options are listed on the [remote Docker builders page](/runners/docker-builders), and [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) covers the full setup around them. ## [Related Questions](#related-questions)Copy Anchor Link ### [Do I pay for the GitHub Actions runner and the Docker builder at the same time?](#do-i-pay-for-the-github-actions-runner-and-the-docker-builder-at-the-same-time)Copy Anchor Link Yes. The runner and the builder are two separate resources and both are charged. The runner bills per minute for the job, and the builder bills per minute for the session it stays up, so a job that offloads its image build carries two lines. See [paying for both the runner and the Docker builder](/answers/do-i-pay-for-both-the-runner-and-the-docker-builder) for how that lands on a monthly invoice. ### [Does a builder session cost more when ten jobs share it?](#does-a-builder-session-cost-more-when-ten-jobs-share-it)Copy Anchor Link No. Concurrent jobs that name the same builder profile share one session, billed from the first job's builder start to the last job's completion. Ten parallel jobs on a 32 vCPU profile for a 12 minute window cost $1.44 of builder time in total, and each job still bills its own runner minutes. The [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) page covers profile layout for fan-out workflows. ### [How is a multi-architecture build billed?](#how-is-a-multi-architecture-build-billed)Copy Anchor Link Each architecture runs on a separate builder instance, so a multi-arch profile opens one session per architecture and bills them independently. arm64 and multi-arch profiles support a maximum size of 64 vCPU, so the 96 vCPU and 192 vCPU rows apply to amd64-only profiles. Sizes per architecture are on the [remote Docker builders page](/runners/docker-builders), and the rate list is on the [pricing page](/pricing). --- # How Long Are Artifacts Kept? | WarpBuild URL: https://www.warpbuild.com/answers/how-long-are-github-actions-artifacts-kept Description: GitHub keeps workflow artifacts for 90 days by default. Public repositories allow 1 to 90 days, private repositories 1 to 400, and each upload can set less. # How Long Are Artifacts Kept? GitHub keeps workflow artifacts for 90 days by default. Public repositories allow 1 to 90 days, private repositories 1 to 400, and each upload can set less. GitHub keeps workflow artifacts and log files for 90 days by default, and that period is a setting rather than a fixed rule: public repositories can move it anywhere from 1 to 90 days, private repositories from 1 to 400 days, and any single upload can ask for a shorter window with `retention-days`. Holding large artifacts for a long time is a storage bill decision rather than a technical one, because the artifact service charges for gigabytes held over time and the 90 day default multiplies every upload by 90. ## [Answer](#answer)Copy Anchor Link The default and the ranges come from GitHub's [organization retention documentation](https://docs.github.com/en/organizations/managing-organization-settings/configuring-the-retention-period-for-github-actions-artifacts-and-logs-in-your-organization), checked on 2026-08-13\. Four places can set the number, and the narrowest one wins. | Where it is set | Field | Allowed range | What it covers | | --------------- | ------------------------------------------------------ | --------------------------------------------- | -------------------------------------------- | | Repository | Settings, Actions, General, Artifact and log retention | 1 to 90 days public, 1 to 400 days private | New artifacts and logs in that repository | | Organization | Organization settings, Actions, General | Same ranges, caps every repository below it | New artifacts and logs across the org | | Enterprise | Enterprise policies for GitHub Actions | Same ranges, caps every organization below it | New artifacts and logs across the enterprise | | A single upload | retention-days on actions/upload-artifact | 1 day up to the limit above | That one artifact | Two rules decide what actually happens. A retention change applies to new artifacts and log files only and never reaches back to existing objects, so lowering the repository default today leaves last week's 90 day uploads on their original schedule. And the per-upload value cannot exceed the repository, organization, or enterprise limit, per GitHub's [store and share data tutorial](https://docs.github.com/en/actions/tutorials/store-and-share-data), so `retention-days` is a way to shorten a window and never a way to extend one. Splitting the window by artifact is the change worth making first, because one job usually produces outputs with very different shelf lives. Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: make build - name: Upload failure triage bundle if: failure() uses: actions/upload-artifact@v4 with: name: debug-bundle path: build/debug retention-days: 3 - name: Upload release binary uses: actions/upload-artifact@v4 with: name: release-binary path: build/release/app retention-days: 30 ``` The triage bundle exists for whoever reads the failed run that afternoon, so 3 days covers it. The binary might be pulled by a release job or a rollback, so it holds for 30\. Neither one needs the 90 day default, and the run that produced them can be deleted outright, which removes its artifacts from storage with it, as the [remove workflow artifacts documentation](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/remove-workflow-artifacts) describes. The same page notes that the REST API returns an `expires_at` value per artifact, which is the fastest way to confirm what a repository is really holding. Every rule above behaves identically on all four, because artifacts are stored by GitHub rather than by the runner. ## [Detail](#detail)Copy Anchor Link ### [The storage model behind the number](#the-storage-model-behind-the-number)Copy Anchor Link Retention cost follows one multiplication: artifact size in GB x uploads per day x retention days = gigabytes held in steady state GitHub bills shared storage for artifacts and GitHub Packages at $0.25 per GB-month beyond the allowance included with the plan, accruing from hourly usage, and its own worked example divides GB-hours by 744 hours per month ([GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13). Take a repository that uploads one 250 MB artifact on each of 40 runs a day. | Retention window | Gigabytes held in steady state | Monthly storage at $0.25 per GB-month | | -------------------- | ------------------------------ | ------------------------------------- | | 1 day | 10 | $2.50 | | 3 days | 30 | $7.50 | | 7 days | 70 | $17.50 | | 30 days | 300 | $75.00 | | 90 days, the default | 900 | $225.00 | Nothing about the workflow changed across those rows. The only variable is the number in the retention field, and it moves the monthly line by a factor of 90 from top to bottom. This is also why a cleanup pass feels slow to pay off: deleting artifacts frees space for current storage without reducing the usage already accrued in the current billing cycle, so the saving lands in the next cycle. ### [When rebuilding costs less than holding](#when-rebuilding-costs-less-than-holding)Copy Anchor Link For anything reproducible, retention competes with a rebuild, and both sides carry a rate. * One GB-day of artifact storage: $0.25 divided by the 744 hour month is $0.0081. * One 250 MB artifact held for a day: $0.0020\. Held for the full 90 days: $0.18. * A 12 minute rebuild on `warp-ubuntu-latest-x64-4x` at $0.008 per minute from the [pricing page](/pricing), checked on 2026-08-13: $0.096. * Break-even: $0.096 divided by $0.0020 is about 48 days. So a reproducible artifact that someone requests once a quarter is cheaper to rebuild than to keep past roughly seven weeks. The arithmetic inverts for anything you cannot reproduce on demand: signed binaries, outputs built from a base image tag that has since moved, or a build whose inputs are gone. Those belong in durable storage you control, with a retention policy you set, rather than in a 400 day artifact. Pricing on the runner side of that sum is purely usage based. ### [For artifacts a later job needs, use a registry and a digest](#for-artifacts-a-later-job-needs-use-a-registry-and-a-digest)Copy Anchor Link A long retention window is the wrong container for an output that a deploy job consumes. Push the image once, capture the digest, and reference that digest downstream. Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-4x outputs: digest: ${{ steps.push.outputs.digest }} steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: push uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} deploy: needs: build runs-on: warp-ubuntu-latest-x64-2x steps: - run: | docker pull ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }} ``` The digest pins the exact bytes the build produced, so the deploy job cannot pick up a retagged image. Registry layers are shared across builds, so a hundred runs of a mostly unchanged image store one copy of the common layers instead of a hundred full artifacts. And the retention question moves to a registry policy you write once. [Build once, deploy many on GitHub Actions](/guides/build-once-deploy-many-github-actions) covers the full shape, and [large artifact uploads on GitHub Actions](/guides/large-artifact-uploads-github-actions) covers the cases that stay in the artifact service. ### [Where retention shows up in your numbers](#where-retention-shows-up-in-your-numbers)Copy Anchor Link Artifact gigabytes appear on GitHub's billing surface, since GitHub stores them. What the WarpBuild side gives you is the other half of the break-even: the Jobs section of the [Billing report](/docs/ci/features/reports) reports duration percentiles per repository, workflow, and job name, so the rebuild minute count in the arithmetic above is a measured number rather than a guess. Keep artifact retention separate from cache expiry when you tune either one. A cache entry under the [WarpBuild caching documentation](/docs/ci/features/caching) expires 7 days after its last use, and each restore resets that clock, so hot entries survive indefinitely. Artifacts have no such reset, which is exactly why the default sits so far out at 90 days. The vocabulary is pinned down in [artifact retention](/glossary/artifact-retention). Upload and download steps also spend wall-clock time inside the job, so trimming what you upload shortens runs as well as the storage line. [Speeding up GitHub Actions](/guides/speed-up-github-actions) covers that side of the same change. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can I set a different retention period for each artifact in a workflow?](#can-i-set-a-different-retention-period-for-each-artifact-in-a-workflow)Copy Anchor Link Yes. The `retention-days` input on `actions/upload-artifact` sets a window for that artifact alone, so a triage bundle can expire in 3 days while a release binary from the same job holds for 30\. The value cannot exceed the limit set by the repository, organization, or enterprise, so the per-upload setting only ever shortens the window. GitHub documents the input in its [store and share data tutorial](https://docs.github.com/en/actions/tutorials/store-and-share-data). ### [Does lowering the repository retention period delete artifacts already stored?](#does-lowering-the-repository-retention-period-delete-artifacts-already-stored)Copy Anchor Link No. A retention change applies to new artifacts and log files only and does not apply retroactively to existing objects. Remove the existing ones from the workflow run summary, the REST API, or `gh run delete`, all covered in the [remove workflow artifacts documentation](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/remove-workflow-artifacts). GitHub also notes that deleting artifacts frees space for current storage without reducing the accrued usage already counted in the current billing cycle, so the new number shows up on the following invoice. ### [Do artifacts expire the same way as caches?](#do-artifacts-expire-the-same-way-as-caches)Copy Anchor Link No. Artifact retention is a countdown from upload that nothing resets, so a 90 day artifact disappears 90 days after the run that produced it. A WarpBuild cache entry expires 7 days after its last use, and every restore restarts that window, so a busy cache entry never ages out. The two are separate meters with separate rates, described in the [caching documentation](/docs/ci/features/caching) and on the [pricing page](/pricing). --- # How Long Do GitHub Actions Caches Last? | WarpBuild URL: https://www.warpbuild.com/answers/how-long-do-github-actions-caches-last Description: A WarpBuild cache entry expires 7 days after its last use, and every restore restarts that window. Key, cache version, and branch scope decide what survives. # How Long Do GitHub Actions Caches Last? A WarpBuild cache entry expires 7 days after its last use, and every restore restarts that window. Key, cache version, and branch scope decide what survives. Last verified: 2026-08-13 A WarpBuild cache entry expires 7 days after its last use, and it can be deleted manually at any time from the action or the console. The clock counts from the last restore rather than from the moment the entry was written, so an entry that jobs keep asking for stays alive indefinitely, and one that nothing asks for drops out a week later. ## [Answer](#answer)Copy Anchor Link The 7 day rule is documented in the [WarpBuild caching documentation](/docs/ci/features/caching), and it is a sliding window. Every restore of an entry resets the countdown. A dependency cache on `main` that gets restored on every pull request run never reaches day 7, while the entry a feature branch wrote before the branch merged reaches it about a week after the last job touched that branch. Expiry is only one of the four ways an entry stops being useful to you. The other three end its usefulness earlier, and they are the reason most teams believe their cache expired when it did not. | What ends the entry | When it happens | What you see in the job log | | --------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | Expiry after 7 days of no use | 7 days after the last restore or write | Cache not found for the exact key, no partial match either | | Manual deletion | Whenever you run it, from the action or the console | Cache not found on the next run, immediately | | A changed key | The next run after the key expression changes | Cache not found for the primary key, restore-keys may still match a stale entry | | A changed cache version or branch scope | The next run on a different runner OS, a different path list, or a different branch | Cache not found even though the key string is identical | The last row is the one that costs teams the most time. Cache version is a hash over the compression tool the runner uses and over the `path` list being cached, so two entries with the same key and different versions are two separate entries that never match each other. WarpBuild caching is available on Linux and macOS runners. It is unsupported on Windows runners, per the limitations section of the caching documentation, so nothing in this page applies to a Windows job. ## [Detail](#detail)Copy Anchor Link ### [The window slides on every restore](#the-window-slides-on-every-restore)Copy Anchor Link Treat the 7 days as an idle timer on each entry rather than a retention period on the cache as a whole. Three consequences follow. A hot entry never expires. A `node_modules` or Cargo registry cache keyed on a lock file that changes once a month is restored by every run in between, so each restore pushes expiry out another 7 days and the entry survives until the lock file moves. A cold entry cleans itself up. When a feature branch merges and its branch-scoped entries stop being restored, they age out about a week later without anyone writing a cleanup workflow. A weekend gap is safe and a holiday gap is not. A repository that goes quiet from Friday to Monday keeps everything. A repository that goes quiet for 10 days comes back to a full cold rebuild on the first run, because the entries crossed day 7 while nobody was pushing. The Cache tab of the [Billing report](/docs/ci/features/reports) is where this is visible in aggregate. It shows total entries, storage cost, and operations cost for a date range, split by cache type across storage, operation-hit, and operation-commit. A storage number that falls over a quiet week and climbs again on the next busy one is the expiry window doing its job. ### [Three things make an entry a different entry](#three-things-make-an-entry-a-different-entry)Copy Anchor Link An entry is scoped to its key, its version, and its branch. Change any one of the three and the restore looks for something that was never written. The key is the part you control directly. A key built from `hashFiles` on a lock file changes only when dependencies change, which is what you want. A key built from a commit SHA or a timestamp changes on every run, which writes a fresh entry every time and restores nothing. That failure mode is covered in [why a GitHub Actions cache misses every run](/answers/why-does-my-github-actions-cache-miss-every-run). The version is a hash over the compression tool in use, which follows the runner OS, and over the `path` list. Adding one directory to `path` changes the version, so a workflow edit that looks harmless turns every existing entry into a miss on the next run. The branch is the third axis. Entries are scoped to the branch that wrote them, with the default branch readable from branches cut off it. A feature branch restores what `main` wrote, and `main` does not restore what a feature branch wrote. Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - name: Restore build cache id: build-cache uses: WarpBuilds/cache@v1 with: path: | .turbo dist key: ${{ runner.os }}-build-${{ hashFiles('**/bun.lockb') }} restore-keys: | ${{ runner.os }}-build- - run: bun run build - run: bun test ``` Two details in that file decide how long the entries last in practice. The `restore-keys` prefix means a run whose exact key misses still restores the newest matching entry, and that partial restore resets the 7 day window on the older entry, which keeps a fallback alive across dependency bumps. The `path` list of `.turbo` and `dist` is part of the version hash, so adding a third directory later starts a new entry family and leaves the old one to expire on its own. ### [A macOS entry cannot restore on a Linux runner](#a-macos-entry-cannot-restore-on-a-linux-runner)Copy Anchor Link The version hash covers the compression tool, and the compression tool follows the runner OS. An entry written on `warp-macos-15-arm64-6x` therefore carries a different version from an entry written on `warp-ubuntu-latest-x64-4x`, and the two never match even when the key string and the path list are identical. | Job that wrote the entry | Job that tries to restore it | Result | | --------------------------------------------------------- | ---------------------------- | ----------------------------------------------------------- | | warp-ubuntu-latest-x64-4x | warp-ubuntu-latest-x64-8x | Restores, since OS and paths match | | warp-ubuntu-latest-x64-4x | warp-ubuntu-latest-arm64-4x | Restores when the cached paths are architecture independent | | warp-macos-15-arm64-6x | warp-ubuntu-latest-x64-4x | Misses, since the cache version differs | | warp-ubuntu-latest-x64-4x inside a container without zstd | Any runner with zstd | Misses, since the action fell back to gzip | The container row is the same rule wearing a disguise. When a job runs inside a custom container that lacks `zstd`, the action logs `zstd version: null`, falls back to gzip, and writes an entry under a different version. The next run on a normal runner asks for the zstd version and gets a 404\. The fix is to make the compression tools match across every job that shares a key, and the [caching documentation](/docs/ci/features/caching) lists the `wget`, `zstd`, and token requirements for container jobs. Plan for this when a matrix spans platforms. Give each platform its own key prefix, usually through `${{ runner.os }}`, so a miss reads as a miss instead of looking like an expiry. ### [Deleting an entry before the 7 days run out](#deleting-an-entry-before-the-7-days-run-out)Copy Anchor Link Waiting for expiry is the wrong move when an entry is wrong. A cache that captured a broken build output keeps handing that output to every job on the branch for up to a week, and each restore pushes the expiry out another 7 days, so a poisoned entry that stays busy never ages out at all. The `delete-cache` input removes it in one run. Setting it to `true` skips both restore and save and deletes the entry for that key. Copy Text ``` name: purge-build-cache on: workflow_dispatch: jobs: purge: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/cache@v1 with: path: | .turbo dist key: ${{ runner.os }}-build-${{ hashFiles('**/bun.lockb') }} delete-cache: true ``` Run it on the branch that owns the entry, since entries are branch scoped. Entries can also be deleted from the WarpBuild console at any time, which is the faster route for a one-off. Wiring the workflow above to `workflow_dispatch` keeps the purge on hand without leaving a delete step in the normal build path. ### [What a live cache costs while it waits](#what-a-live-cache-costs-while-it-waits)Copy Anchor Link Expiry has a bill attached, because storage is charged for as long as an entry lives. Rates come from the [WarpBuild pricing page](/pricing) and the pricing table in the caching documentation, checked on 2026-08-13. | Metric | Hosted runners | BYOC | | ----------------------------- | ------------------------ | -------- | | Cache storage | $0.20 per GB-month | Free | | Cache write, restore, or list | $0.0001 per operation | Free | | Snapshot restore | $0.04 per job | Included | | Snapshot storage | $0.025 per snapshot hour | Included | Worked model on hosted runners. A repository keeps 25 GB of entries alive at any moment and runs 3,000 jobs a month, each doing one restore and one write. * Storage: 25 x $0.20 = $5.00 per month. * Operations: 3,000 x 2 x $0.0001 = $0.60 per month. * Total: $5.60 per month. Now add the branch churn that the 7 day window absorbs. Say 40 feature branches open in a month, each writing one 1.5 GB entry, and 12 of them are active in any rolling week. Only those 12 are billed, since the rest have crossed day 7 and been removed: 12 x 1.5 = 18 GB, or $3.60 per month, against the 60 GB and $12.00 that indefinite retention would carry. The window is the cleanup job you did not have to write. Sizing the footprint deliberately is covered in the [guide to the GitHub Actions cache size limit](/guides/github-actions-cache-size-limit), and the per-gigabyte arithmetic in [what GitHub Actions cache storage costs](/answers/what-does-github-actions-cache-storage-cost). ### [When the 7 day window is the wrong tool](#when-the-7-day-window-is-the-wrong-tool)Copy Anchor Link Some state should outlive an idle week, and a keyed cache entry is a poor fit for it. Snapshot runners and remote Docker builders are the two parts of the product surface built for that, alongside CI observability, an MCP server, and the Action Debugger. Snapshot runners capture a runner VM mid workflow and boot later jobs from that snapshot, which suits a long provisioning step that no key expression describes well. Remote Docker builders hold a persistent layer cache on the builder's own disk rather than in keyed entries, so repeat image builds reuse layers without a restore step in the job at all. Both are described in the [guide to persistent caches for GitHub Actions](/guides/persistent-caches-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link ### [What does holding a GitHub Actions cache for 7 days cost?](#what-does-holding-a-github-actions-cache-for-7-days-cost)Copy Anchor Link On WarpBuild hosted runners, cache storage is $0.20 per GB-month and each write, restore, or list operation is $0.0001\. A repository holding 25 GB of live entries and running 3,000 jobs a month with one restore and one write per job pays $5.00 in storage and $0.60 in operations, or $5.60 a month. On BYOC, cache storage and operations are free. Full rates are on the [WarpBuild pricing page](/pricing). ### [Can two workflows in the same repository share one cache entry?](#can-two-workflows-in-the-same-repository-share-one-cache-entry)Copy Anchor Link Yes. An entry is scoped to its key, its cache version, and its branch, and the workflow name is not part of that scope. Any job on the same branch that asks for the same key and the same path list restores the same entry, and that restore resets the 7 day window for both workflows. Keep the `path` lists identical across the two workflows, since the path list feeds the version hash. ### [How do I delete a WarpBuild cache entry before it expires?](#how-do-i-delete-a-warpbuild-cache-entry-before-it-expires)Copy Anchor Link Set `delete-cache` to `true` on the `WarpBuilds/cache` action, which skips restore and save and removes the entry for that key. Entries can also be deleted from the WarpBuild console at any time. Deletion is the tool for a poisoned entry, since waiting for the 7 day expiry leaves every job on that branch restoring the bad copy, and each of those restores pushes the expiry out another week. ### [Does restoring a cache entry extend how long it lasts?](#does-restoring-a-cache-entry-extend-how-long-it-lasts)Copy Anchor Link Yes. The 7 day clock counts from last use rather than from creation, so an entry restored by a job on day 6 lives another 7 days from that restore. Entries that stop being restored, such as the ones left behind by a merged feature branch, age out on their own. Watch the totals on the Cache tab of the [Billing report](/docs/ci/features/reports) to see the steady state your repository settles into. --- # How Long Do Runner Snapshots Last? | WarpBuild URL: https://www.warpbuild.com/answers/how-long-do-runner-snapshots-last Description: Runner snapshots are deleted after 15 days, so an alias no workflow refreshes stops resolving and the next job that asks for it boots from the base image. # How Long Do Runner Snapshots Last? Runner snapshots are deleted after 15 days, so an alias no workflow refreshes stops resolving and the next job that asks for it boots from the base image. Runner snapshots are temporary and are deleted after 15 days, per the [snapshot runners documentation](/docs/ci/features/snapshot-runners). An alias that no workflow rewrites inside that window stops resolving, and the next job that requests it boots from the base image and runs normally, so an expiry costs a slow job rather than a failed one. ## [Answer](#answer)Copy Anchor Link The 15 day figure applies per snapshot, counted from when `WarpBuilds/snapshot-save` created it. Booting a job from a snapshot does not reset that clock. The only thing that keeps an alias alive is another save writing a new snapshot for the same alias string, which is why the lifetime question is really a question about how often your workflow saves. That gives a short timeline for any alias whose producing workflow stops running. | Time since the last save | State of the alias | What a job with snapshot.key= does | | ------------------------ | ------------------------- | --------------------------------------------------------- | | Day 0 to day 14 | Snapshot exists | Boots from the snapshot, WARPBUILD\_SNAPSHOT\_KEY is set | | Day 15 | Snapshot deleted | Boots from the base image, no error and no warning | | After day 15 | No snapshot for the alias | Same as a first run: base image, full setup, job succeeds | The last row is the behavior that surprises people. Requesting an alias that has never existed and requesting one that expired produce the same result: the runner boots from the base image and the job proceeds, which is the documented behavior of `snapshot.key` in the [snapshot runners documentation](/docs/ci/features/snapshot-runners). There is no failure signal to alert on, so a repository can spend weeks paying cold boot time before anyone notices that the alias went stale. The detectable signal is the environment variable. A runner created from a snapshot carries `WARPBUILD_SNAPSHOT_KEY` set to the alias it booted from, per the same documentation, and a runner that fell back to the base image does not. A single step that reads it turns a silent expiry into a visible one. Copy Text ``` - name: Report snapshot state run: | if [ -z "$WARPBUILD_SNAPSHOT_KEY" ]; then echo "::warning::cold boot, no snapshot for this alias" else echo "restored from $WARPBUILD_SNAPSHOT_KEY" fi ``` Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. A snapshot label on a Windows, macOS, or BYOC runner is silently ignored, so on those runner types there is no alias to expire in the first place. The Ubuntu labels that accept snapshot labels are listed in the [cloud runners documentation](/docs/ci/cloud-runners). ## [Detail](#detail)Copy Anchor Link ### [What an expiry costs on each runner size](#what-an-expiry-costs-on-each-runner-size)Copy Anchor Link An expiry is a wall-clock cost first. Take a job with 7 minutes of environment setup that a warm snapshot skips. On a miss, the job pays those 7 minutes back and skips the snapshot boot penalty, which the [snapshot runners documentation](/docs/ci/features/snapshot-runners) puts at 45 to 60 seconds, so the run lands about 6 minutes longer than a warm one. The dollar effect runs the other way on small runners, because a miss also skips the $0.04 snapshot restore fee from the [pricing page](/pricing), checked on 2026-08-13\. Per-minute rates below come from the same page, and the delta column is `(7 - 1) x rate - $0.04`. | Runner label | Per minute | Added minutes on a miss | Dollar delta per job | | -------------------------- | ---------- | ----------------------- | -------------------- | | warp-ubuntu-latest-x64-2x | $0.004 | 6 | \-$0.016 | | warp-ubuntu-latest-x64-4x | $0.008 | 6 | $0.008 | | warp-ubuntu-latest-x64-8x | $0.016 | 6 | $0.056 | | warp-ubuntu-latest-x64-16x | $0.032 | 6 | $0.152 | | warp-ubuntu-latest-x64-32x | $0.064 | 6 | $0.344 | On a 2 vCPU runner the expired alias is marginally cheaper per job, which matches the break-even arithmetic for that size. On a 32 vCPU runner the same expiry adds $0.344 per job and 6 minutes of pull request latency, and 500 jobs a month turns that into $172.00 and roughly 50 hours of added job time. Storage stops billing when the snapshot is deleted, which is covered in [how snapshot storage costs are billed](/answers/how-are-snapshot-runner-storage-costs-billed). ### [Refreshing the alias before it expires](#refreshing-the-alias-before-it-expires)Copy Anchor Link The reliable pattern is a job on the default branch that boots clean and republishes the snapshot. Merges keep it fresh in active repositories, and a schedule covers the quiet weeks. A weekly cron gives two refresh attempts inside every 15 day lifetime, so one missed run does not expire the alias. Copy Text ``` name: refresh-snapshot on: schedule: - cron: "17 6 * * 1" workflow_dispatch: jobs: refresh: runs-on: warp-ubuntu-latest-x64-4x;snapshot.enabled=true steps: - uses: actions/checkout@v5 - name: Install system packages run: | sudo apt-get update sudo apt-get install -y libvips-dev protobuf-compiler - name: Install dependencies run: npm ci - name: Warm build caches run: npm run build - name: Cleanup credentials run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot uses: WarpBuilds/snapshot-save@v1 with: alias: "web-app-main" fail-on-error: true wait-timeout-minutes: 60 ``` Three details in that file carry weight. The job uses `snapshot.enabled=true` rather than `snapshot.key`, so the refresh always starts from the base image and the published snapshot never accumulates drift from a chain of restores. The cron minute is 17 rather than 0, because GitHub documents that the `schedule` event can be delayed during periods of high load and recommends scheduling away from the top of the hour ([GitHub Actions events reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule)). And `workflow_dispatch` sits alongside the schedule so anyone can rebuild the alias by hand after a base image change. One GitHub behavior can silently end the refresh: in a public repository, scheduled workflows are disabled automatically when no repository activity has occurred in 60 days, per the same reference. A repository quiet enough to need the cron is exactly the repository where GitHub may switch it off, so treat a public repository schedule as a backstop and keep the merge-triggered save as the primary refresh. ### [Feature branch aliases and the cleanup step](#feature-branch-aliases-and-the-cleanup-step)Copy Anchor Link The feature branch example in the [snapshot runners documentation](/docs/ci/features/snapshot-runners) uses one alias per project, refreshed by every run, with the cleanup step immediately before the save. Every push boots from the previous snapshot for that alias and writes a new one, so the alias never approaches its 15 day lifetime while the branch is active, and the live snapshot count stays at one. Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-2x;snapshot.key=my-project-snapshot steps: - uses: actions/checkout@v5 - name: Install dependencies run: npm ci - name: Cleanup credentials run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot uses: WarpBuilds/snapshot-save@v1 with: alias: "my-project-snapshot" fail-on-error: true wait-timeout-minutes: 60 ``` The cleanup step matters more on this pattern than on any other, because a snapshot that gets rewritten on every branch push carries whatever the last run left on disk. `rm -rf $HOME/.ssh $HOME/.aws` removes the credential locations, and `git clean -ffdx` removes untracked files from the working tree, including files ignored by `.gitignore`. On a public repository any contributor can read the alias out of the workflow file and boot a job from it, so credentials must be gone before the save runs. Aliases interpolated from a branch name, such as `snapshot.key=deps-${{ github.head_ref }}`, behave differently on expiry. Each merged branch leaves a snapshot that no workflow ever refreshes again, and the 15 day lifetime is what eventually clears it. Retiring one before then is covered in [how to invalidate a runner snapshot](/answers/how-do-i-invalidate-a-runner-snapshot), and the rollout patterns are on the [snapshot runners page](/runners/snapshot). ## [Related Questions](#related-questions)Copy Anchor Link ### [What happens when a snapshot alias expires?](#what-happens-when-a-snapshot-alias-expires)Copy Anchor Link Nothing fails. A job whose `runs-on` carries `snapshot.key=` for an expired alias boots from the base image and runs its setup steps again, exactly as it would on the first run before any snapshot existed. The only symptoms are the cold job duration and an unset `WARPBUILD_SNAPSHOT_KEY` environment variable. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) is the reference for that fallback behavior. ### [How often should a workflow rewrite its snapshot?](#how-often-should-a-workflow-rewrite-its-snapshot)Copy Anchor Link Often enough that the gap between two saves stays under 15 days. A save on every merge to the default branch covers active repositories on its own. Repositories that go quiet for a week or two need a scheduled run, and a weekly cron gives two chances to refresh before the 15 day lifetime ends. The [snapshot runners page](/runners/snapshot) walks through which workloads justify keeping an alias alive at all. ### [Does a snapshot expire 15 days after creation or 15 days after its last use?](#does-a-snapshot-expire-15-days-after-creation-or-15-days-after-its-last-use)Copy Anchor Link After creation. Restoring from a snapshot does not extend its life, so a busy alias still ages out on schedule unless a workflow calls `WarpBuilds/snapshot-save` again and writes a new snapshot for that alias. Because storage bills for every hour a snapshot exists, the lifetime also caps what an abandoned alias can cost, as [how snapshot storage costs are billed](/answers/how-are-snapshot-runner-storage-costs-billed) sets out against the rates on the [pricing page](/pricing). --- # How Many GitHub Actions Jobs Can Run at Once? | WarpBuild URL: https://www.warpbuild.com/answers/how-many-github-actions-jobs-can-run-at-once Description: Three ceilings decide it: the concurrency key you wrote, the concurrent job limit on your GitHub plan of 20 to 500, and the runner capacity behind runs-on. # How Many GitHub Actions Jobs Can Run at Once? Three ceilings decide it: the concurrency key you wrote, the concurrent job limit on your GitHub plan of 20 to 500, and the runner capacity behind runs-on. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link There is no single number, because three separate ceilings sit between a queued job and a running one: the `concurrency` key you wrote in workflow YAML, the concurrent job limit attached to your GitHub plan, and the capacity of the runner pool behind your `runs-on` labels. On GitHub-hosted runners the binding number is usually the plan ceiling, which runs from 20 concurrent jobs on Free to 500 on Enterprise, while jobs on managed or self-hosted labels sit outside that count and are limited only by how many matching runners can come online. Take the three in order, because the fix for each is different. **1\. The concurrency key you wrote.** This is a throttle you opted into. A group holds one running item and one pending item; a third arrival cancels the pending one. Set at the workflow level it governs whole runs, and set inside a job it governs that job across runs. It never makes anything start sooner, so its symptoms are runs marked Waiting or Canceled rather than a long queue of jobs. The [concurrency limit glossary entry](/glossary/concurrency-limit) covers the term itself. **2\. The concurrent job ceiling on your GitHub plan.** Standard GitHub-hosted runners draw from shared pools with a fixed ceiling per account, and macOS carries a lower sub-ceiling. Figures below come from [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13. | GitHub plan | Concurrent jobs | Concurrent macOS jobs | | ----------- | --------------- | --------------------- | | Free | 20 | 5 | | Pro | 40 | 5 | | Team | 60 | 5 | | Enterprise | 500 | 50 | The ceiling is account wide, so one repository fanning out a large matrix delays workflows in every other repository on the account. A matrix also has its own cap: 256 jobs per workflow run, from the same reference. **3\. Runner capacity behind your labels.** Jobs targeting self-hosted or managed labels are outside the plan ceiling. Their queue depends on how many matching runners can come online: the count of registered machines for a static fleet, the maximum runner setting for an autoscaled fleet such as an ARC scale set, or whatever the managed provider holds behind the label. That third ceiling is the one a runner provider changes. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts as workflows fan out. That statement carries the scope in the [cloud runners documentation](/docs/ci/cloud-runners): features that are Generally Available support unlimited concurrency on Linux and Windows runners, while features in beta may carry limits. macOS sits outside it. macOS concurrency runs on a per-organization quota, so teams that need high macOS concurrency arrange the quota with support at [support@warpbuild.com](mailto:support@warpbuild.com). Teams fanning out simulator and device test jobs should read the guide to [running iOS jobs in parallel on GitHub Actions](/guides/parallel-ios-jobs-github-actions) before sizing a macOS matrix. ## [Detail](#detail)Copy Anchor Link ### [Match the symptom to the ceiling before changing YAML](#match-the-symptom-to-the-ceiling-before-changing-yaml)Copy Anchor Link Each ceiling produces a different failure signature. Reading the signature costs a minute and saves a sprint of edits to the wrong file. | What you see | Ceiling responsible | | ----------------------------------------------------------- | ------------------------------------------ | | A run shows a Waiting banner naming a concurrency group | The concurrency key | | A queued run flips to Canceled when a newer commit lands | The concurrency key | | Running job count plateaus at 20, 40, 60, or 500 | GitHub plan ceiling | | macOS jobs queue while Linux jobs on the same account start | GitHub plan ceiling, macOS sub-limit | | Jobs on one label queue while other labels start on time | Runner capacity for that label | | Jobs on a label never start and produce no logs | Label mismatch or runner group permissions | A job whose labels match zero online runners waits forever, which reads as a capacity problem and is a routing problem. Check the label spelling against the catalog before adding capacity. The full symptom to mechanism walkthrough lives in the guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits). ### [A workflow that throttles runs and fans out jobs](#a-workflow-that-throttles-runs-and-fans-out-jobs)Copy Anchor Link The two halves of concurrency control belong in the same file. The `concurrency` block below keeps one lane per branch, and the matrix underneath fans a test suite across 24 parallel jobs on two WarpBuild labels. Copy Text ``` name: test-suite on: pull_request: push: branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: test: strategy: fail-fast: false matrix: runner: - warp-ubuntu-latest-x64-4x - warp-ubuntu-latest-arm64-4x shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/12 package: needs: test runs-on: warp-windows-latest-x64-4x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run package ``` Four details in that file decide how many jobs run at once: * The `group` expression builds one lane per workflow per ref, so a push to a feature branch never parks a run on `main`. * `cancel-in-progress` cancels superseded pull request runs and lets `main` runs finish, which keeps deploy gates honest. * The matrix produces 2 labels by 12 shards, so 24 jobs start together. Both `runs-on` values are managed labels, so those 24 jobs do not consume the plan ceiling. * `fail-fast: false` keeps the other 23 shards running when one fails. Add `max-parallel` under `strategy` when a downstream dependency cannot absorb the fan-out. ### [The machines those labels resolve to](#the-machines-those-labels-resolve-to)Copy Anchor Link Every rate and shape below comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | runs-on label | OS | vCPU | RAM | Storage | Rate per minute | | --------------------------- | ------------------- | ---- | ---- | --------- | --------------- | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16GB | 150GB SSD | $0.006 | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16GB | 256GB SSD | $0.016 | | warp-macos-latest-arm64-6x | macOS | 6 | 14GB | 120GB SSD | $0.08 | Linux sizes run from 2 vCPU to 32 vCPU on both architectures, Windows from 4 vCPU to 32 vCPU, and macOS at 6 or 12 vCPU. Rates scale with the size, so a 32 vCPU Linux x64 runner bills at $0.064 per minute. Full per-minute rates by runner type are on the [pricing page](/pricing). ### [Fan-out buys back wall clock time and leaves billed minutes alone](#fan-out-buys-back-wall-clock-time-and-leaves-billed-minutes-alone)Copy Anchor Link This is the part teams get wrong when they plan around a ceiling. Running 24 shards at once and running them 6 at a time burn the same compute. Concurrency buys back wall clock and leaves the invoice alone. Assumptions, stated up front: the 24 shards from the workflow above, 12 on each label, each shard taking exactly 6 minutes, no retries, and no queue wait once a slot frees. Total compute is 24 jobs at 6 minutes each, so 144 job-minutes per run in every scenario. | Scenario | Slots available | Waves | Wall clock per run | Billed job-minutes | | -------------------------------------- | --------------- | ----- | ------------------ | ------------------ | | Squeezed behind a busy account ceiling | 6 | 4 | 24 min | 144 | | Room on a mid-tier plan ceiling | 12 | 2 | 12 min | 144 | | Uncapped managed labels | 24 | 1 | 6 min | 144 | Now price the 144 minutes at catalog rates, using 72 minutes on each of the two labels: * 12 shards at 6 minutes on `warp-ubuntu-latest-x64-4x`: 72 minutes at $0.008 equals $0.576. * 12 shards at 6 minutes on `warp-ubuntu-latest-arm64-4x`: 72 minutes at $0.006 equals $0.432. * Total per run: $1.008, identical in all three rows of the table. At 400 runs a month, that suite bills 57,600 job-minutes for $403.20\. Moving from the 6-slot row to the uncapped row returns 18 minutes of wall clock per run, which is 7,200 minutes a month, or 120 hours of engineers waiting on a pull request check. The dollar column does not move. ### [Measure the ceiling instead of guessing at it](#measure-the-ceiling-instead-of-guessing-at-it)Copy Anchor Link Queue wait per label is the measurement that separates the three ceilings, and it is the number to watch after a change. CI observability is part of the WarpBuild product surface alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger. The [reports documentation](/docs/ci/features/reports) covers three places the number appears: * **Queue Timings.** P75 and P90 queue wait per runner label and stack, plus run count, a daily average chart, and CSV export. * **Jobs.** Per-job queue time at P75 and P90 next to duration, CPU, and memory, aggregated per repository, workflow, and job name. * **Billing.** Per-job cost with execution time and billed time, so the cost model above can be checked against real rows rather than assumptions. Read the shape of the curve. A P90 that spikes during working hours and settles overnight points at contention for a shared ceiling. A P90 that stays high on one label around the clock points at capacity for that label. A P90 near zero on every label while pull requests still feel slow means the queue is fine and job duration is the problem. The guide to [GitHub Actions queue times](/guides/github-actions-queue-times) walks through each of those readings and the fix that matches it. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does the concurrency key control how many jobs run in parallel?](#does-the-concurrency-key-control-how-many-jobs-run-in-parallel)Copy Anchor Link No. The concurrency key holds one running item and one pending item per named group, so it throttles workflow runs, or single jobs when set at the job level. The number of jobs running in parallel inside one run comes from your GitHub plan ceiling and from runner capacity behind the `runs-on` labels. The guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) shows the group shapes that cause accidental serialization. ### [How do I tell which ceiling is holding my jobs?](#how-do-i-tell-which-ceiling-is-holding-my-jobs)Copy Anchor Link Measure queue wait per runner label before changing anything. The Queue Timings report breaks queue wait into P75 and P90 per runner label and stack with CSV export, documented in the [reports documentation](/docs/ci/features/reports). A label whose P90 climbs while other labels start on time points at capacity for that label rather than at an account ceiling. The guide to [GitHub Actions queue times](/guides/github-actions-queue-times) maps each reading to a fix. ### [Do GitHub plan ceilings apply to jobs on WarpBuild runners?](#do-github-plan-ceilings-apply-to-jobs-on-warpbuild-runners)Copy Anchor Link The per-plan concurrent job ceilings count jobs on GitHub-hosted runners. Jobs that target `warp-` labels queue on pool capacity instead, and generally available Linux and Windows runners do not have plan-level concurrency caps for generally available features on Linux and Windows runners. macOS concurrency runs on a per-organization quota, so raise it with support at [support@warpbuild.com](mailto:support@warpbuild.com). ### [How many jobs can a single matrix create?](#how-many-jobs-can-a-single-matrix-create)Copy Anchor Link A matrix generates at most 256 jobs per workflow run, checked on 2026-08-13 against [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits). Past that, split the suite across workflows or collapse a matrix dimension, and use `max-parallel` when a downstream dependency cannot take the fan-out. Price your own fan-out against the per-minute rates on the [pricing page](/pricing), then confirm the change with the Queue Timings numbers in the [reports documentation](/docs/ci/features/reports). --- # How Many Jobs Can a Matrix Fan Out To? | WarpBuild URL: https://www.warpbuild.com/answers/how-many-jobs-can-a-matrix-fan-out-to Description: A single matrix generates at most 256 jobs per workflow run. Before that cap binds, runner capacity behind runs-on and billed minutes set the usable width. # How Many Jobs Can a Matrix Fan Out To? A single matrix generates at most 256 jobs per workflow run. Before that cap binds, runner capacity behind runs-on and billed minutes set the usable width. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link A single matrix generates at most 256 jobs per workflow run, and a definition whose product lands above that fails the run before any job starts ([GitHub Actions usage limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). Two other numbers usually bind first: how many matching runners are available behind your `runs-on` label, and what the fan-out bills in runner minutes. The generated count is the product of every list under `strategy.matrix`, minus the combinations removed by `exclude`, plus the `include` entries that match no existing combination ([workflow syntax for strategy.matrix](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstrategymatrix), checked on 2026-08-13). Worked through one definition, step by step: | Definition step | Arithmetic | Jobs generated | | ----------------------------------------- | ---------- | -------------- | | os with 3 values, node with 3 values | 3 x 3 | 9 | | add shard with 8 values | 3 x 3 x 8 | 72 | | exclude the macOS plus Node 20 pair | 72 - 8 | 64 | | include one entry matching no combination | 64 + 1 | 65 | `max-parallel` moves none of those numbers. It caps how many of the generated jobs run at the same time, and the run still generates the full product ([workflow syntax for max-parallel](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstrategymax-parallel), checked on 2026-08-13). A 300 cell matrix with `max-parallel: 10` fails the same way a 300 cell matrix without it does. ## [Detail](#detail)Copy Anchor Link ### [What a wide matrix bills](#what-a-wide-matrix-bills)Copy Anchor Link Fan-out buys wall clock. The billed minutes track the work, so the cost column grows with the width while the wall clock column stays flat as long as every cell can start. Assumptions, stated up front: one dimension of shards, each cell taking 8 minutes, all cells on `warp-ubuntu-latest-x64-4x` at $0.008 per minute from the [cloud runners documentation](/docs/ci/cloud-runners), no retries, and enough capacity for every cell to start at once. The fourth column prices the same fan-out at GitHub's published rate for the 4-core Linux larger runner, $0.012 per minute ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). | Matrix width | Job-minutes per run | Cost on warp-ubuntu-latest-x64-4x | Cost on the 4-core Linux larger runner | Wall clock per run | | ------------ | ------------------- | --------------------------------- | -------------------------------------- | ------------------ | | 32 | 32 x 8 = 256 | 256 x $0.008 = $2.048 | 256 x $0.012 = $3.072 | 8 min | | 64 | 64 x 8 = 512 | 512 x $0.008 = $4.096 | 512 x $0.012 = $6.144 | 8 min | | 128 | 128 x 8 = 1,024 | 1,024 x $0.008 = $8.192 | 1,024 x $0.012 = $12.288 | 8 min | | 256 | 256 x 8 = 2,048 | 2,048 x $0.008 = $16.384 | 2,048 x $0.012 = $24.576 | 8 min | Both machines carry the same shape: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB), which is 33 percent lower list price, arithmetic (0.012 - 0.008) / 0.012\. GitHub list price checked on 2026-08-13. Run the 128 cell version 200 times a month and it bills 204,800 job-minutes, so $1,638.40 against $2,457.60 at the GitHub rate, a difference of $819.20 for the same grid. Per-minute rates for every size are on the [pricing page](/pricing). The sizes those Linux x64 labels resolve to, from the same documentation, checked on 2026-08-13: | runs-on label | vCPU | RAM | Storage | Rate per minute | | -------------------------- | ---- | ---- | --------- | --------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | ### [Compute the width instead of hardcoding it](#compute-the-width-instead-of-hardcoding-it)Copy Anchor Link A hardcoded shard list drifts from the suite it splits. Once the test count grows, half the cells finish in seconds and the other half carry the run. Emitting the width from a job output keeps the two in step and keeps the product under the cap by construction. The `discover` job below counts spec files, clamps the width to 64, and writes a JSON array to its outputs. The `test` job reads that array through `fromJSON`, so the matrix width is whatever the repository needs today ([expressions reference for fromJSON](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#fromjson), checked on 2026-08-13). Copy Text ``` name: test-matrix on: pull_request: jobs: discover: runs-on: warp-ubuntu-latest-x64-2x outputs: shards: ${{ steps.plan.outputs.shards }} width: ${{ steps.plan.outputs.width }} steps: - uses: actions/checkout@v4 - id: plan run: | count=$(find tests -name '*.spec.ts' | wc -l) width=$(( count < 64 ? count : 64 )) if [ "$width" -lt 1 ]; then width=1; fi shards=$(seq 1 "$width" | jq -Rsc 'split("\n") | map(select(length > 0) | tonumber)') echo "width=$width" >> "$GITHUB_OUTPUT" echo "shards=$shards" >> "$GITHUB_OUTPUT" test: needs: discover strategy: fail-fast: false matrix: shard: ${{ fromJSON(needs.discover.outputs.shards) }} runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/${{ needs.discover.outputs.width }} ``` Three properties of that file matter for the ceiling. The clamp at 64 keeps the product below 256 even when the suite triples. `fail-fast: false` keeps the other cells running when one shard fails, so a wide grid returns a full failure list per run. Adding a second dimension multiplies against the computed width, so a two-architecture version of the same matrix generates up to 128 jobs and stays inside the cap. ### [The capacity side of the ceiling](#the-capacity-side-of-the-ceiling)Copy Anchor Link The cap is GitHub's. The capacity is your runner provider's. ([cloud runners documentation](/docs/ci/cloud-runners)). Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That scope covers generally available features on Linux and Windows runners; for high concurrency on macOS runners, contact [support@warpbuild.com](mailto:support@warpbuild.com). Check the position rather than taking it on faith. The [reports documentation](/docs/ci/features/reports) covers the Queue Timings report, which gives queue wait at P75 and P90 per runner label and stack, plus run count, a daily average chart, and CSV export. Widen the matrix from 32 cells to 128, then read the same label a week later. A P90 that holds steady means the extra cells started on time and the wall clock column above is real. A P90 that climbs with the width means the cells are waiting on capacity, and the wall clock saving you priced is going to the queue instead. The guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) maps each reading to the ceiling responsible for it. ## [Related Questions](#related-questions)Copy Anchor Link ### [What is the maximum number of jobs a GitHub Actions matrix can create?](#what-is-the-maximum-number-of-jobs-a-github-actions-matrix-can-create)Copy Anchor Link A matrix generates at most 256 jobs per workflow run, checked on 2026-08-13 against [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits). The count is the product of every list under `strategy.matrix`, minus combinations removed by `exclude`, plus `include` entries that match no existing combination. A definition whose result is larger fails the run before any job starts. The [build matrix glossary entry](/glossary/build-matrix) covers the semantics of each key, and the guide to [fixing a GitHub Actions matrix explosion](/guides/github-actions-matrix-explosion) covers the edits that bring a product back under the cap. ### [Does max-parallel change the 256 job cap?](#does-max-parallel-change-the-256-job-cap)Copy Anchor Link No. `max-parallel` caps how many of the generated jobs run at the same time, and the generated count stays the product of the matrix dimensions. Reach for it when something outside the runner fleet sets the ceiling, such as a rate limited API or a shared test database. When the product itself is over 256, split the suite across workflows or collapse a dimension, as covered in the guide to [building a GitHub Actions matrix that scales](/guides/github-actions-matrix-builds). ### [How do I tell whether a wide matrix is really running in parallel?](#how-do-i-tell-whether-a-wide-matrix-is-really-running-in-parallel)Copy Anchor Link Read queue wait per runner label in the Queue Timings report, which gives P75 and P90 per label and stack with CSV export ([reports documentation](/docs/ci/features/reports)). A 128 cell matrix whose P90 climbs with the width is waiting on capacity for that label. A flat P90 across widths means every cell started on time and job duration sets the wall clock. The guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) walks through the three ceilings that produce each reading. Price your own fan-out against the per-minute rates on the [pricing page](/pricing), then confirm the width with the Queue Timings numbers in the [reports documentation](/docs/ci/features/reports). --- # How Much Disk Do macOS Runners Have? | WarpBuild URL: https://www.warpbuild.com/answers/how-much-disk-do-macos-github-actions-runners-have Description: WarpBuild macOS runners carry a 120GB SSD on the 6 vCPU labels and a 270GB SSD on the 12 vCPU labels, and that volume is deleted when the runner terminates. # How Much Disk Do macOS Runners Have? WarpBuild macOS runners carry a 120GB SSD on the 6 vCPU labels and a 270GB SSD on the 12 vCPU labels, and that volume is deleted when the runner terminates. A WarpBuild macOS runner carries a 120GB SSD on the 6 vCPU labels and a 270GB SSD on the 12 vCPU labels, and the figure comes from the runner row you name in `runs-on` ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). That volume is ephemeral: it is deleted when the runner is terminated, so anything the job needs to keep goes to an artifact, a cache, or a registry before the job ends. ## [Answer](#answer)Copy Anchor Link Five macOS labels ship in the catalog, and storage moves with the size rather than with the macOS release. | Runner label | OS | vCPU | Memory | Storage | Per minute | Alias | | ----------------------- | -------- | ---- | ------ | --------- | ---------- | --------------------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | none | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | none | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | none | Every row comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. macOS 13 labels were removed on June 8, 2026, so a workflow that still names one queues without a match. Volume size and free space at step one are two different numbers. The image is built from the matching upstream GitHub runner image and carries the same preinstalled tooling ([preinstalled software documentation](/docs/ci/preinstalled-software)), which means Xcode, the bundled simulator runtimes, and the rest of the toolchain already occupy part of the disk when step one starts. On the macOS 26 labels that set includes Xcode 27.0 (build 27A266a) with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes, a WarpBuild addition on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 image is in beta ([macOS 26 tooling](/docs/ci/cloud-runners#macos-26-tooling)). A dedicated macOS 27 image follows once that upstream image is released. For a baseline, GitHub documents 14 GB of SSD storage on its hosted macOS runners, standard and larger alike ([GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13). A job that fits in 14 GB has room to spare at 120GB, and a job that spends steps deleting simulator runtimes to survive may stop needing those steps at all. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and macOS pricing is flat per minute by size: $0.08 for the 6 vCPU labels and $0.16 for the 12 vCPU labels ([pricing page](/pricing), checked on 2026-08-13). ## [Detail](#detail)Copy Anchor Link ### [What consumes the disk in an Xcode job](#what-consumes-the-disk-in-an-xcode-job)Copy Anchor Link Four things account for nearly every macOS runner disk failure, and they are worth knowing by path because that is what a cleanup step needs. | Consumer | Where it lands | Grows with | | ------------------------------ | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | | DerivedData | DerivedData/Build, DerivedData/Index.noindex, DerivedData/ModuleCache.noindex | Module count, configuration count, index store size | | Simulator runtimes and devices | \~/Library/Developer/CoreSimulator/Profiles/Runtimes, \~/Library/Developer/CoreSimulator/Devices | Every extra runtime downloaded at job time, every booted device | | Archives and exports | build/App.xcarchive, the exported .ipa, .dSYM bundles | Binary size, bitcode-free symbol tables, asset catalogs | | Downloaded packages | SourcePackages, \~/Library/Caches/org.swift.swiftpm, Pods | Dependency count and their transitive checkouts | Delete in this order when a job runs tight: 1. Unused simulator runtimes and unused Xcode bundles. These are the largest single items on the image and your job rebuilds none of them, so removing them costs nothing beyond the delete. Pin `DEVELOPER_DIR` before you do it so a later step cannot reach for a bundle you removed. 2. DerivedData intermediates, once the archive is written. `Intermediates.noindex`, `ModuleCache.noindex`, and `Index.noindex` are rebuild artifacts; the `Build/Products` directory is what your test and export steps still read. 3. The `.xcarchive`, once the IPA is exported and the dSYMs are uploaded. Keep the export, drop the archive. 4. Package checkouts, last. They are the cheapest to lose in disk terms and the most expensive to lose in time, since dropping them means a fresh resolve on the next step that needs them. macOS runners do not support nested virtualization and cannot run Docker ([cloud runners documentation](/docs/ci/cloud-runners)), so container images are never part of the macOS disk budget. Container builds belong on remote Docker builders, which sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the product surface around the runner. ### [Measure your own headroom](#measure-your-own-headroom)Copy Anchor Link Guessing at disk pressure wastes more time than measuring it. Print free space around the archive step and the log tells you exactly how much room the job had. Copy Text ``` name: ios-archive on: workflow_dispatch: jobs: archive: runs-on: warp-macos-26-arm64-12x steps: - uses: actions/checkout@v4 - name: Disk before archive run: df -h /System/Volumes/Data - name: Archive run: | xcodebuild archive \ -workspace App.xcworkspace \ -scheme App \ -configuration Release \ -destination 'generic/platform=iOS' \ -derivedDataPath DerivedData \ -clonedSourcePackagesDirPath SourcePackages \ -archivePath build/App.xcarchive - name: Disk after archive run: | df -h /System/Volumes/Data du -sh DerivedData SourcePackages build - name: Reclaim space before the export run: | rm -rf DerivedData/Build/Intermediates.noindex rm -rf DerivedData/Index.noindex xcrun simctl delete unavailable - uses: actions/upload-artifact@v4 with: name: app-archive path: build/App.xcarchive ``` Read `df -h /System/Volumes/Data` rather than `df -h /`. On macOS the root filesystem is the read-only system volume, and the writable data volume is where the checkout, DerivedData, and every download live. The `du -sh` line names the three directories the numbers usually point at, so one run tells you whether DerivedData or the packages are the growth term in your repository. ### [Sizing the disk against the bill](#sizing-the-disk-against-the-bill)Copy Anchor Link The 12 vCPU labels double the disk and double the rate: 270GB at $0.16 per minute against 120GB at $0.08 per minute ([pricing page](/pricing), checked on 2026-08-13). Per vCPU-minute the two sizes cost the same, so the size choice is a throughput and headroom decision rather than a discount decision. A worked case: a release job that runs 20 minutes costs 20 x $0.08 = $1.60 on the 6 vCPU label and 20 x $0.16 = $3.20 on the 12 vCPU label. If the tight disk forces three cleanup steps that add four minutes, the 6 vCPU job is 24 minutes at $1.92, and the wider label buys both the space and the shorter wall clock for $1.28 more. Run the measurement workflow above on both labels before you decide, because the delta lands differently on a test job that runs on every push than on an archive that runs once per merge. ## [Related Questions](#related-questions)Copy Anchor Link **How much disk does a WarpBuild macOS runner have?** A 120GB SSD on the 6 vCPU labels and a 270GB SSD on the 12 vCPU labels, with the exact row per label on the [macOS runner hub](/runners/macos) and the full shape breakdown on the [macOS runner sizes page](/runners/macos/sizes). Runner storage is ephemeral and is deleted when the runner is terminated. **Why does df report less free space than the catalog figure?** The image ships Xcode, the bundled simulator runtimes, and the rest of the preinstalled toolchain before your job starts, and those files sit on the same volume ([preinstalled software documentation](/docs/ci/preinstalled-software)). Print `df -h /System/Volumes/Data` as the first step to record the real starting headroom. **What should I delete first when an Xcode job runs out of disk?** Unused simulator runtimes and unused Xcode bundles first, then DerivedData intermediates once the archive is written, then the `.xcarchive` after the IPA is exported, then the resolved package checkouts. The step-by-step cleanup workflow is in the [macOS runner disk space guide](/guides/macos-runner-disk-space-github-actions), and the cross-platform picture is in [which GitHub Actions runners carry large disks](/answers/do-github-actions-runners-have-large-disks). Pick a label on the [macOS runner hub](/runners/macos), check the disk against your own `df` output, and price the minutes on the [pricing page](/pricing). --- # How Much Do GitHub Actions Runners Cost? | WarpBuild URL: https://www.warpbuild.com/answers/how-much-do-github-actions-runners-cost Description: GitHub Actions runners bill per minute. WarpBuild Linux runners start at $0.003 per minute and macOS at $0.08, with no base fee, platform fee, or seat fee. # How Much Do GitHub Actions Runners Cost? GitHub Actions runners bill per minute. WarpBuild Linux runners start at $0.003 per minute and macOS at $0.08, with no base fee, platform fee, or seat fee. Last verified: 2026-08-13 GitHub Actions runners are billed by the minute, so the cost of a runner is its per-minute rate multiplied by the minutes your jobs actually run. On WarpBuild, that rate starts at $0.003 per minute for a 2 vCPU Linux ARM64 runner and $0.004 per minute for a 2 vCPU Linux x64 runner, and reaches $0.08 per minute for a 6 vCPU macOS runner. ## [Answer](#answer)Copy Anchor Link A GitHub Actions bill has two parts: runner minutes and a short list of usage add-ons such as cache storage and snapshot restores. WarpBuild charges both by usage. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits, so the first few thousand Linux minutes cost nothing. Here is the entry rate on each platform, next to the nearest GitHub-hosted shape. | Platform | WarpBuild runner label | Shape | WarpBuild rate | Nearest GitHub-hosted runner | GitHub rate | Difference | | ----------- | --------------------------- | ------------- | -------------- | ------------------------------------------------------- | ----------- | --------------------------- | | Linux x64 | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004/min | ubuntu-latest on private repositories, 2 vCPU, 8 GB | $0.006/min | 33 percent lower list price | | Linux ARM64 | warp-ubuntu-latest-arm64-2x | 2 vCPU, 8 GB | $0.003/min | ubuntu-24.04-arm on private repositories, 2 vCPU, 8 GB | $0.005/min | 40 percent lower list price | | macOS | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08/min | largest GitHub-hosted macOS ARM64 runner, 5 vCPU, 14 GB | $0.102/min | 22 percent lower list price | | Windows | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016/min | 4-core Windows larger runner, 4 vCPU, 16 GB | $0.022/min | 27 percent lower list price | To turn that into a monthly figure, take the minutes your organization already burns on GitHub Actions and multiply by the rate of the runner you plan to use. A team burning 30,000 Linux minutes a month on 4 vCPU machines pays $240.00 at $0.008 per minute. The same 30,000 minutes on 2 vCPU machines pay $120.00\. Nothing else is added for the account, the repository count, or the number of engineers pushing code. The tables below compare specific runner sizes at fixed durations. Substitute your workload's measured job times and use the [WarpBuild pricing page](/pricing) for the full rate list. GitHub rates come from the [GitHub Actions per-minute rate list](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the plan overview at [github.com/pricing](https://github.com/pricing), both checked on 2026-08-13\. WarpBuild rates come from the [WarpBuild pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners). The four rows above cover the whole catalog. ## [Detail](#detail)Copy Anchor Link ### [The arithmetic behind a runner bill](#the-arithmetic-behind-a-runner-bill)Copy Anchor Link Runner cost is minutes multiplied by rate. A job that takes 7 minutes on `warp-ubuntu-latest-x64-4x` at $0.008 per minute costs $0.056\. Run it on 500 pull requests in a month and the line item is $28.00\. Double the vCPU count and the rate doubles too, which means a bigger runner pays for itself only when it cuts wall-clock time by more than half. Billing granularity matters when jobs are short. WarpBuild bills GitHub Actions runners per minute, so a 40 second job and a 60 second job land in the same billing bucket. Fleets with thousands of tiny jobs should look at job count as closely as total minutes. Matrix builds are where that shows up first. A matrix of 12 lint jobs that each finish in 35 seconds bills as 12 minutes, while folding the same work into one job that runs for 7 minutes bills as 7\. The reverse trade holds for slow suites: splitting a 40 minute test job into 8 parallel shards of 6 minutes bills 48 minutes and returns a result in 6, which is the right trade when developers are waiting on the result and the wrong one when the job runs on a nightly schedule. Billed time and execution time are separate numbers on the invoice view, so the per-job report is the place to check how a queue of short jobs rounds up. One comparison caveat is worth stating up front. GitHub gives public repositories a 4 vCPU, 16 GB Linux shape at no charge, so open source projects on GitHub-hosted runners pay nothing for standard Linux minutes. Every GitHub number on this page is the private-repository list price, which is what paying teams actually see on an invoice. ### [Linux x64 rates](#linux-x64-rates)Copy Anchor Link | WarpBuild runner | Shape | WarpBuild rate | GitHub-hosted equivalent | GitHub rate | Difference | | -------------------------- | -------------------------- | -------------- | ----------------------------------- | ----------- | --------------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB, 150GB SSD | $0.004/min | ubuntu-latest, private repositories | $0.006/min | 33 percent lower list price | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB, 150GB SSD | $0.008/min | 4-core Linux larger runner | $0.012/min | 33 percent lower list price | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB, 150GB SSD | $0.016/min | 8-core Linux larger runner | $0.022/min | 27 percent lower list price | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB, 150GB SSD | $0.032/min | 16-core Linux larger runner | $0.042/min | 24 percent lower list price | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB, 150GB SSD | $0.064/min | 32-core Linux larger runner | $0.082/min | 22 percent lower list price | GitHub list prices checked on 2026-08-13 against the [GitHub Actions per-minute rate list](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The same rates apply to the Ubuntu 22.04 and Ubuntu 26.04 label families, so pinning `warp-ubuntu-2604-x64-4x` instead of `warp-ubuntu-latest-x64-4x` changes the image and leaves the price at $0.008 per minute. ### [Linux ARM64 rates](#linux-arm64-rates)Copy Anchor Link ARM64 is the cheapest platform in the catalog at every size. | WarpBuild runner | Shape | WarpBuild rate | GitHub-hosted equivalent | GitHub rate | Difference | | ---------------------------- | --------------- | -------------- | -------------------------------------- | ----------- | --------------------------- | | warp-ubuntu-latest-arm64-2x | 2 vCPU, 8 GB | $0.003/min | ubuntu-24.04-arm, private repositories | $0.005/min | 40 percent lower list price | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006/min | 4-core Linux ARM64 larger runner | $0.008/min | 25 percent lower list price | | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | $0.012/min | 8-core Linux ARM64 larger runner | $0.014/min | 14 percent lower list price | | warp-ubuntu-latest-arm64-16x | 16 vCPU, 64 GB | $0.024/min | 16-core Linux ARM64 larger runner | $0.026/min | 8 percent lower list price | | warp-ubuntu-latest-arm64-32x | 32 vCPU, 128 GB | $0.048/min | 32-core Linux ARM64 larger runner | $0.05/min | 4 percent lower list price | GitHub list prices checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). ARM64 runners on Ubuntu 24.04 set the working directory to `/runner/_work` instead of GitHub's `/home/runner/work/`, which matters for scripts that hardcode paths. The Ubuntu 22.04 ARM64 images were deprecated on March 31, 2025. ### [macOS rates](#macos-rates)Copy Anchor Link macOS is the most expensive platform on every GitHub Actions runner fleet, so it usually dominates the invoice for iOS and macOS teams. | WarpBuild runner | Shape | WarpBuild rate | | ----------------------- | ------------------------- | -------------- | | warp-macos-26-arm64-6x | 6 vCPU, 14 GB, 120GB SSD | $0.08/min | | warp-macos-26-arm64-12x | 12 vCPU, 28 GB, 270GB SSD | $0.16/min | | warp-macos-15-arm64-6x | 6 vCPU, 14 GB, 120GB SSD | $0.08/min | | warp-macos-15-arm64-12x | 12 vCPU, 28 GB, 270GB SSD | $0.16/min | | warp-macos-14-arm64-6x | 6 vCPU, 14 GB, 120GB SSD | $0.08/min | `warp-macos-latest-arm64-6x` is an alias for the macOS 15 6 vCPU runner, and `warp-macos-latest-arm64-12x` aliases the 12 vCPU one. The macOS 26 runners ship Xcode 27.0 with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes. The macOS arithmetic: `warp-macos-latest-arm64-6x` (6 vCPU, 14 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB), which is 22 percent lower list price on a shape that carries one more vCPU and the same memory. GitHub list price checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). GitHub also publishes a smaller 3 vCPU, 7 GB macOS runner at $0.062 per minute; that is a different shape and a different comparison, so treat it separately when you model an Xcode build that needs memory. For the full macOS breakdown, see the [macOS GitHub Actions runner cost guide](/guides/github-actions-macos-runner-cost). ### [Windows rates](#windows-rates)Copy Anchor Link | WarpBuild runner | Shape | WarpBuild rate | GitHub-hosted equivalent | GitHub rate | Difference | | --------------------------- | -------------------------- | -------------- | ----------------------------- | ----------- | --------------------------- | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB, 256GB SSD | $0.016/min | 4-core Windows larger runner | $0.022/min | 27 percent lower list price | | warp-windows-latest-x64-8x | 8 vCPU, 32 GB, 256GB SSD | $0.032/min | 8-core Windows larger runner | $0.042/min | 24 percent lower list price | | warp-windows-latest-x64-16x | 16 vCPU, 64 GB, 256GB SSD | $0.064/min | 16-core Windows larger runner | $0.082/min | 22 percent lower list price | | warp-windows-latest-x64-32x | 32 vCPU, 128 GB, 256GB SSD | $0.128/min | 32-core Windows larger runner | $0.162/min | 21 percent lower list price | GitHub list prices checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Windows Server 2025 labels carry the same rates as the Windows Server 2022 labels, and the `warp-windows-2025-vs2026-x64-*` labels carry Visual Studio 2026 at those same rates. The 2 vCPU Windows tier was removed on June 8, 2026, so 4 vCPU at $0.016 per minute is the Windows floor. Sizes and image details live on the [Windows runners page](/runners/windows). ### [Everything else that appears on the bill](#everything-else-that-appears-on-the-bill)Copy Anchor Link Runner minutes are the largest line, and these usage items sit next to them. | Line item | Rate | Notes | | ----------------------------- | ------------------------- | ----------------------------------------------------------------------------- | | Cache storage | $0.20 per GB-month | Free on BYOC runners | | Cache write, restore, or list | $0.0001 per operation | Free on BYOC runners | | Snapshot restore | $0.04 per job | Snapshots are deleted after 15 days | | Snapshot storage | $0.025 per snapshot-hour | Charged while the snapshot exists | | Networking (Tailscale) | $0.00 | Included | | Remote Docker builders | $0.06 to $0.88 per minute | 16 vCPU at $0.06, 32 vCPU at $0.12, 64 vCPU at $0.24, up to 192 vCPU at $0.88 | | BYOC runners | $0.002 per minute | The compute is billed by your own cloud account | Remote Docker builders bill per session, measured from the moment the builder action starts until the job completes. Concurrent jobs that share a builder profile share one session, so ten parallel jobs on a 32 vCPU profile at $0.12 per minute cost the same as one job holding that builder for the same wall-clock window. Multi-architecture profiles open one session per architecture and bill them independently. ### [What the bill never contains](#what-the-bill-never-contains)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. A team of 40 engineers and a team of 400 engineers pay the same rate for the same minute of the same runner. Concurrency carries no price either. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a merge queue that fans out to 200 parallel jobs pays for 200 jobs worth of minutes and nothing else. For a finance team, the practical effect of the usage-based structure is that headcount changes and repository sprawl leave the runner bill alone. Onboarding 15 engineers adds cost only when those engineers push commits that queue jobs. Archiving a repository removes its minutes from the next invoice with no plan change and no contract amendment. The forecast reduces to one line: expected job minutes per platform, multiplied by the rate for the labels those jobs use. Two items are worth budgeting separately. SSO is available for a flat $250 per month, whatever the user count, and it is charged monthly rather than per minute; the rest of the price list is on the [WarpBuild pricing page](/pricing). ### [A worked model on a real workflow](#a-worked-model-on-a-real-workflow)Copy Anchor Link This workflow runs four jobs on every pull request, one per platform. Copy Text ``` name: ci on: pull_request: branches: [main] jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm test arm-image: runs-on: warp-ubuntu-latest-arm64-4x steps: - uses: actions/checkout@v4 - run: docker build -t app:arm64 . ios-build: runs-on: warp-macos-latest-arm64-6x steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build windows-tests: runs-on: warp-windows-latest-x64-4x steps: - uses: actions/checkout@v4 - run: dotnet test ``` Assume the jobs take 6, 5, 12, and 8 minutes. | Job | Runner | Minutes | Rate | Cost per run | | ------------- | --------------------------- | ------- | ---------- | ------------ | | unit-tests | warp-ubuntu-latest-x64-4x | 6 | $0.008/min | $0.048 | | arm-image | warp-ubuntu-latest-arm64-4x | 5 | $0.006/min | $0.030 | | ios-build | warp-macos-latest-arm64-6x | 12 | $0.08/min | $0.960 | | windows-tests | warp-windows-latest-x64-4x | 8 | $0.016/min | $0.128 | | Total | 31 | $1.166 | | | At 300 pull request runs a month, that workflow costs $349.80. The same minutes on the nearest GitHub-hosted shapes cost $0.072 for the 4-core Linux larger runner, $0.040 for the 4-core Linux ARM64 larger runner, $1.224 for the largest macOS ARM64 runner, and $0.176 for the 4-core Windows larger runner: $1.512 per run, or $453.60 for 300 runs. The monthly difference is $103.80 on list prices checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). ### [A worked model on a full month](#a-worked-model-on-a-full-month)Copy Anchor Link A 40 engineer team running a mixed fleet produces an invoice like this. | Line item | Volume | Rate | Cost | | ----------------------------------- | ------------------ | ------------------ | ------- | | warp-ubuntu-latest-x64-2x minutes | 40,000 | $0.004/min | $160.00 | | warp-ubuntu-latest-arm64-4x minutes | 8,000 | $0.006/min | $48.00 | | warp-macos-latest-arm64-6x minutes | 2,000 | $0.08/min | $160.00 | | warp-windows-latest-x64-4x minutes | 1,500 | $0.016/min | $24.00 | | Cache storage | 50 GB-month | $0.20 per GB-month | $10.00 | | Cache operations | 200,000 | $0.0001 each | $20.00 | | Snapshot restores | 1,000 jobs | $0.04 per job | $40.00 | | Snapshot storage | 200 snapshot-hours | $0.025 per hour | $5.00 | | Seats | 40 engineers | none | $0.00 | | Base subscription | 1 account | none | $0.00 | | Platform fee | 1 account | none | $0.00 | | Total | $467.00 | | | Signup includes $10 free credits, so the first invoice on that usage is $457.00\. Payment details are held by Stripe, and accounts are billed on the last day of the month unless usage crosses an account threshold first. Three levers move that total. Cutting minutes through caching and better job parallelism moves the biggest line. Right-sizing each job to the smallest runner that keeps it fast moves the rate. Moving Linux jobs that have no x86 dependency to ARM64 moves both, since a 4 vCPU ARM64 runner is $0.006 per minute against $0.008 for the x64 runner of the same shape. The [guide to reducing GitHub Actions costs](/guides/reduce-github-actions-costs) works through each lever with configuration examples. ### [Seeing what you actually spend](#seeing-what-you-actually-spend)Copy Anchor Link Estimates only go so far. The WarpBuild [reports documentation](/docs/ci/features/reports) covers the billing view that breaks cost down per job, per repository, and per runner label, with separate tabs for CI runners, Docker builders, and cache. Each row shows execution time, billed time, and the split between runner cost and snapshot cost, and every table exports to CSV. Label-level attribution is the fastest way to find the expensive corner of a fleet. Group the daily cost chart by runner label and the macOS line usually stands alone, since one macOS minute at $0.08 costs as much as twenty 2 vCPU Linux x64 minutes at $0.004\. Group it by repository and the answer is usually one service with a matrix that grew without review. The Jobs tab adds duration, queue time, CPU, and memory percentiles per job, which is how you find the jobs that are paying for vCPU they never use. CPU and memory columns need CI observability enabled on the runner. Alongside those reports, snapshot runners, remote Docker builders, an MCP server, and the Action Debugger round out the product surface that shapes what a job costs. ## [Related Questions](#related-questions)Copy Anchor Link ### [How much do GitHub Actions larger runners cost?](#how-much-do-github-actions-larger-runners-cost)Copy Anchor Link On WarpBuild, a 4 vCPU Linux x64 runner costs $0.008 per minute and an 8 vCPU runner costs $0.016 per minute. GitHub lists $0.012 per minute for its 4-core Linux larger runner and $0.022 for the 8-core, checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The [larger runners cost guide](/guides/github-actions-larger-runners-cost) walks through every size class. ### [How much does a macOS GitHub Actions runner cost per minute?](#how-much-does-a-macos-github-actions-runner-cost-per-minute)Copy Anchor Link A WarpBuild macOS runner with 6 vCPU and 14 GB of memory costs $0.08 per minute. The largest GitHub-hosted macOS ARM64 runner, with 5 vCPU and 14 GB, lists at $0.102 per minute, checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). See the [macOS runner cost guide](/guides/github-actions-macos-runner-cost) for Xcode image details and sizing advice. ### [How much do Windows GitHub Actions runners cost?](#how-much-do-windows-github-actions-runners-cost)Copy Anchor Link WarpBuild Windows runners start at $0.016 per minute for 4 vCPU and 16 GB and reach $0.128 per minute for 32 vCPU and 128 GB. GitHub lists $0.022 per minute for its 4-core Windows larger runner, checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The [Windows runners page](/runners/windows) lists the image variants, including the Visual Studio 2026 labels. ### [What is the cheapest way to run GitHub Actions?](#what-is-the-cheapest-way-to-run-github-actions)Copy Anchor Link Cut billed minutes first through caching and job parallelism, then match each job to the smallest runner that keeps it fast. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the only variable you control is minutes multiplied by rate. Start with the [cost reduction guide](/guides/reduce-github-actions-costs) and the full price list on the [WarpBuild pricing page](/pricing). --- # Are ARM64 GitHub Actions Runners Cheaper Than x64? | WarpBuild URL: https://www.warpbuild.com/answers/is-arm64-cheaper-than-x64-for-github-actions Description: Yes. ARM64 runners bill 25 percent below x64 at every size, from $0.003 against $0.004 per minute at 2 vCPU to $0.048 against $0.064 at 32 vCPU. # Are ARM64 GitHub Actions Runners Cheaper Than x64? Yes. ARM64 runners bill 25 percent below x64 at every size, from $0.003 against $0.004 per minute at 2 vCPU to $0.048 against $0.064 at 32 vCPU. Last verified: 2026-08-13 Yes. ARM64 starts at $0.003 per minute against $0.004 for the x64 runner at the same 2 vCPU and 8 GB shape, and that 25 percent gap holds at every size in the Linux catalog ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). The rate is the easy half of the answer. Whether the lower rate reaches your invoice depends on how long the job runs after the switch, which is what the rest of this page works through. ## [Answer](#answer)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. Inside the Linux catalog, the two architectures ship the same five sizes with the same vCPU and RAM, so the comparison is exact at every row. | Shape | x64 label | x64 per minute | ARM64 label | ARM64 per minute | Difference | | --------------- | -------------------------- | -------------- | ---------------------------- | ---------------- | ------------------------ | | 2 vCPU, 8 GB | warp-ubuntu-latest-x64-2x | $0.004 | warp-ubuntu-latest-arm64-2x | $0.003 | $0.001 lower, 25 percent | | 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | $0.008 | warp-ubuntu-latest-arm64-4x | $0.006 | $0.002 lower, 25 percent | | 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | $0.016 | warp-ubuntu-latest-arm64-8x | $0.012 | $0.004 lower, 25 percent | | 16 vCPU, 64 GB | warp-ubuntu-latest-x64-16x | $0.032 | warp-ubuntu-latest-arm64-16x | $0.024 | $0.008 lower, 25 percent | | 32 vCPU, 128 GB | warp-ubuntu-latest-x64-32x | $0.064 | warp-ubuntu-latest-arm64-32x | $0.048 | $0.016 lower, 25 percent | Every rate above comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The same five sizes exist on Ubuntu 26.04 under labels such as `warp-ubuntu-2604-arm64-4x` and `warp-ubuntu-2604-x64-4x` at identical rates, so pinning a release does not change the arithmetic. ### [The same question against GitHub-hosted runners](#the-same-question-against-github-hosted-runners)Copy Anchor Link Line up each WarpBuild rate against GitHub's published rate for the same shape and the same architecture, and WarpBuild is lower on both lines at every size. Rates below come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing), checked on 2026-08-13. | Shape | WarpBuild x64 | GitHub x64 | x64 list price difference | WarpBuild ARM64 | GitHub ARM64 | ARM64 list price difference | | --------------- | --------------------------------- | ------------------------------------ | ------------------------- | ----------------------------------- | ---------------------------------------- | --------------------------- | | 2 vCPU, 8 GB | warp-ubuntu-latest-x64-2x $0.004 | ubuntu-latest (private repos) $0.006 | 33 percent lower | warp-ubuntu-latest-arm64-2x $0.003 | ubuntu-24.04-arm (private repos) $0.005 | 40 percent lower | | 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x $0.008 | 4-core Linux larger runner $0.012 | 33 percent lower | warp-ubuntu-latest-arm64-4x $0.006 | 4-core Linux ARM64 larger runner $0.008 | 25 percent lower | | 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x $0.016 | 8-core Linux larger runner $0.022 | 27 percent lower | warp-ubuntu-latest-arm64-8x $0.012 | 8-core Linux ARM64 larger runner $0.014 | 14 percent lower | | 16 vCPU, 64 GB | warp-ubuntu-latest-x64-16x $0.032 | 16-core Linux larger runner $0.042 | 24 percent lower | warp-ubuntu-latest-arm64-16x $0.024 | 16-core Linux ARM64 larger runner $0.026 | 8 percent lower | | 32 vCPU, 128 GB | warp-ubuntu-latest-x64-32x $0.064 | 32-core Linux larger runner $0.082 | 22 percent lower | warp-ubuntu-latest-arm64-32x $0.048 | 32-core Linux ARM64 larger runner $0.05 | 4 percent lower | The 2 vCPU row uses GitHub's private-repository rate, which is what paying teams run against. GitHub gives public repositories a 4 vCPU, 16 GB shape at no charge, and no runner rate competes with free. Read across one row for the full picture at 4 vCPU: `warp-ubuntu-latest-arm64-4x` at $0.006 is 25 percent under WarpBuild's own `warp-ubuntu-latest-x64-4x` at $0.008, which itself is 33 percent under the GitHub 4-core Linux larger runner at $0.012 (GitHub pricing, checked on 2026-08-13). The architecture switch and the platform switch stack, and the worked model below prices both. ### [Worked model on real job minutes](#worked-model-on-real-job-minutes)Copy Anchor Link A repository runs 20,000 minutes a month of unit and integration jobs on a 4 vCPU shape. Same jobs, same duration, four different labels: * `warp-ubuntu-latest-arm64-4x` at $0.006: 20,000 x $0.006 = **$120.00 per month** * `warp-ubuntu-latest-x64-4x` at $0.008: 20,000 x $0.008 = **$160.00 per month** * GitHub 4-core Linux ARM64 larger runner at $0.008: 20,000 x $0.008 = **$160.00 per month** * GitHub 4-core Linux larger runner at $0.012: 20,000 x $0.012 = **$240.00 per month** The architecture change alone is worth $40.00 a month, or $480.00 over twelve months, at this volume. Run the same model at 8 vCPU and the spread grows in absolute dollars while the percentage holds: 20,000 minutes on `warp-ubuntu-latest-arm64-8x` at $0.012 is $240.00 against $320.00 on `warp-ubuntu-latest-x64-8x`, an $80.00 monthly difference for the same 8 vCPU and 32 GB shape. ## [Detail](#detail)Copy Anchor Link ### [The rate is only half the bill](#the-rate-is-only-half-the-bill)Copy Anchor Link A per-minute rate multiplied by minutes is the invoice, so a 25 percent lower rate survives only while the minute count holds. The break-even is arithmetic you can do before you move anything. At the 4 vCPU pair, $0.008 divided by $0.006 is 1.33\. The ARM64 job can take 4 minutes for every 3 minutes the x64 job took and produce the same monthly bill. Beyond that ratio the extra minutes cost more than the rate saves. The ratio is identical at every size, because the 25 percent gap is constant across the catalog. Three things move that ratio in practice: 1. **Toolchain maturity for your language.** Compilers and runtimes with mature aarch64 backends land near parity on wall clock. Toolchains that fall back to a generic code path, or that pull a dependency with no prebuilt aarch64 wheel or binary and compile it from source at install time, add minutes to every run. 2. **Dependency install shape.** A step that fetches a prebuilt x86-64 artifact and finds no aarch64 equivalent turns into a source build. That single change can double the install step. 3. **Cache behavior.** Setup actions scope cache keys per architecture, so the first ARM64 runs on a repository start cold. Compare steady-state runs once the cache has filled, and discount the first pull request after the switch. Measure your own numbers before you commit. Job duration by step is visible in the runner observability views, and the [Graviton build guide](/guides/graviton-builds-github-actions) covers the ordering teams use when they move a pipeline whose production target is already aarch64. ### [Native ARM64 and emulated ARM64 bill differently](#native-arm64-and-emulated-arm64-bill-differently)Copy Anchor Link The $0.003 rate applies to a native aarch64 machine. A QEMU-emulated build is a different billing event: it runs on an x64 runner through `docker/setup-qemu-action` with binfmt handlers registered, so the job bills at the x64 rate and pays instruction translation on top. That combination moves the cost in both directions at once. The runner rate is the higher of the two, and the guest work takes longer than it would on native hardware, so the emulated route pays twice for producing the same aarch64 artifact. Emulation earns its place in one situation: producing an `linux/arm64` image layer inside a build that is otherwise x64, where the alternative is standing up a second job. For a test suite, a native ARM64 label runs the same binary you ship to aarch64 hosts, which is the point of testing on the architecture at all. The [cross compiling comparison](/guides/cross-compiling-vs-native-arm64-builds) works through the third option, where the compile happens on x64 with a cross toolchain and only the test job moves to ARM64. ### [Toolchain support decides whether the job runs at all](#toolchain-support-decides-whether-the-job-runs-at-all)Copy Anchor Link Rate comparisons assume the workflow completes. Two image facts decide that. The Ubuntu 24.04 ARM64 runners are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the package list is published at [actions/partner-runner-images](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md). Ubuntu 26.04 ARM64 tracks [actions/runner-images](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2604-Arm64-Readme.md). Read the list against the versions your workflow pins before you switch, because the ARM64 and x86-64 images do not ship an identical set. The arm64 images for ubuntu-22.04 were deprecated on March 31, 2025 ([preinstalled software documentation](/docs/ci/preinstalled-software)). Those images were thinner than their x86-64 siblings: they were based on the upstream `ubuntu:22.04` image, the default user was `root` rather than `runner`, and tooling had to be installed with `apt` inside the workflow. Ubuntu 24.04 and Ubuntu 26.04 ARM64 closed both gaps, so any ARM64 pricing decision starts at 24.04. ### [The Ubuntu 24.04 ARM64 work directory difference](#the-ubuntu-2404-arm64-work-directory-difference)Copy Anchor Link The arm64 images for ubuntu-24.04 set the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/` for the same instance ([cloud runners documentation](/docs/ci/cloud-runners)). This is the single most common reason a job that costs less on paper fails on the first ARM64 run. Audit four places for a hardcoded `/home/runner/work` string before the switch: * Docker `-v` bind mounts in `run` steps * coverage and test-report upload paths * `actions/cache` path globs * any script that reconstructs a path instead of reading the environment Use `${{ github.workspace }}` in YAML expressions and `$GITHUB_WORKSPACE` in shell steps and the difference stops mattering. ### [Moving one job at a time](#moving-one-job-at-a-time)Copy Anchor Link The cheapest way to test the price claim is to move one job, keep the rest where it is, and compare a week of runs. The `runs-on` label is the only line that changes. Copy Text ``` name: ci on: pull_request: jobs: unit: runs-on: warp-ubuntu-latest-arm64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm test - name: Confirm the architecture and the workspace path run: | uname -m echo "workspace=${GITHUB_WORKSPACE}" package: runs-on: warp-ubuntu-latest-arm64-8x needs: unit steps: - uses: actions/checkout@v4 - run: ./scripts/build-release.sh - uses: actions/upload-artifact@v4 with: name: release-linux-arm64 path: dist/ instrumentation: runs-on: warp-ubuntu-latest-x64-4x;nested-virtualization.enabled=true steps: - uses: actions/checkout@v4 - run: ./scripts/run-emulator-tests.sh ``` `uname -m` prints `aarch64` on the first two jobs. The third job keeps an x64 label on purpose: nested virtualization and `/dev/kvm` access are unavailable on ARM64 runners, and the `nested-virtualization.enabled=true` dynamic label is silently ignored on runner classes that do not support it ([cloud runners documentation](/docs/ci/cloud-runners)). Priced at 500 pull requests a month with the shape above, and assuming 4 minutes for `unit` and 3 minutes for `package`: 500 x 4 x $0.006 = $12.00 plus 500 x 3 x $0.012 = $18.00, for $30.00 a month. The same two jobs on x64 labels are $16.00 plus $24.00, for $40.00. ### [Where the ARM64 rate does not apply](#where-the-arm64-rate-does-not-apply)Copy Anchor Link ARM64 coverage in the catalog is Linux and macOS. The Windows catalog is x86-64 only, from `warp-windows-latest-x64-4x` at $0.016 per minute through `warp-windows-latest-x64-32x` at $0.128, so a Windows job has no architecture choice to price. The macOS runners are already ARM64, which means a macOS job has no decision to make either. Inside Linux, the ARM64 line covers everything a normal workflow does, including checkout, the language setup actions, caching, artifacts, and service containers. The one hard exclusion is nested virtualization. ## [Related Questions](#related-questions)Copy Anchor Link **Are ARM64 GitHub Actions runners cheaper than x64 runners?** On the per-minute rate, yes. `warp-ubuntu-latest-arm64-2x` is $0.003 per minute against $0.004 for `warp-ubuntu-latest-x64-2x` at the same 2 vCPU and 8 GB shape, and the 25 percent gap holds at every size from 2x through 32x. The invoice follows only when the ARM64 job keeps roughly the same duration. The full label list with sizes and rates is in the [Linux ARM64 runner catalog](/runners/linux-arm64). **How much longer can an ARM64 job run before it costs more than the x64 job?** One third longer. At $0.006 against $0.008 for the 4 vCPU pair, the ARM64 leg can take 4 minutes for every 3 x64 minutes and land on the same bill. Past that ratio the lower rate stops covering the extra minutes, so measure the duration on your own build before you move the job. The [ARM64 migration guide](/guides/arm64-github-actions-runners) covers the step types that change duration after a switch, and the [Graviton build guide](/guides/graviton-builds-github-actions) covers the case where production already runs on aarch64. **Do QEMU-emulated ARM64 builds get the ARM64 rate?** No. An emulated build runs on an x64 runner and bills at the x64 rate while translating every guest instruction, so it pays the higher rate and the translation overhead together. The ARM64 rate applies to native aarch64 labels such as `warp-ubuntu-latest-arm64-8x`. The [cross compiling comparison](/guides/cross-compiling-vs-native-arm64-builds) sets out when to cross compile on x64 and when to build natively. **What changes besides the rate when a job moves from x64 to ARM64?** Three things. Toolchain availability differs because the ARM64 image ships its own package list, the Ubuntu 24.04 ARM64 image sets the work dir to `/runner/_work` instead of GitHub's `/home/runner/work/`, and nested virtualization stays on x64 because `/dev/kvm` is unavailable on ARM64 runners. The capability side of the question is covered in [can GitHub Actions run ARM64](/answers/can-github-actions-run-arm64). Price the switch against your own job minutes on the [pricing page](/pricing), pick a label from the [Linux ARM64 runner catalog](/runners/linux-arm64), and confirm the image ships your toolchain in the [preinstalled software documentation](/docs/ci/preinstalled-software). --- # Is It Cheaper to Run Fewer, Larger Runners? | WarpBuild URL: https://www.warpbuild.com/answers/is-it-cheaper-to-run-fewer-larger-runners Description: Only when the work parallelizes. Each size step doubles the per-minute rate, so a larger runner has to cut GitHub Actions wall clock in half to break even. # Is It Cheaper to Run Fewer, Larger Runners? Only when the work parallelizes. Each size step doubles the per-minute rate, so a larger runner has to cut GitHub Actions wall clock in half to break even. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link Only when the work actually parallelizes. Rates double at every step up the Linux x64 ladder, from $0.004 per minute on `warp-ubuntu-latest-x64-2x` to $0.064 per minute on `warp-ubuntu-latest-x64-32x` per the [cloud runners catalog](/docs/ci/cloud-runners), so a machine with twice the cores has to cut wall clock by more than half before the bill goes down. That break-even is the entire arithmetic. Take a job that runs 24.0 minutes on `warp-ubuntu-latest-x64-2x` at $0.004 per minute, which bills $0.096 per run. Here is the duration each larger label needs to hold that run at $0.096. | Runner label | Shape | Rate | Duration that holds the run at $0.096 | Share of the 2 vCPU duration | | -------------------------- | --------------- | ---------- | ------------------------------------- | ---------------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004/min | 24.0 min | 100 percent | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008/min | 12.0 min | 50 percent | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016/min | 6.0 min | 25 percent | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032/min | 3.0 min | 12.5 percent | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064/min | 1.5 min | 6.25 percent | Shapes and rates come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13\. Few real jobs reach those durations, so moving a single job up the ladder is usually a purchase of wall-clock time at a higher price. The version of this question that does end in a lower invoice is different. It is about a fan-out across many small machines that repeats fixed setup on every job and leaves cores idle, and consolidating that fan-out onto one larger machine. The model below works through both. ## [Detail](#detail)Copy Anchor Link ### [Price per vCPU-minute does not change with size](#price-per-vcpu-minute-does-not-change-with-size)Copy Anchor Link The first thing to check is whether size carries a premium. It does not: the per-minute rate divided by vCPU count is constant on every ladder in the catalog. | Platform | Smallest label and rate | Largest label and rate | Price per vCPU-minute | | ----------- | --------------------------------------- | ---------------------------------------- | --------------------- | | Linux x64 | warp-ubuntu-latest-x64-2x, $0.004/min | warp-ubuntu-latest-x64-32x, $0.064/min | $0.002 | | Linux ARM64 | warp-ubuntu-latest-arm64-2x, $0.003/min | warp-ubuntu-latest-arm64-32x, $0.048/min | $0.0015 | | Windows | warp-windows-latest-x64-4x, $0.016/min | warp-windows-latest-x64-32x, $0.128/min | $0.004 | | macOS | warp-macos-latest-arm64-6x, $0.08/min | warp-macos-latest-arm64-12x, $0.16/min | $0.0133 | Rates from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The flat per-vCPU price holds on all four. Because the per-vCPU price is flat, the size label by itself moves no money. Total billed vCPU-minutes is what moves money, and three things drive that total: fixed work repeated once per job, cores that sit idle while one thread runs, and the number of jobs. ### [Measure the parallel fraction before you move up a size](#measure-the-parallel-fraction-before-you-move-up-a-size)Copy Anchor Link Run the job at its current size and again at double the vCPU, then compute the parallel fraction from the two durations. With `T1` as the current duration and `T2` as the duration on twice the cores, Amdahl's law gives `p = 2 x (1 - T2 / T1)`. A job that goes from 24.0 minutes to 14.0 minutes has `p = 2 x (1 - 0.583) = 0.83`. Feed that fraction back into the cost. The 24.0 minute, $0.096 job from the section above, moved from `warp-ubuntu-latest-x64-2x` to `warp-ubuntu-latest-x64-4x`: | Parallel fraction | Duration on 4 vCPU | Cost per run at $0.008/min | Change against $0.096 | | ----------------- | ------------------ | -------------------------- | --------------------- | | 1.00 | 12.0 min | $0.096 | no change | | 0.90 | 13.2 min | $0.106 | plus $0.010 | | 0.80 | 14.4 min | $0.115 | plus $0.019 | | 0.60 | 16.8 min | $0.134 | plus $0.038 | | 0.40 | 19.2 min | $0.154 | plus $0.058 | | 0.00 | 24.0 min | $0.192 | plus $0.096 | Anything short of a perfectly parallel job costs more after the move. Decide whether the returned minutes are worth the difference before you change the label. The [observability view](/docs/ci/features/observability) reports max sustained CPU per job and instance type, and a job holding CPU in the low tens while it runs has no parallel work left to buy. ### [Where fewer, larger runners actually win](#where-fewer-larger-runners-actually-win)Copy Anchor Link Consolidation is the case that lowers the invoice. Take eight test shards, each single threaded, each job spending 2.5 minutes on checkout, toolchain setup, and dependency restore, then 5.0 minutes running its shard. | Line item | 8 jobs on warp-ubuntu-latest-x64-2x | 1 job on warp-ubuntu-latest-x64-8x | | -------------------------------------- | ----------------------------------- | ---------------------------------- | | Setup, billed vCPU-minutes | 40.0 | 20.0 | | Shard work, billed vCPU-minutes | 80.0 | 40.0 | | Total billed vCPU-minutes | 120.0 | 60.0 | | Cost per run at $0.002 per vCPU-minute | $0.240 | $0.120 | | Wall clock | 7.5 min | 7.5 min | | Cost at 500 runs per month | $120.00 | $60.00 | Two effects produce that gap. Setup runs eight times in the fan-out and once after consolidation. And a single-threaded shard on a 2 vCPU machine bills two cores while using one, so half of the 80 shard vCPU-minutes are idle capacity. Change one assumption and the answer flips. If each shard uses both cores of the 2 vCPU runner, the eight shards carry 80 vCPU-minutes of real work, which takes 10.0 minutes on 8 vCPU instead of 5.0\. The consolidated job then runs 12.5 minutes at $0.016, or $0.200 per run against $0.240, and wall clock grows from 7.5 minutes to 12.5\. That trade saves $0.040 per run and adds 5 minutes to every pull request, which is the wrong direction for a required check. So run the utilization test before the size test. Consolidate when the small runners show idle cores and repeated setup. Keep the fan-out when the shards saturate the cores they already have. The [right-sizing guide](/guides/runner-right-sizing-github-actions) covers reading those numbers per job. ### [Moving one job up a size without touching the rest of the matrix](#moving-one-job-up-a-size-without-touching-the-rest-of-the-matrix)Copy Anchor Link A matrix does not have to share a runner label. Give each entry its own `runner` value and change one line. Copy Text ``` name: ci on: pull_request: branches: [main] jobs: test: runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: - shard: 1 runner: warp-ubuntu-latest-x64-8x - shard: 2 runner: warp-ubuntu-latest-x64-2x - shard: 3 runner: warp-ubuntu-latest-x64-2x - shard: 4 runner: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/4 ``` Shard 1 holds the slow integration suite and moves to 8 vCPU at $0.016 per minute. The other three stay at $0.004\. Run it for a week, read the duration percentiles per shard, and keep the change only if shard 1 lands under the break-even duration in the first table. ### [What the bill contains](#what-the-bill-contains)Copy Anchor Link Against GitHub-hosted list prices, the consolidated shape stays cheaper at both common sizes. `warp-ubuntu-latest-x64-8x` bills $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner, which is 27 percent lower on list price, and `warp-ubuntu-latest-x64-16x` bills $0.032 against $0.042 for the 16-core Linux larger runner, 24 percent lower on list price ([GitHub Actions per-minute rate list](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does moving a GitHub Actions job up one runner size ever lower the bill?](#does-moving-a-github-actions-job-up-one-runner-size-ever-lower-the-bill)Copy Anchor Link Only when the job finishes in under half the time. Each step up the Linux x64 ladder doubles the per-minute rate, from $0.004 to $0.064 across the five sizes on the [pricing page](/pricing), and a job whose parallel fraction is below 1.0 always lands above that break-even. The upgrade is still often correct, because it buys wall clock on a required check. The [larger runners cost guide](/guides/github-actions-larger-runners-cost) works through the sizes where the trade turns. ### [When are fewer, larger GitHub Actions runners actually cheaper?](#when-are-fewer-larger-github-actions-runners-actually-cheaper)Copy Anchor Link When the fan-out duplicates fixed work or leaves cores idle. On the model above, eight single-threaded shards on `warp-ubuntu-latest-x64-2x` bill 120 vCPU-minutes per run, or $0.240, while the same shards on one `warp-ubuntu-latest-x64-8x` bill 60 vCPU-minutes, or $0.120, at the same 7.5 minutes of wall clock. Check max sustained CPU per job in the [observability view](/docs/ci/features/observability) before assuming the cores are idle. ### [Does a larger runner cost more per vCPU?](#does-a-larger-runner-cost-more-per-vcpu)Copy Anchor Link No. Every Linux x64 size bills $0.002 per vCPU-minute, from `warp-ubuntu-latest-x64-2x` at $0.004 per minute to `warp-ubuntu-latest-x64-32x` at $0.064 per minute, and Linux ARM64 is flat at $0.0015 per vCPU-minute across the same ladder, per the [cloud runners documentation](/docs/ci/cloud-runners) checked on 2026-08-13\. Size changes how the cores are packed rather than what they cost, which is why [choosing a runner size](/answers/how-do-i-choose-a-github-actions-runner-size) starts from measured utilization. --- # Is There a Minimum Billing Increment? | WarpBuild URL: https://www.warpbuild.com/answers/is-there-a-minimum-billing-increment-for-runners Description: WarpBuild bills GitHub Actions runners in one-minute increments, with no minimum block and no per-job fee, and the reports view shows billed minutes per job. # Is There a Minimum Billing Increment? WarpBuild bills GitHub Actions runners in one-minute increments, with no minimum block and no per-job fee, and the reports view shows billed minutes per job. Last verified: 2026-08-13 The billing increment for a WarpBuild GitHub Actions runner is one minute. There is no five-minute block, no ten-minute block, and no per-job runner fee on top of the minutes, so a job's runner line is its billed minutes multiplied by the per-minute rate of the label it ran on. ## [Answer](#answer)Copy Anchor Link Runners bill on a per-minute basis, which the [WarpBuild pricing page](/pricing) states in its billing FAQ and the [cloud runners documentation](/docs/ci/cloud-runners) carries as a per-minute rate against every label in the catalog, both checked on 2026-08-13\. One minute is the entire increment. A job that finishes in 40 seconds and a job that finishes in 60 seconds land in the same one-minute bucket, so a fleet with thousands of tiny jobs needs to watch job count as closely as total minutes. That makes the minimum runner charge for any single job easy to read off the rate list: it is one minute at the label rate. | Platform | Runner label | Shape | Rate per minute | Minimum runner charge for one job | | ----------- | ---------------------------- | --------------- | --------------- | --------------------------------- | | Linux x64 | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | $0.004 | | Linux x64 | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | $0.008 | | Linux x64 | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $0.016 | | Linux x64 | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | $0.032 | | Linux x64 | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064 | $0.064 | | Linux ARM64 | warp-ubuntu-latest-arm64-2x | 2 vCPU, 8 GB | $0.003 | $0.003 | | Linux ARM64 | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | $0.006 | | Linux ARM64 | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | $0.012 | $0.012 | | Linux ARM64 | warp-ubuntu-latest-arm64-16x | 16 vCPU, 64 GB | $0.024 | $0.024 | | Linux ARM64 | warp-ubuntu-latest-arm64-32x | 32 vCPU, 128 GB | $0.048 | $0.048 | | macOS | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08 | $0.08 | | macOS | warp-macos-latest-arm64-12x | 12 vCPU, 28 GB | $0.16 | $0.16 | | Windows | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016 | $0.016 | | Windows | warp-windows-latest-x64-8x | 8 vCPU, 32 GB | $0.032 | $0.032 | | Windows | warp-windows-latest-x64-16x | 16 vCPU, 64 GB | $0.064 | $0.064 | | Windows | warp-windows-latest-x64-32x | 32 vCPU, 128 GB | $0.128 | $0.128 | Rates come from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The floor for one job across the whole catalog is $0.003, one minute on `warp-ubuntu-latest-arm64-2x`, and the highest single-minute charge is $0.16 on `warp-macos-latest-arm64-12x`. Those four ladders are the complete picture. The rate the increment multiplies is the other half of the answer. `warp-ubuntu-latest-x64-2x` (2 vCPU, 8 GB) costs $0.004 per minute against $0.006 per minute for GitHub-hosted `ubuntu-latest` on private repositories (2 vCPU, 8 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers), where GitHub also documents its own per-minute accounting for [GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions). Billed minutes are not an estimate you have to reconstruct. The [reports documentation](/docs/ci/features/reports) describes a Billing section whose CI tab lists execution time and billed time in adjacent columns for every job execution, which is where the increment becomes visible per job. ## [Detail](#detail)Copy Anchor Link ### [Many short jobs against fewer long ones](#many-short-jobs-against-fewer-long-ones)Copy Anchor Link The increment costs money only when jobs carry a fractional-minute tail, and the penalty scales with job count rather than with total work. Take 60 minutes of execution on `warp-ubuntu-latest-x64-4x` at $0.008 per minute and split it four ways. | Shape of the run | Jobs | Execution per job | Execution total | Billed minutes | Cost per run | | ---------------------------- | ---- | ----------------- | --------------- | -------------- | ------------ | | Wide lint and unit matrix | 90 | 0:40 | 60 min | 90 | $0.720 | | Test shards on whole minutes | 20 | 3:00 | 60 min | 60 | $0.480 | | Test shards with a tail | 20 | 3:10 | 63 min 20 s | 80 | $0.640 | | Folded suites | 6 | 10:00 | 60 min | 60 | $0.480 | At 400 pull request runs a month, the 90-job shape costs $288.00 and the 6-job shape costs $192.00 for identical work, a $96.00 monthly difference created entirely by job count. The bound is simple enough to budget against: billed minutes never exceed execution minutes plus one minute per job. A 200-job fan-out therefore adds at most 200 billed minutes, which is $1.60 on `warp-ubuntu-latest-x64-4x` and $0.60 on `warp-ubuntu-latest-arm64-2x`. The same worst case on `warp-macos-latest-arm64-6x` at $0.08 per minute is $16.00 per run, which is why short macOS jobs are the ones worth folding together first. A sharded matrix is the usual source of forty-second jobs. Copy Text ``` name: tests on: pull_request: branches: [main] jobs: unit: runs-on: warp-ubuntu-latest-x64-4x strategy: matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npx jest --shard=${{ matrix.shard }}/10 ``` Each shard pays for checkout and `npm ci` before it runs a single test, so the real cost of splitting is the rounding plus that repeated setup. Sharding still wins when developers are waiting on the result, since 10 shards return an answer in a tenth of the wall-clock time. It loses on a nightly schedule, where nobody is watching and the extra billed minutes buy nothing. The [cost attribution guide](/guides/github-actions-cost-attribution) covers how to find the matrices that grew past their usefulness. ### [Where billed minutes appear](#where-billed-minutes-appear)Copy Anchor Link The [reports documentation](/docs/ci/features/reports), checked on 2026-08-13, describes three surfaces that carry the number. Per job: the Billing section's CI tab gives one row per job execution with repository, job name, runner label, stack, snapshot usage, execution time, billed time, and the cost split between runner and snapshot. Sorting by the gap between execution time and billed time brings the rounded-up jobs to the top. Per label and per repository: the same tab draws a daily stacked bar chart that groups by repository or by runner label, and filters cover repository, runner label, stack, job name, and snapshot usage. Summary cards total the period's cost, runner cost, snapshot cost, and job count. Every tab exports to CSV with all rows matching the current filters, which is the practical path into a spreadsheet. Per job shape over time: the Jobs section aggregates each unique repository, workflow, and job name combination with run count, success rate, and P75 and P90 figures for duration, queue time, CPU, and memory. CPU and memory columns need observability enabled on the runner. Those percentiles are how a team decides whether a shard is short because the work is small or short because it is oversized. ### [The meters that do not use the minute increment](#the-meters-that-do-not-use-the-minute-increment)Copy Anchor Link Runner minutes are one line among several, and the other meters carry their own units. | Meter | Unit | Rate | | ----------------------------- | --------------------- | ------------------------------- | | CI runner minutes | per minute | $0.003 to $0.16 by label | | Remote Docker builder session | per minute of session | $0.06 to $0.88 by profile | | Cache storage | per GB-month | $0.20 | | Cache write or restore | per operation | $0.0001 | | Snapshot restore | per job | $0.04 | | Snapshot storage | per snapshot-hour | $0.025 | | Networking (Tailscale) | included | $0.00 | | BYOC runner minutes | per minute | $0.002 plus your own cloud bill | Rates from the [pricing page](/pricing), checked on 2026-08-13\. A Docker builder session is measured from when the builder action starts until the job completes, and concurrent jobs sharing a profile share one session, so builder minutes and runner minutes are counted separately even inside the same job. ### [What the increment sits on top of](#what-the-increment-sits-on-top-of)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the per-minute increment is the only unit that turns work into a runner bill. Signup includes $10 free credits, which is enough to run a few thousand short Linux jobs while you measure how your own matrices round, and the full rate structure is on the [pricing page](/pricing). To turn all of this into a forecast, multiply expected job minutes per platform by the rate for the labels those jobs use, then add one minute per job as the rounding allowance. The [bill estimation answer](/answers/how-do-i-estimate-my-github-actions-bill) works through that calculation on a monthly fleet, and the full rate list stays on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [Is there a minimum charge for a single GitHub Actions job?](#is-there-a-minimum-charge-for-a-single-github-actions-job)Copy Anchor Link One minute at the rate of the label the job ran on. That is $0.003 on `warp-ubuntu-latest-arm64-2x` and $0.16 on `warp-macos-latest-arm64-12x`. No per-job runner fee is added on top of that minute, and the full ladder is on the [pricing page](/pricing). ### [Does a 20 second job cost the same as a 60 second job?](#does-a-20-second-job-cost-the-same-as-a-60-second-job)Copy Anchor Link Yes. Both land in the same one-minute bucket, so a matrix of 90 forty-second shards bills 90 minutes while the same 60 minutes of work folded into 6 jobs bills 60 minutes. The [reports view](/docs/ci/features/reports) shows execution time and billed time side by side per job, which is the direct way to confirm it. ### [Do Docker builders, cache, and snapshots use the same one-minute increment?](#do-docker-builders-cache-and-snapshots-use-the-same-one-minute-increment)Copy Anchor Link No. Docker builders bill per session measured in minutes from $0.06 per minute, cache storage bills $0.20 per GB-month, cache operations bill $0.0001 each, snapshot restores bill $0.04 per job, and snapshot storage bills $0.025 per snapshot-hour. The [cost attribution guide](/guides/github-actions-cost-attribution) covers splitting those meters across teams. --- # Is WarpBuild SOC 2 Compliant? | WarpBuild URL: https://www.warpbuild.com/answers/is-warpbuild-soc-2-compliant Description: Yes. WarpBuild is SOC 2 Type 2 certified with three Trust Services Criteria, Security, Availability, and Confidentiality, requested at trust.warpbuild.com. # Is WarpBuild SOC 2 Compliant? Yes. WarpBuild is SOC 2 Type 2 certified with three Trust Services Criteria, Security, Availability, and Confidentiality, requested at trust.warpbuild.com. ## [Answer](#answer)Copy Anchor Link Yes. WarpBuild is SOC 2 Type 2 certified with three Trust Services Criteria, Security, Availability, and Confidentiality, as stated in the [runner security documentation](/docs/ci/security), and the report and its scope are requested through [trust.warpbuild.com](https://trust.warpbuild.com/). That trust center is the evidence path a reviewer uses, so the report arrives through a documented request rather than through a sales thread. Here is the attestation stated exactly, in the shape a vendor questionnaire asks for it. | Questionnaire field | WarpBuild answer | Source | | ------------------------ | ------------------------------------------------------------------------------------------- | --------------------------------------------------- | | Report type | SOC 2 Type 2 | [Runner security documentation](/docs/ci/security) | | Criteria in scope | Security, Availability, Confidentiality | [Runner security documentation](/docs/ci/security) | | Controls status | The controls required for SOC 2 compliance are implemented | [Runner security documentation](/docs/ci/security) | | How to obtain the report | Request it in the trust center | [trust.warpbuild.com](https://trust.warpbuild.com/) | | Alternate contact | [support@warpbuild.com](mailto:support@warpbuild.com), for scope questions and requirements | [Runner security documentation](/docs/ci/security) | | Supporting material | Security documentation shared with the report on request | [Runner security documentation](/docs/ci/security) | Type 2 is the part reviewers care about, because a Type 2 report covers how controls operated across a period rather than how they were designed on a single day. The [SOC 2 Type 2 definition](/glossary/soc-2-type-2) covers the report structure and the criteria vocabulary if your questionnaire uses different wording. ## [Detail](#detail)Copy Anchor Link ### [What each criterion maps to in the documentation](#what-each-criterion-maps-to-in-the-documentation)Copy Anchor Link The report is the audited evidence. The documentation states the control facts a reviewer usually wants before the report request clears, and the three criteria in scope line up with three published statements. * **Security.** Each runner runs in its own virtual machine. The virtual machines are created on demand and destroyed after each build, and they are not reused, so no data is left behind for a later job. * **Confidentiality.** Each runner has its own encrypted storage volume, created on demand and destroyed after each build. When caching is enabled, the cache is encrypted and stored in a location that is only accessible to your runner. WarpBuild does not access or store any build secrets; secrets stay in your source code repository and are only accessible to your runner environment. * **Availability.** The report is the evidence for this criterion. This page publishes no uptime figure, so a reviewer who needs availability numbers should ask for them with the report request. All three statements come from the same [runner security documentation](/docs/ci/security) page, which is the single page to send a reviewer first. ### [The facts a reviewer asks for next](#the-facts-a-reviewer-asks-for-next)Copy Anchor Link Attestation questions are usually followed by scope questions. Four answers cover most of them: * **Isolation unit.** One virtual machine per job, with no reuse between builds, per the [runner security documentation](/docs/ci/security). * **Storage lifetime.** One encrypted volume per runner, created on demand and destroyed when the build ends, per the [runner security documentation](/docs/ci/security). * **Catalog under review.** WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, per the [runner catalog](/docs/ci/cloud-runners), and every label in that catalog resolves to the per-job machine described above. * **Regions.** US and EU are the regions WarpBuild names publicly for hosted runners, per [region-specific infrastructure](/products/region-specific-infrastructure). ### [Where your own account carries the evidence](#where-your-own-account-carries-the-evidence)Copy Anchor Link Teams running BYOC keep the runner instance inside their cloud account, which moves part of the evidence from the vendor attestation to their own audit log. The [BYOC security hardening documentation](/docs/ci/byoc/aws/security) states the model: each job runs on a distinct, freshly provisioned EC2 instance that is terminated after the job completes, and the permissions available to runner instances are exactly what you provide through security groups and IAM instance profiles, with no additional permissions injected into runner workloads. The same page publishes the hardening checklist a reviewer can verify in your console: * Deploy the stack in a dedicated VPC or a dedicated AWS account. * Remove all inbound rules from the runner security group. Outbound-only rules still let runners reach WarpBuild and the required AWS services, because runners initiate the connections. * Deny traffic between runner instances with Network ACLs, so a compromised job cannot move sideways. * Require IMDSv2 per runner, which closes the SSRF path to instance credentials. * Set an S3 lifecycle policy on the stack bucket so cache and telemetry objects expire. Under that split, the SOC 2 Type 2 report covers the WarpBuild service, and your CloudTrail, your IAM policies, and your VPC configuration cover the instances. ### [What is not claimed](#what-is-not-claimed)Copy Anchor Link The compliance statement on this site is SOC 2 Type 2 with Security, Availability, and Confidentiality. Processing Integrity and Privacy are the two remaining Trust Services Criteria categories, and neither appears in the scope stated above. No other framework is claimed on this page. If your questionnaire asks about a different standard, ask through [trust.warpbuild.com](https://trust.warpbuild.com/) and treat what comes back as the answer instead of anything inferred from this page. ### [Running the review against a real repository](#running-the-review-against-a-real-repository)Copy Anchor Link A pilot is one label change on one job: Copy Text ``` name: security-pilot on: pull_request: permissions: contents: read jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Install dependencies run: npm ci - name: Test run: npm test ``` The [security review checklist](/enterprise/ci-security-review) lists the five questions a runner vendor review covers and the documentation page behind each answer. ## [Related Questions](#related-questions)Copy Anchor Link ### [Which Trust Services Criteria does the WarpBuild SOC 2 Type 2 report cover?](#which-trust-services-criteria-does-the-warpbuild-soc-2-type-2-report-cover)Copy Anchor Link Three: Security, Availability, and Confidentiality. The [runner security documentation](/docs/ci/security) states the attestation and the criteria, and the report itself is requested through [trust.warpbuild.com](https://trust.warpbuild.com/). The [SOC 2 Type 2 definition](/glossary/soc-2-type-2) covers what the criteria mean if your questionnaire words them differently. ### [How do we get a copy of the SOC 2 Type 2 report?](#how-do-we-get-a-copy-of-the-soc-2-type-2-report)Copy Anchor Link Request it in the WarpBuild trust center at [trust.warpbuild.com](https://trust.warpbuild.com/), or email [support@warpbuild.com](mailto:support@warpbuild.com). The same request covers the supporting security documentation a reviewer needs alongside the report, and the [security review checklist](/enterprise/ci-security-review) shows where that request sits in the review sequence. ### [Does the attestation cover runners running inside our own cloud account?](#does-the-attestation-cover-runners-running-inside-our-own-cloud-account)Copy Anchor Link A BYOC runner instance lives in your account, so your own cloud audit log and IAM configuration are the evidence for it. Each job runs on a freshly provisioned instance that is terminated when the job finishes, and the permissions it carries are the ones you attach through your security groups and instance profiles, per the [BYOC security hardening documentation](/docs/ci/byoc/aws/security). The isolation model for hosted runners is covered in [are GitHub Actions runners secure](/answers/are-github-actions-runners-secure). --- # What Are restore-keys in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/what-are-restore-keys-in-github-actions Description: restore-keys are ordered prefix fallbacks for a cache miss: the action restores the newest entry whose key starts with the first prefix that matches. # What Are restore-keys in GitHub Actions? restore-keys are ordered prefix fallbacks for a cache miss: the action restores the newest entry whose key starts with the first prefix that matches. `restore-keys` is an ordered list of key prefixes the cache action falls back to when the primary `key` finds no entry. The action walks the list top to bottom, and the first prefix that matches at least one stored entry restores the most recently created of those entries, so a job whose lockfile just changed still starts from the previous run's dependency tree instead of an empty directory. ## [Answer](#answer)Copy Anchor Link Every cache entry is stored under one exact string. A job asks for that string in `key`, and when the string has changed since the last run, the lookup misses and the job starts cold. A lockfile hash inside the key guarantees this happens the moment a dependency moves, which is the point of the hash and also the reason `restore-keys` exists. The mechanics are three rules, documented in the [GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching) (checked on 2026-08-13) and mirrored by the `restore-keys` input in the [WarpBuild caching documentation](/docs/ci/features/caching): * Matching is by prefix from the left. `Linux-node22-` matches `Linux-node22-npm-8c41f0`. It does not match `Linux-npm-node22-8c41f0`, and a prefix such as `Linux-npm-` matches neither of them. * The list is searched in order. The first prefix with at least one match ends the search, and later prefixes are never evaluated. * Within one prefix, the most recently created entry wins. The primary `key` is still evaluated first, and an exact match short-circuits the whole list. `restore-keys` is what runs after that miss. ### [Which entry each prefix matches](#which-entry-each-prefix-matches)Copy Anchor Link Take a repository holding these four Linux entries, and a pull request job whose lockfile hash has just changed to `b71e05`: | Stored entry key | Created | | ------------------------ | ---------- | | Linux-node22-npm-8c41f0 | 2026-08-12 | | Linux-node22-yarn-51ad3c | 2026-08-09 | | Linux-node22-npm-2b90d7 | 2026-08-05 | | Linux-node20-npm-77b3ee | 2026-07-28 | The job requests `key: Linux-node22-npm-b71e05` with a three-rung ladder. The primary key misses because no entry carries that hash. | Rung | Prefix | Entries it matches | Outcome | | ---- | ----------------- | ---------------------- | ---------------------------------------------------- | | 1 | Linux-node22-npm- | 8c41f0, 2b90d7 | Restores 8c41f0, the newer of the two. Search stops. | | 2 | Linux-node22- | 8c41f0, 51ad3c, 2b90d7 | Never evaluated. | | 3 | Linux- | all four | Never evaluated. | Now bump the Node major to 24\. The key becomes `Linux-node24-npm-b71e05`, rung 1 becomes `Linux-node24-npm-` and matches nothing, rung 2 becomes `Linux-node24-` and matches nothing, and rung 3 is still `Linux-`. Rung 3 matches all four entries and restores `Linux-node22-npm-8c41f0`, a tree installed under a different Node major. That rung is where a ladder starts working against the job, and the Detail section below covers when to remove it. Cache behavior is the same across WarpBuild's Linux and macOS runners. The WarpBuild cache backend covers everything except the Windows labels, per the limitations section of the [caching documentation](/docs/ci/features/caching). `WarpBuilds/cache@v1` is a drop-in for `actions/cache@v4` and accepts the same `key` and `restore-keys` inputs, so a ladder written for one works unchanged on the other. ## [Detail](#detail)Copy Anchor Link ### [A three-rung ladder in a workflow](#a-three-rung-ladder-in-a-workflow)Copy Anchor Link Copy Text ``` name: build on: push: branches: [main] pull_request: env: NODE_VERSION: "22" jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} - name: Restore npm cache id: npm-cache uses: WarpBuilds/cache/restore@v1 with: path: | ~/.npm node_modules key: Linux-node${{ env.NODE_VERSION }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | Linux-node${{ env.NODE_VERSION }}-npm- Linux-node${{ env.NODE_VERSION }}- Linux- - name: Install dependencies run: npm ci - name: Build run: npm run build - name: Save npm cache if: steps.npm-cache.outputs.cache-hit != 'true' uses: WarpBuilds/cache/save@v1 with: path: | ~/.npm node_modules key: ${{ steps.npm-cache.outputs.cache-primary-key }} ``` Three details in that file are worth reading closely. `npm ci` runs unconditionally. After a prefix restore, `cache-hit` is `false` by design, and the install is what reconciles the restored tree against the lockfile that moved. Guarding the install on `cache-hit` would leave the job running against a dependency set the lockfile no longer describes. The save is guarded on `cache-hit != 'true'` and writes `cache-primary-key`, the exact string the restore step computed. Entries are immutable, so a save under a key that already exists writes nothing. Splitting restore and save keeps the entry publishable even when a later step fails, which the [persistent caches guide](/guides/persistent-caches-github-actions) covers alongside the cases where a cache is the wrong mechanism entirely. The ladder narrows one segment at a time. Each rung drops the rightmost component of the key, which only works because the key is ordered from general on the left to specific on the right. `restore-keys` and the key layout are one design, and [cache key](/glossary/cache-key) covers the segment ordering that makes a truncated key meaningful. If you would rather not maintain any of this, the cache-enabled forks listed in the [setup actions documentation](/docs/ci/features/caching/setup-actions) build the key and the fallback ladder for Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise without a cache step in the workflow. ### [Why a partial restore usually beats a cold start](#why-a-partial-restore-usually-beats-a-cold-start)Copy Anchor Link A prefix restore hands the job a dependency tree that is one lockfile edit out of date. `npm ci` over a warm `~/.npm` reads the unchanged tarballs from disk and downloads only the delta, so the work left is proportional to the change rather than to the size of the dependency set. Here is a month for a repository at 600 runs on `warp-ubuntu-latest-x64-4x` at $0.008 per minute (from the [WarpBuild pricing page](/pricing), checked on 2026-08-13) with a 1.2 GB entry. Cache is billed separately at $0.20 per GB-month for storage and $0.0001 per operation on hosted runners, and is free on BYOC, both from the pricing table in the [caching documentation](/docs/ci/features/caching), checked on 2026-08-13\. Substitute your own dependency-step timings; the three columns are what the ladder decides between. | Line | No cache | Exact key hit | Prefix restore | | ------------------------------------------- | -------- | ------------- | -------------- | | Dependency minutes per run | 4.0 | 0.4 | 1.2 | | Runner minutes per month | 2,400 | 240 | 720 | | Runner cost at $0.008 per minute | $19.20 | $1.92 | $5.76 | | Cache storage, 1.2 GB at $0.20 per GB-month | $0.00 | $0.24 | $0.24 | | Cache operations, 1,200 at $0.0001 | $0.00 | $0.12 | $0.12 | | Monthly total | $19.20 | $2.28 | $6.12 | The middle column is the ceiling a repository would reach if its lockfile never changed, and no real repository sits there. The right column is where an active repository actually lands, and the gap between it and the left column is the entire argument for a ladder. ### [The case where a partial restore is worse](#the-case-where-a-partial-restore-is-worse)Copy Anchor Link Two shapes turn the fallback into a loss. The first is an entry restored from a rung so broad that its contents do not apply. Native modules compiled against one Node ABI, a Rust `target` directory produced by a different `rustc`, and a Python virtualenv built for another minor version all restore cleanly and then either fail at link time or, worse, run against binaries nobody rebuilt. That is the `Linux-` rung in the ladder above after a Node major bump. Remove the widest rung on any project with compiled dependencies, and keep the toolchain version inside every prefix that survives. The second is arithmetic. Restoring 1.2 GB costs wall clock time before the install step even starts, and after a prefix match the install runs anyway. When the restore takes longer than the install it partially replaces, the ladder has added a download for nothing. Large entries are where this bites, and entry size interacts with eviction: the [cache size limit guide](/guides/github-actions-cache-size-limit) covers the ceiling and the least-recently-used eviction that decides which of your prefixes still have anything to match. WarpBuild cache entries expire 7 days after their last use, per the [caching documentation](/docs/ci/features/caching), so a rung pointing at a branch nobody has built in a week matches nothing at all. The fix for both is the same: fewer rungs, each carrying the toolchain identity, and the widest rung deleted rather than left in place as insurance. ## [Related Questions](#related-questions)Copy Anchor Link ### [In what order are restore-keys tried?](#in-what-order-are-restore-keys-tried)Copy Anchor Link Top to bottom, in the order written in the workflow. The first prefix that matches at least one entry ends the search, and the remaining prefixes are never evaluated. When one prefix matches several entries, the most recently created entry is the one restored, per the [GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13\. Order the list from the most specific prefix to the least, because a broad rung placed above a narrow one makes the narrow one unreachable. ### [Does a restore-keys match set cache-hit to true?](#does-a-restore-keys-match-set-cache-hit-to-true)Copy Anchor Link No. The `cache-hit` output is `true` only when the primary key matched exactly. A restore that lands through a prefix sets `cache-hit` to `false`, which the [WarpBuild caching documentation](/docs/ci/features/caching) records for both the combined action and the split restore and save actions. That is why an install step guarded on `cache-hit != 'true'` still runs after a partial restore, and why guarding it differently breaks the reconciliation the partial restore depends on. ### [Do restore-keys change what the job saves?](#do-restore-keys-change-what-the-job-saves)Copy Anchor Link No. The save step always writes under the primary key, so a job that restored through `Linux-node22-npm-` still publishes a new entry under `Linux-node22-npm-b71e05`. That is how the ladder stays useful over time: each run adds one entry at the exact key, and later runs fall back to it by prefix. [How to write a good cache key](/answers/how-do-i-write-a-good-cache-key-in-github-actions) covers the key construction that makes those prefixes line up, and the [persistent caches guide](/guides/persistent-caches-github-actions) covers what to do when the state you need is larger than a cache entry should be. --- # What Are Snapshot Runners? | WarpBuild URL: https://www.warpbuild.com/answers/what-are-snapshot-runners Description: A snapshot runner boots a GitHub Actions job from a saved image of an earlier run's VM instead of a clean base image. What persists, what resets, what it costs. # What Are Snapshot Runners? A snapshot runner boots a GitHub Actions job from a saved image of an earlier run's VM instead of a clean base image. What persists, what resets, what it costs. Last verified: 2026-08-13 A snapshot runner is a GitHub Actions runner that boots from a saved image of a previous run's virtual machine instead of from a clean base image, so the packages, dependency directories, container images, and build outputs an earlier run left on disk are already present when the first step executes. WarpBuild implements snapshot runners on its Cloud Ubuntu runners: you append `snapshot.enabled=true` or `snapshot.key=` to the `runs-on` label, capture the machine with the `WarpBuilds/snapshot-save` action, and later jobs that request the same alias start from that captured disk. ## [Answer](#answer)Copy Anchor Link Every job on an ephemeral runner starts from an identical base image. That is what makes GitHub Actions reproducible, and it is also why the same forty minutes of `apt-get install`, `npm ci`, `docker pull`, and cold compilation get repeated on every pull request in a busy repository. A snapshot runner changes the starting point of that job without changing the isolation model. Three properties define the mechanism: * **The unit is the whole runner disk.** A snapshot is taken at whatever point in the workflow you call the save action, and it captures the machine as it stood at that moment, including files no cache action was ever configured to track. * **A snapshot is addressed by an alias you choose.** The alias is a string such as `web-app-main`. A job asks for it by putting `snapshot.key=web-app-main` in its `runs-on` label. * **The job stays ephemeral.** WarpBuild allocates a fresh virtual machine per job and destroys it when the workflow completes. The saved image is what survives, and it survives for 15 days. That third property is what separates a snapshot runner from a persistent self-hosted runner. A persistent runner keeps one machine registered and hands it job after job, accumulating state that nobody chose and that no branch can reset. A snapshot runner builds a new machine for every job from a disk image you named and can rebuild on demand. The distinction from caching is just as concrete. A cache action restores named paths into a clean machine. Restoring 3 GB of `node_modules` still leaves the apt packages uninstalled, the Docker daemon's local image store empty, the Rust `target` directory partial, and every compiled artifact outside the cached paths missing. A snapshot carries the disk, so whatever a previous run installed anywhere on that disk is present at step one. The [WarpBuild snapshot runners documentation](/docs/ci/features/snapshot-runners) is the reference for the exact behavior, and the [persistent caches guide](/guides/persistent-caches-github-actions) covers the cases where a cache is the better tool. Platform scope is the first thing to check before designing around this. Snapshot runners apply to the Ubuntu part of that catalog, described in the [cloud runners documentation](/docs/ci/cloud-runners). ## [Detail](#detail)Copy Anchor Link ### [What carries over and what resets](#what-carries-over-and-what-resets)Copy Anchor Link A snapshot holds the runner VM's disk at the instant `snapshot-save` ran. Installed system packages, cloned repositories, dependency directories, container images pulled into the local Docker store, compiler caches, and generated build outputs all come back with the machine. Two behaviors need planning around. `/tmp` does not persist. The directory is cleaned on reboots, and booting from a snapshot is a boot, so anything a job wrote to `/tmp` before the save is gone on restore. Build tooling that stages artifacts in `/tmp` needs a different path if you want those artifacts in the snapshot. Secrets on disk do persist. Credentials written by a login step, an SSH key, an AWS profile, or a token file left in a working directory are part of the disk and therefore part of the image. This is the reason the documentation puts a cleanup step immediately before every save, and the reason the security section below matters more than the cost section. One signal comes back with the machine: a runner created from a snapshot has the environment variable `WARPBUILD_SNAPSHOT_KEY` set to the alias it booted from. That variable is how a workflow knows whether it is on a warm machine and can skip the setup steps that a warm machine no longer needs. ### [The two snapshot labels](#the-two-snapshot-labels)Copy Anchor Link Snapshot behavior is requested through dynamic labels appended to the runner label with a semicolon. There are two, and they do different jobs: * `snapshot.enabled=true` turns the feature on and always boots from the base image. Use it on the workflow that produces the snapshot, so that the image you capture is built from a known clean starting point. * `snapshot.key=` turns the feature on and boots from the existing snapshot for that alias when one exists. When no snapshot exists yet for the alias, the runner boots from the base image and the job proceeds normally. Both forms are written inline in `runs-on`, for example `warp-ubuntu-latest-x64-4x;snapshot.key=web-app-main`. If either label is applied to a runner type that does not support snapshots, the label is silently ignored and the job runs without snapshot behavior. There is no error and no warning in the job log, so a typo in the runner label is indistinguishable from a working configuration until you check whether `WARPBUILD_SNAPSHOT_KEY` was set. ### [Capturing a snapshot](#capturing-a-snapshot)Copy Anchor Link The capture step is the `WarpBuilds/snapshot-save` action, published at [github.com/WarpBuilds/snapshot-save](https://github.com/WarpBuilds/snapshot-save). Place it at the point in the workflow where the disk holds the state you want the next run to start from, which is usually after dependencies are installed and the build has warmed the compiler caches, and always after credentials have been removed. | Input | Required | Default | Purpose | | -------------------- | -------- | ------- | -------------------------------------------------------- | | alias | Yes | none | Unique name for the snapshot, used later in snapshot.key | | fail-on-error | No | true | Fail the job when snapshot creation errors | | wait-timeout-minutes | No | 30 | Maximum minutes to wait for the snapshot to be created | Setting `fail-on-error: false` is the right choice when the snapshot is an optimization and a failed capture should leave the pipeline green. Raising `wait-timeout-minutes` above the default matters for large disks, where the capture takes longer than half an hour. ### [Runners that accept snapshot labels](#runners-that-accept-snapshot-labels)Copy Anchor Link Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. The table below lists the Ubuntu 24.04 labels and their published rates, taken from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13. | Runner label | OS | vCPU | RAM | Storage | Price per minute | | ---------------------------- | ------------ | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.064 | | warp-ubuntu-latest-arm64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.003 | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.006 | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.012 | | warp-ubuntu-latest-arm64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.024 | | warp-ubuntu-latest-arm64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.048 | The Ubuntu 26.04 labels carry the same sizes and rates for both x64 and ARM64, under `warp-ubuntu-2604-*`. Ubuntu 22.04 carries the same x64 sizes and rates under `warp-ubuntu-2204-*`; WarpBuild deprecated the Ubuntu 22.04 ARM64 images on March 31, 2025, so the 22.04 label is x64 only. Each `latest` label also has a pinned alias, so `warp-ubuntu-latest-x64-4x` and `warp-ubuntu-2404-x64-4x` route to the same machine shape. A snapshot boots on the runner shape you request in `runs-on`, so keep the size stable across the workflow that saves the snapshot and the workflows that restore it. ### [A workflow that saves on main and restores on pull requests](#a-workflow-that-saves-on-main-and-restores-on-pull-requests)Copy Anchor Link This is the shape most teams want. The default branch always boots clean and republishes the snapshot, so the image never drifts. Pull request runs boot from the published image and skip the setup work it already contains. Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-4x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-4x;snapshot.key=web-app-main' }} steps: - name: Checkout code uses: actions/checkout@v5 - name: Install system packages run: | if [ -z "$WARPBUILD_SNAPSHOT_KEY" ]; then sudo apt-get update sudo apt-get install -y libvips-dev protobuf-compiler else echo "booted from snapshot $WARPBUILD_SNAPSHOT_KEY" fi - name: Install dependencies run: npm ci - name: Build run: npm run build - name: Test run: npm test - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "web-app-main" fail-on-error: true wait-timeout-minutes: 60 ``` The `npm ci` step is left unguarded on purpose. It reconciles the lockfile against the `node_modules` tree that came out of the snapshot, which is fast when the tree is already correct and correct when the lockfile moved on since the image was captured. Guard the steps whose work is expensive and idempotent, such as system package installation and toolchain downloads, and leave the steps that enforce correctness running every time. The second shape is a single alias that updates itself on every run, with no separate producing branch: Copy Text ``` jobs: integration: runs-on: warp-ubuntu-latest-x64-8x;snapshot.key=integration-fixtures steps: - uses: actions/checkout@v5 - name: Seed fixtures run: ./scripts/seed-fixtures.sh - name: Integration tests run: ./scripts/integration.sh - name: Cleanup credentials run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - uses: WarpBuilds/snapshot-save@v1 with: alias: "integration-fixtures" fail-on-error: false ``` Each run boots from the previous run's disk and writes a new one, so state compounds run over run. That is useful for fixture data and large downloaded corpora. It also means a bad run can poison the alias, and the fix is to change the alias string, which sends the next job back to the base image. ### [Cleanup and security](#cleanup-and-security)Copy Anchor Link Two exposure paths deserve explicit handling before a snapshot alias goes into a workflow file. On public repositories, a pull request workflow can name any alias in `runs-on`. A contributor who reads your workflow file knows the alias, so any credential left in a snapshot is reachable by anyone who can open a pull request. Cloud credentials, registry tokens, and signing keys belong outside snapshots on public repositories. On private repositories, WarpBuild provisions runners at the organization level, and GitHub may allocate a runner intended for a snapshot job to a different job in the same organization. A snapshot that carries secrets can therefore surface them to other users in the organization. The mitigation is the same in both cases: remove credentials before the save step runs. Copy Text ``` rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx ``` `git clean -ffdx` removes untracked files from the repository working tree, including directories and files ignored by `.gitignore`. It is the blunt version of the cleanup and it will also delete build output you may have wanted in the snapshot, so on repositories where the build directory is gitignored, replace it with targeted deletions of the paths that hold secrets. ### [The 15-day lifetime](#the-15-day-lifetime)Copy Anchor Link Snapshots are temporary and are deleted after 15 days. An alias that no workflow refreshes stops resolving after that window, and the next job requesting it boots from the base image and runs normally. Nothing fails, and the only visible symptom is a job that takes its cold-boot duration again. A workflow that saves on every merge to the default branch refreshes the alias continuously and never reaches the expiry, which is another argument for the save-on-main pattern above. Boot behavior is the other timing fact worth knowing. Booting a snapshot runner can be slower than booting a default runner and takes 45 to 60 seconds, per the [snapshot runners documentation](/docs/ci/features/snapshot-runners). Snapshots pay for themselves when the state they carry costs more than a minute to rebuild, and the arithmetic below turns that into a threshold you can check against your own job durations. ### [What snapshot runners cost](#what-snapshot-runners-cost)Copy Anchor Link Two line items sit on top of the runner's per-minute rate, both from the [pricing page](/pricing), checked on 2026-08-13: * Snapshot restore: $0.04 per job. * Snapshot storage: $0.025 per hour per snapshot. The break-even is straightforward. Let `r` be the runner's per-minute rate and `S` the setup minutes the snapshot lets a job skip. Restoring costs one extra boot minute plus the $0.04 restore fee, so a snapshot pays off when `S > 1 + 0.04 / r`. | Runner label | Price per minute | Break-even setup time skipped | | -------------------------- | ---------------- | ----------------------------- | | warp-ubuntu-latest-x64-2x | $0.004 | 11 minutes | | warp-ubuntu-latest-x64-4x | $0.008 | 6 minutes | | warp-ubuntu-latest-x64-8x | $0.016 | 3.5 minutes | | warp-ubuntu-latest-x64-16x | $0.032 | 2.25 minutes | | warp-ubuntu-latest-x64-32x | $0.064 | 1.6 minutes | The threshold falls as the runner gets larger, because the fixed $0.04 restore fee buys fewer minutes at a higher rate. On a 2 vCPU runner, a snapshot needs to remove more than 11 minutes of setup work to be worth its fee. On a 16 vCPU runner, 2.25 minutes is enough. Here is a full month for a repository with a real workload. The assumptions are stated so you can substitute your own: 40 workflow runs per weekday across 22 weekdays, which is 880 runs per month; a job that takes 12 minutes on a clean boot, of which 7 minutes is environment setup; and one live snapshot alias refreshed on every merge, held for the whole month at 720 hours. With the snapshot, setup drops to zero and boot adds a minute, so the job takes 6 minutes. The GitHub column prices the same 12-minute job on the 16-core Linux larger runner at $0.042 per minute, from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. GitHub-hosted runners have no snapshot equivalent, so that column carries no snapshot line items. | Line | GitHub 16-core Linux larger runner | warp-ubuntu-latest-x64-16x, no snapshot | warp-ubuntu-latest-x64-16x, snapshot | | ------------------------ | ---------------------------------- | --------------------------------------- | ------------------------------------ | | Rate per minute | $0.042 | $0.032 | $0.032 | | Minutes per job | 12 | 12 | 6 | | Runner minutes per month | 10,560 | 10,560 | 5,280 | | Runner cost per month | $443.52 | $337.92 | $168.96 | | Snapshot restore fees | none | $0 | $35.20 | | Snapshot storage | none | $0 | $18.00 | | Total per month | $443.52 | $337.92 | $222.16 | The gap between the first and third columns is $221.36 per month for this workload. Per-size arithmetic for every label is on the [pricing page](/pricing). Two of the assumptions carry most of the weight. Setup minutes is the sensitive one: at 3 minutes of setup instead of 7, the snapshot column loses its advantage over the plain runner column, which is exactly what the break-even table predicts for a 16 vCPU runner. Storage is the other: $18.00 assumes a single alias alive continuously. Ten aliases held the same way is $180.00 per month, so per-branch snapshot aliases are worth counting before they are worth adopting. Two structural notes belong with any of these numbers. The [snapshot runners hub](/runners/snapshot) covers the rollout patterns, and the [cache size limit guide](/guides/github-actions-cache-size-limit) covers the cases where a cache is the cheaper answer. ## [Related Questions](#related-questions)Copy Anchor Link ### [Do snapshot runners work on Windows, macOS, or BYOC runners?](#do-snapshot-runners-work-on-windows-macos-or-byoc-runners)Copy Anchor Link No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. BYOC runners, Windows runners, and macOS runners are not supported, and snapshot labels on those runner types are silently ignored, so the job runs normally with no snapshot behavior and no error. The [snapshot runners hub](/runners/snapshot) lists the Ubuntu labels that accept the feature. ### [How long does a snapshot last?](#how-long-does-a-snapshot-last)Copy Anchor Link Snapshots are temporary and are deleted after 15 days. An alias that stops being refreshed therefore stops resolving after 15 days, and the next job that requests it boots from the base image instead of failing. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) is the reference for the lifetime and the save action inputs. ### [Are snapshot runners the same thing as a cache?](#are-snapshot-runners-the-same-thing-as-a-cache)Copy Anchor Link No. A cache action restores named paths into a clean machine, so apt packages, Docker images in the local daemon store, and files outside the cached paths are still missing. A snapshot restores the whole runner disk as it stood when the save action ran. The [persistent caches guide](/guides/persistent-caches-github-actions) walks through which of the two fits a given workflow. ### [What do snapshot runners cost?](#what-do-snapshot-runners-cost)Copy Anchor Link Snapshot restore is billed at $0.04 per job and snapshot storage at $0.025 per hour per snapshot, on top of the per-minute rate of the Ubuntu runner you selected. The [pricing page](/pricing) carries the per-minute rate for every runner label, and [GitHub Actions runner](/glossary/github-actions-runner) defines the underlying terms. --- # Which ARM64 Runner Sizes Are Available? | WarpBuild URL: https://www.warpbuild.com/answers/what-arm64-runner-sizes-are-available-on-github-actions Description: Linux ARM64 GitHub Actions runners come in five sizes, 2x through 32x, from 2 vCPU and 8GB at $0.003 per minute to 32 vCPU and 128GB at $0.048 per minute. # Which ARM64 Runner Sizes Are Available? Linux ARM64 GitHub Actions runners come in five sizes, 2x through 32x, from 2 vCPU and 8GB at $0.003 per minute to 32 vCPU and 128GB at $0.048 per minute. Last verified: 2026-08-13 Linux ARM64 runners come in the same five sizes as Linux x64, 2x through 32x, running from 2 vCPU and 8GB of memory up to 32 vCPU and 128GB, with 150GB of SSD storage at every size ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). Each ARM64 size costs less per minute than the x64 size with the same vCPU and memory: $0.003 against $0.004 at 2x, and $0.048 against $0.064 at 32x. ## [Answer](#answer)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Linux ARM64 line is a five-rung ladder where the number in the label is the vCPU count, memory is 4GB per vCPU, and storage is flat. | Size | vCPU | Memory | Storage | Linux ARM64 label | ARM64 per minute | Linux x64 label | x64 per minute | | ---- | ---- | ------ | --------- | ---------------------------- | ---------------- | -------------------------- | -------------- | | 2x | 2 | 8GB | 150GB SSD | warp-ubuntu-latest-arm64-2x | $0.003 | warp-ubuntu-latest-x64-2x | $0.004 | | 4x | 4 | 16GB | 150GB SSD | warp-ubuntu-latest-arm64-4x | $0.006 | warp-ubuntu-latest-x64-4x | $0.008 | | 8x | 8 | 32GB | 150GB SSD | warp-ubuntu-latest-arm64-8x | $0.012 | warp-ubuntu-latest-x64-8x | $0.016 | | 16x | 16 | 64GB | 150GB SSD | warp-ubuntu-latest-arm64-16x | $0.024 | warp-ubuntu-latest-x64-16x | $0.032 | | 32x | 32 | 128GB | 150GB SSD | warp-ubuntu-latest-arm64-32x | $0.048 | warp-ubuntu-latest-x64-32x | $0.064 | Every row comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13, and the [Linux ARM64 runner catalog](/runners/linux-arm64) carries the same rows with the alias column. Each size exists on two Ubuntu releases. The `warp-ubuntu-latest-arm64-*` labels run Ubuntu 24.04 and each carries a pinned alias, `warp-ubuntu-2404-arm64-2x` through `warp-ubuntu-2404-arm64-32x`. The `warp-ubuntu-2604-arm64-*` labels run Ubuntu 26.04 at the same five sizes and the same five rates. That makes ten Linux ARM64 labels, five shapes across two releases. ARM64 coverage outside Linux is macOS, where the runners are ARM64 already and the catalog is two sizes, 6 vCPU with 14GB at $0.08 per minute and 12 vCPU with 28GB at $0.16 per minute. The Windows catalog is x86-64 only, so a Windows job stays on a `warp-windows` label. ## [Detail](#detail)Copy Anchor Link ### [What the ARM64 sizes cost against GitHub-hosted ARM64 runners](#what-the-arm64-sizes-cost-against-github-hosted-arm64-runners)Copy Anchor Link Both tables below quote GitHub list prices from the [GitHub pricing page](https://github.com/pricing) and the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. WarpBuild rates come from the [cloud runners documentation](/docs/ci/cloud-runners). | WarpBuild ARM64 label | Shape | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | Difference | | ---------------------------- | -------------- | -------------------- | ------------------------------------------------------ | ----------------- | --------------------------- | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | the 4-core Linux ARM64 larger runner (4 vCPU, 16 GB) | $0.008 | 25 percent lower list price | | warp-ubuntu-latest-arm64-16x | 16 vCPU, 64 GB | $0.024 | the 16-core Linux ARM64 larger runner (16 vCPU, 64 GB) | $0.026 | 8 percent lower list price | The arithmetic behind each row is plain division. At 4x, ($0.008 - $0.006) / $0.008 = 25 percent lower list price. At 16x, ($0.026 - $0.024) / $0.026 = 8 percent lower list price. GitHub list prices checked on 2026-08-13. Put that against real volume. A repository that queues 10,000 minutes a month on the 16x size pays 10,000 x $0.024 = $240 on `warp-ubuntu-latest-arm64-16x`, against 10,000 x $0.026 = $260 on the 16-core Linux ARM64 larger runner. Whether ARM64 or x64 is the cheaper choice for a given job is covered in [is ARM64 cheaper than x64](/answers/is-arm64-cheaper-than-x64-for-github-actions). ### [Picking a size](#picking-a-size)Copy Anchor Link The size in the label is the only thing that changes between rungs, so sizing is one line of YAML: Copy Text ``` name: build on: pull_request: jobs: build: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - name: Show the shape run: | uname -m nproc free -g - name: Build run: cargo build --release --jobs "$(nproc)" ``` `uname -m` prints `aarch64`, `nproc` prints 8, and the build parallelism follows the size rather than a hardcoded number. Move the job up a rung and the same file uses the extra cores. Two rules of thumb keep the choice honest. A step that runs one process at a time, such as a single-threaded linter or a deploy step, gains nothing above 4x. A step that saturates cores, such as a Rust or C++ compile or a sharded test suite, keeps scaling until the job becomes disk or network bound, and 150GB of SSD is the same at every size. 32x is the top rung, so work that needs more parallelism than 32 vCPU splits across a matrix of ARM64 runners, or sends its container image build to a remote Docker builder. ### [Ubuntu releases and the sizes they cover](#ubuntu-releases-and-the-sizes-they-cover)Copy Anchor Link The five sizes are identical across Ubuntu 24.04 and Ubuntu 26.04\. The Ubuntu 24.04 ARM64 runners are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the package list lives at [actions/partner-runner-images](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md). Ubuntu 26.04 ARM64 tracks [actions/runner-images](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2604-Arm64-Readme.md). The [preinstalled software documentation](/docs/ci/preinstalled-software) links both lists, which is worth checking before you pin a language version, since ARM64 and x86-64 images do not always ship the same set. The arm64 images for ubuntu-22.04 were deprecated on March 31, 2025 ([preinstalled software documentation](/docs/ci/preinstalled-software)), so there is no 22.04 ARM64 rung at any size. Ubuntu 22.04 remains available on x64 at all five sizes. ### [One image difference to plan for](#one-image-difference-to-plan-for)Copy Anchor Link The arm64 images for Ubuntu 24.04 set the work directory to `/runner/_work`, which differs from GitHub's `/home/runner/work/` for the same instance ([cloud runners documentation](/docs/ci/cloud-runners)). The path is the same at every ARM64 size, so this is a per-image detail rather than a per-size one. Any step holding a literal `/home/runner/work/...` path needs `${{ github.workspace }}` in YAML or `$GITHUB_WORKSPACE` in a shell step. Docker `-v` mounts, coverage upload paths, and cache path globs are the usual places a literal hides. The [ARM64 migration checklist](/guides/arm64-migration-checklist-github-actions) walks the rest of the switch. One hard limit applies across the whole ARM64 ladder rather than to any single size: nested virtualization and `/dev/kvm` access are not available on ARM64 runners ([cloud runners documentation](/docs/ci/cloud-runners)), so Android emulator jobs and similar workloads run on an x86-64 size with the `nested-virtualization.enabled=true` label. ## [Related Questions](#related-questions)Copy Anchor Link **What ARM64 runner sizes are available on GitHub Actions?** Five Linux ARM64 sizes, 2x, 4x, 8x, 16x, and 32x, running from 2 vCPU and 8GB of memory to 32 vCPU and 128GB. Every size carries 150GB of SSD storage and every size exists on both Ubuntu 24.04 and Ubuntu 26.04, which makes ten Linux ARM64 labels in total. The [Linux ARM64 runner catalog](/runners/linux-arm64) lists all ten with aliases. **Do ARM64 runners come in the same sizes as x64 runners?** Yes. The Linux ARM64 ladder matches the Linux x64 ladder vCPU for vCPU and GB for GB, from 2x to 32x. The rate differs. ARM64 runs $0.003 per minute at 2x against $0.004 for x64, and $0.048 at 32x against $0.064 for x64 ([cloud runners documentation](/docs/ci/cloud-runners)). The cost side of that choice is in [is ARM64 cheaper than x64](/answers/is-arm64-cheaper-than-x64-for-github-actions). **How much memory does each ARM64 runner size have?** 4GB per vCPU at every size: 8GB at 2x, 16GB at 4x, 32GB at 8x, 64GB at 16x, and 128GB at 32x. Storage stays at 150GB of SSD across the ladder, so only vCPU and memory change with the size. Pick a size from the [Linux ARM64 runner catalog](/runners/linux-arm64), check the per-minute rate for it on the [pricing page](/pricing), and confirm the image tooling in the [preinstalled software documentation](/docs/ci/preinstalled-software). --- # What Carries Over in a Runner Snapshot? | WarpBuild URL: https://www.warpbuild.com/answers/what-carries-over-in-a-runner-snapshot Description: A runner snapshot carries the VM filesystem as it stood at capture, including installed packages, warmed caches, and pulled images. Here is what resets. # What Carries Over in a Runner Snapshot? A runner snapshot carries the VM filesystem as it stood at capture, including installed packages, warmed caches, and pulled images. Here is what resets. A runner snapshot carries the machine state captured at the moment the save action ran, which means the filesystem as it stood: installed system packages, dependency directories, warmed compiler and package manager caches, container images already pulled into the local Docker store, and any prepared container or fixture data written to disk. Anything that lived outside that filesystem resets, including `/tmp`, running processes, and the credentials GitHub issued to the job that captured the image. ## [Answer](#answer)Copy Anchor Link The [snapshot runners documentation](/docs/ci/features/snapshot-runners) describes the unit precisely: WarpBuild takes a snapshot of the runner VM at whatever point in the workflow you call `WarpBuilds/snapshot-save`, and a later job that requests that alias boots from the saved image. The boundary that matters when you plan around it is the difference between disk and machine. | Item | After a snapshot boot | Source | | ------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | System packages installed with apt | Present | [Snapshot runners docs](/docs/ci/features/snapshot-runners) | | Dependency trees such as node\_modules, vendor, \~/.m2 | Present | [Snapshot runners docs](/docs/ci/features/snapshot-runners) | | Container images pulled into the local Docker store | Present | [Snapshot runners docs](/docs/ci/features/snapshot-runners) | | Compiler and package manager caches on disk | Present | [Snapshot runners docs](/docs/ci/features/snapshot-runners) | | Repository working tree as it stood at capture | Present, minus whatever the cleanup step removed | [Snapshot runners docs](/docs/ci/features/snapshot-runners) | | Credentials written to disk, such as $HOME/.aws or $HOME/.ssh | Present unless deleted before the save | [WarpBuild security docs](/docs/ci/security) | | Files under /tmp | Reset. The directory is cleaned on reboots and a snapshot boot is a boot | [Snapshot runners docs](/docs/ci/features/snapshot-runners) | | Processes and services the capturing job started | Not running. Their installed files are on disk | [Snapshot runners docs](/docs/ci/features/snapshot-runners) | | Variables exported through GITHUB\_ENV or GITHUB\_PATH | Reset. They apply to later steps of the job that set them | [GitHub workflow commands](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions) | | Secrets injected as environment variables | Not in the image unless a step wrote them to a file | [GitHub secrets docs](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets) | | The GITHUB\_TOKEN of the capturing job | Gone. GitHub expires the token when that job finishes | [GitHub automatic token authentication](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication) | | The runner registration itself | Fresh. Each runner is its own VM, created on demand and destroyed after the build | [WarpBuild security docs](/docs/ci/security) | | WARPBUILD\_SNAPSHOT\_KEY | Set to the alias the machine booted from | [Snapshot runners docs](/docs/ci/features/snapshot-runners) | The last row is the one to build workflow logic on. A restored machine advertises itself, so a step can branch on whether the expensive setup work is already done rather than guessing. Two limits bound how much can carry over. The first is disk: the Ubuntu shapes that accept snapshot labels run on 150GB SSD storage from 2 through 32 vCPU, per the [runner catalog](/docs/ci/cloud-runners), so the snapshot holds whatever fits in that volume. The second is the runner shape in `runs-on`. A snapshot boots onto the machine size you request, so keep the size stable between the workflow that saves and the workflows that restore. Snapshot support covers the Ubuntu part of that catalog on both architectures. ## [Detail](#detail)Copy Anchor Link ### [Anything tied to the capturing job resets](#anything-tied-to-the-capturing-job-resets)Copy Anchor Link The pattern behind half of the reset column is that GitHub scopes credentials and context to a single job. The `GITHUB_TOKEN` expires when the job that received it finishes, so a token file left on the disk is dead weight rather than a working credential. Repository secrets arrive as environment variables in the job that references them, so they are absent from a restored machine unless a step wrote them somewhere. Long-lived credentials behave the opposite way. A cloud login step that writes `$HOME/.aws/credentials`, an SSH key added to `$HOME/.ssh`, or a container registry token in `~/.docker/config.json` is a file on the disk, and files on the disk are exactly what the snapshot preserves. That is why the documentation puts a cleanup step immediately before every save: Copy Text ``` rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx ``` `git clean -ffdx` removes untracked files from the working tree, including directories and files ignored by `.gitignore`. It also deletes build output you may have wanted in the image, so on repositories where the build directory is gitignored, replace it with targeted deletions of the paths that hold credentials. ### [The security boundary on public and private repositories](#the-security-boundary-on-public-and-private-repositories)Copy Anchor Link The [snapshot runners documentation](/docs/ci/features/snapshot-runners) documents two distinct exposure paths, and they need different reasoning. On public repositories, a snapshot is addressed by an alias that appears in the workflow file. Anyone who can read the repository can read the alias, and anyone who can open a pull request can run a workflow that names it. Cloud credentials, signing keys, and registry tokens have no place in a snapshot on a public repository. On private repositories, WarpBuild provisions runners at the organization level, and GitHub may allocate a runner intended for a snapshot job to a different job in the same organization. A snapshot carrying secrets can therefore surface them to other users inside the organization. Neither path changes the isolation model of the runner itself. Each runner runs in its own virtual machine, created on demand and destroyed after each build, on an encrypted storage volume, per the [security documentation](/docs/ci/security). The snapshot is the one artifact designed to outlive that machine, so it is the one artifact that needs a cleanup policy. ### [Reading the boot in the workflow](#reading-the-boot-in-the-workflow)Copy Anchor Link This is the shape that uses the carried state without assuming it. The default branch boots clean and republishes the image; other runs boot from it and skip only the steps whose work the image already contains. Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-4x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-4x;snapshot.key=web-app-main' }} steps: - uses: actions/checkout@v5 - name: Install system packages run: | if [ -z "$WARPBUILD_SNAPSHOT_KEY" ]; then sudo apt-get update sudo apt-get install -y libvips-dev protobuf-compiler fi - name: Start services run: docker compose up -d postgres redis - name: Install dependencies run: npm ci - name: Test run: npm test - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "web-app-main" fail-on-error: true wait-timeout-minutes: 60 ``` The `docker compose up` step runs unconditionally on purpose. The Postgres and Redis images are on the restored disk, so the pull is local, but the containers themselves are not running after a boot and the job has to start them. `npm ci` is unguarded for a different reason: it reconciles the lockfile against the restored `node_modules` tree, which is cheap when the tree is correct and correct when the lockfile moved on since capture. ### [When a clean machine is the requirement](#when-a-clean-machine-is-the-requirement)Copy Anchor Link Some jobs exist to prove that a clean machine can build the code, and a snapshot removes exactly the evidence they are meant to produce. * Release and publish jobs. An artifact assembled on a disk carrying leftovers from earlier runs is harder to reason about than one built from the base image. * Lockfile and dependency resolution checks. A restored disk already holds the packages, so a job that should fail on an unresolvable or removed dependency can pass on cached bytes. * First reproduction of a reported bug. Ruling out machine state is the first step, and a snapshot reintroduces it. * Jobs handling long-lived credentials on public repositories, where the alias is readable by every contributor. * Short jobs. A snapshot boot takes 45 to 60 seconds per the [snapshot runners documentation](/docs/ci/features/snapshot-runners), and a restore bills $0.04 per job from the [pricing page](/pricing), checked on 2026-08-13, so a job with under a minute of setup work gives back more than it gains. Both `snapshot.enabled=true` and no snapshot label at all boot from the base image, so the producing workflow and the clean-machine workflows share the same starting point. Snapshot storage bills $0.025 per snapshot-hour while an alias is alive, also from the [pricing page](/pricing) checked on 2026-08-13, and snapshots are deleted after 15 days, which caps a forgotten alias on its own. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does a runner snapshot include running processes or memory?](#does-a-runner-snapshot-include-running-processes-or-memory)Copy Anchor Link No. A job that boots from a snapshot boots a machine, which is why the `/tmp` directory comes back empty: that directory is cleaned on reboots. Database containers, language servers, and any daemon the capturing job started are on disk as installed software and have to be started again by the restored job. [VM snapshot, defined](/glossary/vm-snapshot) covers the disk-versus-memory distinction across implementations. ### [Do secrets end up inside a runner snapshot?](#do-secrets-end-up-inside-a-runner-snapshot)Copy Anchor Link Only the ones written to disk. A secret that reaches the job as an environment variable is not part of the filesystem, but a login step that writes `$HOME/.aws`, `$HOME/.ssh`, or a registry config puts those files in the image. Remove them in a cleanup step before the save action runs, as the [snapshot runners documentation](/docs/ci/features/snapshot-runners) shows. ### [When should a job avoid a snapshot entirely?](#when-should-a-job-avoid-a-snapshot-entirely)Copy Anchor Link When the job exists to prove that a clean machine can build the code: release and publish jobs, lockfile and dependency resolution checks, and first-run reproduction of a bug. Those jobs should carry `snapshot.enabled=true` or no snapshot label at all, since both boot from the base image. The [snapshot runners page](/runners/snapshot) lists the labels and the Ubuntu sizes that accept them. ### [How is this different from state carrying across jobs by itself?](#how-is-this-different-from-state-carrying-across-jobs-by-itself)Copy Anchor Link It is the only way state carries at all. Every job gets a fresh ephemeral VM whose storage is destroyed when the runner terminates, so nothing persists unless a snapshot, a cache, or an artifact carries it. [Do GitHub Actions runners keep state between jobs?](/answers/do-github-actions-runners-keep-state-between-jobs) walks through the three mechanisms and what each one restores. --- # What Changes When a Workflow Moves to ARM64? | WarpBuild URL: https://www.warpbuild.com/answers/what-changes-when-i-move-a-workflow-from-x64-to-arm64 Description: The runs-on label changes. After that, check three things: binaries downloaded with an architecture suffix, native modules, and hardcoded absolute paths. # What Changes When a Workflow Moves to ARM64? The runs-on label changes. After that, check three things: binaries downloaded with an architecture suffix, native modules, and hardcoded absolute paths. The `runs-on` label changes, and in a plain workflow that is the whole edit: swap `warp-ubuntu-latest-x64-4x` for `warp-ubuntu-latest-arm64-4x` and the same steps run on an aarch64 machine. After the label, three classes of step need checking before the first green run: steps that download an architecture-specific binary, steps that build or install native modules, and steps that carry an absolute path copied from the GitHub-hosted layout ([WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link Four categories account for the failures teams hit on the first ARM64 job. Each one has a recognizable error string, which makes triage fast. | Category | What it looks like on the first ARM64 run | The change | | ----------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | | Downloaded binaries with an architecture suffix | cannot execute binary file: Exec format error | Drive the architecture token in the URL from the same variable that drives the label | | Native modules and compiled dependencies | Install or import failure naming a platform package such as @esbuild/linux-x64 | Regenerate the lockfile with the aarch64 variant present, or install with optional dependencies enabled | | Container base images | no matching manifest for linux/arm64/v8 in the manifest list entries | Pick a base image published with a linux/arm64 manifest, or build one | | Absolute paths | A file that the log says exists is not found at the path a later step reads | Read $GITHUB\_WORKSPACE and $RUNNER\_TEMP instead of writing the path out | Everything else in a normal workflow behaves the same. `actions/checkout`, the language setup actions, `actions/cache`, `actions/upload-artifact`, service containers, and job containers all run on the ARM64 images. The runner agent itself ships a `linux-arm64` release ([actions/runner](https://github.com/actions/runner)), so there is no compatibility layer between your steps and the machine. The one WarpBuild-specific difference worth knowing before the first run is the work directory: the arm64 images for Ubuntu 24.04 set it to `/runner/_work`, which differs from GitHub's `/home/runner/work/` for the same instance ([cloud runners documentation](/docs/ci/cloud-runners)). That difference is covered in full in [do ARM64 runners use a different work directory](/answers/do-arm64-runners-use-a-different-work-directory). WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Linux ARM64 line runs from `warp-ubuntu-latest-arm64-2x` at 2 vCPU and 8 GB for $0.003 per minute to `warp-ubuntu-latest-arm64-32x` at 32 vCPU and 128 GB for $0.048 per minute, across Ubuntu 24.04 and Ubuntu 26.04 ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). Every label sits in the [Linux ARM64 runner catalog](/runners/linux-arm64). ### [One variable driving the label and the download URLs](#one-variable-driving-the-label-and-the-download-urls)Copy Anchor Link The pattern that keeps a migrated workflow honest is a single matrix entry that carries the runner label and every architecture token the steps need. The tokens differ by ecosystem, so putting them next to each other in one place stops the mismatch that produces `Exec format error`. Copy Text ``` name: release on: push: tags: ["v*"] jobs: build: strategy: fail-fast: false matrix: include: - arch: x64 runner: warp-ubuntu-latest-x64-4x kubectl_arch: amd64 rust_target: x86_64-unknown-linux-gnu - arch: arm64 runner: warp-ubuntu-latest-arm64-4x kubectl_arch: arm64 rust_target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - name: Install kubectl for ${{ matrix.arch }} run: | version="$(curl -fsSL https://dl.k8s.io/release/stable.txt)" curl -fsSLo kubectl \ "https://dl.k8s.io/release/${version}/bin/linux/${{ matrix.kubectl_arch }}/kubectl" sudo install -m 0755 kubectl /usr/local/bin/kubectl kubectl version --client - uses: actions/cache@v4 with: path: ~/.cargo/registry key: cargo-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock') }} - name: Build run: cargo build --release --target ${{ matrix.rust_target }} - name: Verify the artifact architecture run: file "target/${{ matrix.rust_target }}/release/app" - uses: actions/upload-artifact@v4 with: name: app-linux-${{ matrix.arch }} path: target/${{ matrix.rust_target }}/release/app ``` `fail-fast: false` keeps the x64 leg running while the ARM64 leg is still failing, which is what you want during the move. The `file` step turns a silent mismatch into a readable line in the log, and `${{ runner.arch }}` in the cache key keeps the two legs from overwriting each other ([GitHub contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts)). ## [Detail](#detail)Copy Anchor Link ### [The architecture tokens are inconsistent across ecosystems](#the-architecture-tokens-are-inconsistent-across-ecosystems)Copy Anchor Link Nothing standardized on one spelling. A step that hardcodes `amd64` in a URL and a step that hardcodes `x86_64` in a wheel name are both wrong on ARM64, and they are wrong with different strings. This table covers the tokens that appear most often in a workflow file. | Where the token appears | x86-64 value | ARM64 value | | ------------------------------- | ------------------------- | ------------------------- | | uname -m in a shell step | x86\_64 | aarch64 | | ${{ runner.arch }} context | X64 | ARM64 | | Go toolchain (GOARCH, go env) | amd64 | arm64 | | Rust target triple | x86\_64-unknown-linux-gnu | aarch64-unknown-linux-gnu | | Docker and OCI platform strings | linux/amd64 | linux/arm64 | | Python wheel tags | manylinux\_2\_28\_x86\_64 | manylinux\_2\_28\_aarch64 | | Node platform packages | linux-x64 | linux-arm64 | The `runner.arch` values come from the [GitHub contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts). Two rules keep this manageable. Keep every token explicit instead of deriving one from another with a `sed` expression inside a `run:` block, because the mapping between them is arbitrary. Put the tokens in the matrix entry, next to the runner label, so adding a third architecture later is one block of YAML rather than a search across the file. ### [Native modules are the category that fails quietly](#native-modules-are-the-category-that-fails-quietly)Copy Anchor Link Downloads fail loudly. Native modules sometimes install and then break at run time, which costs more debugging than the label change itself. Node packages that ship prebuilt binaries publish one optional package per platform, so a lockfile written on an x86-64 machine can record the `linux-x64` variant and omit the `linux-arm64` one. `esbuild` documents this platform-package layout in its own repository ([evanw/esbuild](https://github.com/evanw/esbuild)), and the same pattern covers image processing, native crypto, and database driver packages. Regenerate the lockfile with the ARM64 variant resolvable, or install with optional dependencies enabled, then confirm the resolved package name in the install log rather than trusting the exit code. Python wheels follow the tag in the filename. Where the index carries no `aarch64` wheel for a pinned version, `pip` falls back to building from source, which needs the compiler and headers on the runner that a prebuilt wheel makes unnecessary. That fallback shows up as a long install step before it shows up as a failure, so read the install log for lines that say the package is being built rather than downloaded. JVM and Go projects are the easy cases. Go cross-compiles by setting `GOARCH`, and the JVM runs the same bytecode, with the exception of libraries that ship bundled native code and select it by classifier at run time. Check those classifiers the same way you check a wheel tag. ### [Container base images need a linux/arm64 manifest](#container-base-images-need-a-linuxarm64-manifest)Copy Anchor Link A job that uses `container:`, a step that runs `docker run`, or a Dockerfile with a `FROM` line all resolve an image for the host architecture. When the tag has no `linux/arm64` entry in its manifest list, the pull fails with `no matching manifest`. Check before the migration rather than during it: Copy Text ``` docker manifest inspect nginx:1.27 \ | grep -A2 '"platform"' | grep architecture ``` Official images on Docker Hub are built for multiple architectures by the [docker-library/official-images](https://github.com/docker-library/official-images) build system, so the common bases are usually covered. Internal images built by an x86-64-only pipeline usually are not, and those are the ones to find first. Building both architectures natively, one job per architecture on a matching runner, avoids the emulation path entirely; the [ARM64 migration checklist for GitHub Actions](/guides/arm64-migration-checklist-github-actions) sequences that work. ### [Absolute paths, and what to do about the work directory](#absolute-paths-and-what-to-do-about-the-work-directory)Copy Anchor Link The fourth category is the one that has nothing to do with the instruction set. Steps that write `/home/runner/work/...` as a literal break on the ARM64 images because the work directory is `/runner/_work` there ([cloud runners documentation](/docs/ci/cloud-runners)). Replace the literal with `${{ github.workspace }}` in YAML and `$GITHUB_WORKSPACE` in shell steps, which resolve correctly on every image and on GitHub-hosted runners ([GitHub variables reference](https://docs.github.com/en/actions/reference/workflows-and-actions/variables)). Docker `-v` mounts, coverage upload paths, and checked-in tool configuration are the usual carriers. ### [What the move costs](#what-the-move-costs)Copy Anchor Link Rates come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. A team running 10,000 job minutes a month on a 4 vCPU shape pays 10,000 x $0.008 = $80.00 on `warp-ubuntu-latest-x64-4x` and 10,000 x $0.006 = $60.00 on `warp-ubuntu-latest-arm64-4x`, a difference of $20.00 a month at identical vCPU and RAM. Per-size rates for every label are on the [pricing page](/pricing). When the first ARM64 job fails and the log does not say why, the surrounding product surface is faster than adding print statements. The Action Debugger opens a shell on the failed runner so you can run `uname -m` and `file` against the artifact that broke, and CI observability shows whether the failure is on one architecture or both. Jobs that never reach a runner at all are a different problem, covered in the [common issues documentation](/docs/ci/common-issues). ## [Related Questions](#related-questions)Copy Anchor Link ### [What happens if a step downloads an x86-64 binary on an ARM64 runner?](#what-happens-if-a-step-downloads-an-x86-64-binary-on-an-arm64-runner)Copy Anchor Link The download succeeds and the first execution fails with `cannot execute binary file: Exec format error`. The kernel reads the ELF header, sees a machine type it cannot run, and refuses. The fix is to drive the architecture token in the URL from the same matrix variable that drives the `runs-on` label, or from `uname -m` inside the step, so the token always matches the machine. Add a `file` step after the download while you are migrating, because it names the target architecture in the log before anything tries to execute it. ### [Do the ARM64 images ship the same preinstalled tools as the x64 images?](#do-the-arm64-images-ship-the-same-preinstalled-tools-as-the-x64-images)Copy Anchor Link Close, and the lists are published rather than assumed. The Ubuntu 24.04 ARM64 runners are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the package list is at [actions/partner-runner-images](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md); the Ubuntu 26.04 ARM64 list is at [actions/runner-images](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2604-Arm64-Readme.md). Read the list before you assume a language version is present. Sizes and Ubuntu releases for both lines are in the [Linux ARM64 runner catalog](/runners/linux-arm64). ### [Can I move one job to ARM64 without moving the whole workflow?](#can-i-move-one-job-to-arm64-without-moving-the-whole-workflow)Copy Anchor Link Yes. `runs-on` is set per job, so a workflow can run its test job on `warp-ubuntu-latest-arm64-4x` and leave packaging on an x86-64 label until the artifacts are sorted out. Most teams move one job, keep `fail-fast: false` while the architecture-specific breakage is worked through, then move the rest. The [ARM64 migration checklist for GitHub Actions](/guides/arm64-migration-checklist-github-actions) covers the order, and [do ARM64 runners use a different work directory](/answers/do-arm64-runners-use-a-different-work-directory) covers the path audit to run first. --- # What Does GitHub Actions Cache Storage Cost? | WarpBuild URL: https://www.warpbuild.com/answers/what-does-github-actions-cache-storage-cost Description: Cache for GitHub Actions costs $0.20 per GB-month of storage plus $0.0001 per write, restore, or list operation on WarpBuild hosted runners, free on BYOC. # What Does GitHub Actions Cache Storage Cost? Cache for GitHub Actions costs $0.20 per GB-month of storage plus $0.0001 per write, restore, or list operation on WarpBuild hosted runners, free on BYOC. Last verified: 2026-08-13 Cache for GitHub Actions has two price components on WarpBuild hosted runners: $0.20 per GB-month of storage and $0.0001 per cache write, restore, or list operation. Both components are free on BYOC runners, where add-ons are included and the compute is billed by your own cloud account. ## [Answer](#answer)Copy Anchor Link The cache bill separates what you keep from what you do. Storage is metered on the gigabytes held across the month. Operations are metered per call, whether the call writes an entry, restores one, or only checks whether one exists. | Line item | Hosted runners | BYOC runners | | ----------------------------- | --------------------- | ------------ | | Cache storage | $0.20 per GB-month | Free | | Cache write, restore, or list | $0.0001 per operation | Free | Rates come from the [WarpBuild pricing page](/pricing) and the [caching documentation](/docs/ci/features/caching), both checked on 2026-08-13. Those two numbers make the arithmetic short. A 20 GB working set costs $4.00 for the month. A fleet that issues 50,000 restores and 6,000 writes in the same month adds $5.60\. Nothing else attaches to the line, because pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so cache spend never scales with the number of repositories or engineers on the account. GitHub Actions Cache on GitHub-hosted runners carries no separate line on a GitHub invoice. It is constrained by a per-repository storage limit and an expiration policy instead, both configurable under organization Actions settings, as GitHub describes in its [caching dependencies documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows), checked on 2026-08-13\. WarpBuild takes the opposite trade: the cache has no size gate, and the gigabytes you choose to hold show up as a metered line. Entries expire after 7 days of last use and can be deleted at any time from the console or from the action, so an abandoned branch stops billing about a week after its last job. Signup includes $10 free credits, which covers a full month of cache for most small fleets before anything reaches an invoice. ## [Detail](#detail)Copy Anchor Link ### [What the storage meter counts](#what-the-storage-meter-counts)Copy Anchor Link The meter runs on gigabytes held over time, not on gigabytes written once. A 6 GB entry that lives for a full month bills $1.20\. The same entry deleted after 10 days bills roughly $0.40\. Cache size on WarpBuild has no quota to hit, which moves the ceiling from a hard limit to a number on the invoice, and makes retention the thing worth managing. Three attributes decide how many copies of a working set you are holding. The cache is scoped to the key, the [cache version](/docs/ci/features/caching), and the branch. Version is a hash of the compression tool and the list of cached paths, which is why a cache saved on `warp-macos-14-arm64-6x` cannot be restored on `warp-ubuntu-latest-x64-4x`. Each branch that saves an entry holds its own copy of it. Branch scoping is the usual reason a cache bill grows faster than a repository does. A 2 GB dependency set on a single branch is $0.40 a month. The same 2 GB saved on 40 branches that stayed active through the month is 80 GB-month, or $16.00\. Because entries expire 7 days after their last use, the steady state is roughly one copy per branch that ran a job inside a rolling seven day window, and long-lived release branches keep their copies alive indefinitely. ### [What the operation meter counts](#what-the-operation-meter-counts)Copy Anchor Link Every cache call is $0.0001, so operations become material only at fleet scale. A workflow that restores three caches and writes one on each of 900 pull request runs a month issues 3,600 operations, or $0.36\. An organization running 60,000 jobs a month with two cache steps each lands near 130,000 operations, or $13.00. The report distinguishes the two kinds of call, which is what makes the number actionable: hits are recorded separately from commits, so a fleet paying mostly for commits is re-saving entries it should be restoring. Splitting restore and save is the cheapest way to keep commits low, since the save step then runs only on a miss. Copy Text ``` name: ci on: pull_request: branches: [main] jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Restore npm cache id: npm-cache uses: WarpBuilds/cache/restore@v1 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} restore-keys: | npm-${{ runner.os }}- - run: npm ci - name: Save npm cache if: steps.npm-cache.outputs.cache-hit != 'true' uses: WarpBuilds/cache/save@v1 with: path: ~/.npm key: ${{ steps.npm-cache.outputs.cache-primary-key }} - run: npm test integration: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - name: Restore build cache uses: WarpBuilds/cache/restore@v1 with: path: .build-cache key: build-${{ hashFiles('**/lockfiles') }} fail-on-cache-miss: false - run: ./scripts/integration.sh ``` The `key` on the npm step changes only when `package-lock.json` changes, so a week of pull requests against a stable lockfile issues one restore per run and no writes. The `restore-keys` prefix keeps a partial match available after a dependency bump, which turns what would be a full miss into a hit plus one write. The `lookup-only` input is worth knowing about for the reverse case: it checks existence without downloading, which still bills one operation while skipping the transfer time. ### [A worked monthly model](#a-worked-monthly-model)Copy Anchor Link Take an organization of 60 engineers running 6,000 workflow runs a month, five cache-using jobs per run, an average of 30 GB held across the month, and a 12 percent write rate on cache steps. | Line item | Volume | Rate | Cost | | ------------------ | ----------- | ------------------ | ----- | | Cache storage | 30 GB-month | $0.20 per GB-month | $6.00 | | Restore operations | 30,000 | $0.0001 each | $3.00 | | Write operations | 3,600 | $0.0001 each | $0.36 | | Total cache | $9.36 | | | Now put that next to the minutes it removes. Assume each hit skips 1.5 minutes of dependency installation. That is 45,000 minutes a month, which on `warp-ubuntu-latest-x64-4x` at $0.008 per minute is $360.00 of runner time that never gets billed. The same 45,000 minutes on the 4-core Linux larger runner from GitHub, listed at $0.012 per minute on the [GitHub Actions per-minute rate list](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and checked on 2026-08-13, would be $540.00. The failure mode runs the other way. A team that caches a 6 GB toolchain directory on 40 active branches holds 240 GB-month, or $48.00, and gets nothing extra for the duplication. Keying that entry to the toolchain version rather than the branch, and saving it from the default branch so feature branches restore it through `restore-keys`, collapses 40 copies into one and drops the line to $1.20. Cache can be a small share of the total, depending on stored volume and job usage. Use the [pricing page](/pricing) for current rates and model each line item separately. ### [Reading the real number instead of modelling it](#reading-the-real-number-instead-of-modelling-it)Copy Anchor Link The Cache tab of the Billing report carries the actual figures. Summary cards show total cost, storage cost, operations cost, and total entries for the selected period. A daily stacked bar chart breaks cost down by cache type, the table lists entries with their type and cost, and the filter set is storage, operation-hit, and operation-commit. Every view exports to CSV. The [reports documentation](/docs/ci/features/reports) covers the CI and Docker Builder tabs alongside it. Three readings are worth taking each month. A storage cost climbing while entry count stays flat means entries are getting fatter, usually from a widened `path` list. An operation-commit share above a fifth of all operations means keys are churning, usually from a timestamp or a commit SHA in the key. A rising entry count with flat storage means branch sprawl, which the eviction window clears on its own once those branches go quiet. For splitting any of this across teams, the [cost attribution guide](/guides/github-actions-cost-attribution) covers the per job billing rows and the runner label and stack filters that make a chargeback key. ### [Where the cache line goes to zero](#where-the-cache-line-goes-to-zero)Copy Anchor Link BYOC removes both components. Add-ons are included on BYOC runners, so storage and operations cost nothing and the underlying capacity is billed by your own cloud account. Windows is the other case with no cache line, for a different reason. The caching documentation lists Windows runners as unsupported, so a cache step inside a job running on a `warp-windows-latest-x64-4x` label falls back to GitHub Actions Cache and produces no WarpBuild storage or operation charges. Caching is enabled by default on Linux runners. Docker layer caching deserves its own decision. Proxying layers through the cache backend works and bills as ordinary storage and operations, and the documentation recommends remote Docker builders instead, where the persistent layer cache lives on the builder and bills per builder session. ## [Related Questions](#related-questions)Copy Anchor Link ### [When do GitHub Actions cache entries get evicted?](#when-do-github-actions-cache-entries-get-evicted)Copy Anchor Link WarpBuild cache entries expire after 7 days of last use, so an entry that keeps getting restored keeps paying rent and an abandoned branch stops billing a week after its last job. Entries can also be deleted at any time from the console or with the `delete-cache` input on the cache action, which is the fast way to clear a large mistaken entry rather than waiting out the window. The eviction rules are covered in the [caching documentation](/docs/ci/features/caching) and in more depth in [how long GitHub Actions caches last](/answers/how-long-do-github-actions-caches-last). ### [Can two workflows share a cache entry?](#can-two-workflows-share-a-cache-entry)Copy Anchor Link Yes, when they ask for the same key, the same cache version, and run on a branch that can see the entry. Version is a hash of the compression tool and the cached paths, so a cache saved by a macOS job cannot be restored by a Linux job even with an identical key. Entries saved on the default branch are the ones feature branches can restore, which is why storing shared toolchains there costs one copy instead of one per branch. The [cache sharing answer](/answers/can-i-share-a-cache-between-github-actions-workflows) works through the branch rules with examples. ### [Does WarpBuild cache work on Windows runners?](#does-warpbuild-cache-work-on-windows-runners)Copy Anchor Link No. The [caching documentation](/docs/ci/features/caching) lists Windows runners as unsupported, so cache steps in a job running on a `warp-windows-latest-x64-4x` label fall back to GitHub Actions Cache and generate no WarpBuild storage or operation charges. Linux x64 and Linux ARM64 runners have the cache enabled by default, and macOS jobs can use it with the version caveat that a macOS entry restores only on macOS. ### [How do I see what my cache actually costs?](#how-do-i-see-what-my-cache-actually-costs)Copy Anchor Link The Cache tab of the Billing report splits total cost into storage cost and operations cost, shows the entry count for the period, and filters by cache type: storage, operation-hit, and operation-commit. Every view exports to CSV, so a month of cache rows can be summed per type in a spreadsheet. Pair it with the [size limit guide](/guides/github-actions-cache-size-limit) when storage is the growing half, and with the [pricing page](/pricing) for the rest of the rate card. --- # What Gets Created in My Cloud Account? | WarpBuild URL: https://www.warpbuild.com/answers/what-does-warpbuild-create-in-my-cloud-account Description: WarpBuild BYOC creates a CloudFormation stack in your AWS account holding a VPC, a security group, and an S3 bucket, plus per-job EC2 instances and volumes. # What Gets Created in My Cloud Account? WarpBuild BYOC creates a CloudFormation stack in your AWS account holding a VPC, a security group, and an S3 bucket, plus per-job EC2 instances and volumes. ## [Answer](#answer)Copy Anchor Link WarpBuild BYOC creates a stack of resources inside your own cloud account, and every one of them follows a documented naming convention so it stays identifiable in an account you share with other workloads. On AWS that stack is a CloudFormation stack holding network and storage resources, plus an IAM role created once when you connect the account, plus EC2 instances, EBS volumes, and launch templates that appear and disappear around individual GitHub Actions jobs. The resource graph is drawn out in the [BYOC AWS architecture reference](/docs/ci/byoc/aws/architecture) and the per-resource configuration is in the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config). BYOC runs on AWS, GCP, and Azure. This page covers the AWS inventory, because AWS is the cloud with import mode and with the published CloudFormation resource list. | Resource | Created when | Documented name or default | | -------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------ | | IAM role for the cloud connection | Once, when you connect the AWS account | warpbuild- | | VPC, public subnets, and route tables | Stack creation, create mode only | Stack scoped | | Private subnets with NAT | Stack creation, create mode, when runners need static egress IPs | Stack scoped | | Security group | Stack creation in create mode; selected by you in import mode | All egress allowed, ingress from VPC subnet CIDRs only | | Endpoints for S3 and for the ECR API and Docker registry | Stack creation | Stack scoped | | S3 bucket | Stack creation, in the same region as the stack | warpbuild-\* | | Launch templates | Around runner launches | tmpl-warp-\* | | EC2 instances | One per GitHub Actions job, terminated when the job ends | warp-\* | | EBS volumes | With each instance and with each standby disk | warp-\* | Resource lifetimes and default configurations come from the [BYOC AWS security guide](/docs/ci/byoc/aws/security), checked on 2026-08-13. The instances the stack launches in your account are the Linux and Windows ones. macOS runners stay on WarpBuild-hosted capacity, so no macOS resource is created in your AWS account. ## [Detail](#detail)Copy Anchor Link ### [The connection role, created once](#the-connection-role-created-once)Copy Anchor Link Connecting an AWS account creates a single IAM role named `warpbuild-`, applied by a CloudFormation template you run yourself. The role is the whole access surface, and its statements are grouped into named policy contexts so you can read them one block at a time: `FineGrainedEC2Permissions` and `SpotServiceLinkedRolePermissions` for launching just-in-time runners, `NetworkPermissions` for describing your network and managing VPC endpoints, `StoragePermissions` for EBS snapshots and the S3 actions behind the cache action and runner logs, and `CloudFormationPermissions` for changeset requests when a stack needs an upgrade. Deleting the role ends the access. The full policy document is expandable in the [configuration guide](/docs/ci/byoc/aws/config). ### [The stack, created once per region](#the-stack-created-once-per-region)Copy Anchor Link The stack name, the S3 bucket, and the region are fixed at creation. In create mode CloudFormation builds the network: a VPC with public subnets, optional private subnets behind NAT when runners need static egress IPs, route tables, and a security group attached to every instance the stack launches. In import mode you supply an existing VPC, subnets, and security group, and none of those network resources are created. The bucket is where durable data lands, under two documented key layouts: Copy Text ``` //artifact_cache///// /runner/logs/all/. ``` Both prefixes grow until you expire them, and the documented recommendation is a 7 day lifecycle rule adjusted to your own retention policy. The stack also provisions the endpoints that let runners in both public and private subnets reach that bucket and pull images from ECR, which is why an ECR login on a public subnet runner needs CloudFormation template version 1.4 or later. ### [The per-job resources](#the-per-job-resources)Copy Anchor Link Each GitHub Actions job runs on a distinct, freshly provisioned EC2 instance that is terminated when the job completes. Nothing survives to serve a second job. Standby disks are the exception worth knowing about for an account inventory: a [standby disk](/docs/ci/byoc/standby-disks) is a VM of the runner instance type, booted once and then shut down, so a stopped instance and its EBS volume sit in your account waiting. The stopped instance bills no compute; the volume bills storage at your rates. ### [Naming and tagging in a shared account](#naming-and-tagging-in-a-shared-account)Copy Anchor Link | Tag key | Value | | ----------------------- | ------------------------------------- | | warpbuild-managed-by | warpbuild | | Name | {runner-id} | | warpbuild-github-org | {github-org} | | warpbuild-runner-labels | {runner-label1}, {runner-label2}, ... | | warpbuild-runner-id | {runner-id} | | warpbuild-stack-id | {stack-id} | | warpbuild-stack-name | {stack-name} | Custom tags you set on the stack are added to everything the stack creates, which is how cost attribution by team or product works. WarpBuild also adds a `managed-by: warpbuild` tag to provisioned resources, and that tag is usable as a condition key for [AWS IAM tag-based access control](https://docs.aws.amazon.com/IAM/latest/UserGuide/access%5Ftags.html). Stack resource tagging in create mode arrived in CloudFormation template version 1.3; runner instances are tagged in every case. A cost report filtered on `warpbuild-runner-labels` gives you spend per runner class with no extra instrumentation. ### [What the control plane does from outside](#what-the-control-plane-does-from-outside)Copy Anchor Link The control plane holds the GitHub App connection, the runner configurations, and the watch on the GitHub Actions job queue. When a queued job matches one of your `runs-on` labels, it calls the AWS API through the connection role and launches an instance. It never holds your build data. Runners reach `api.warpbuild.com` and GitHub outbound, so the recommended security group blocks all inbound traffic. The permissions your build steps carry are the ones you attach through an instance profile, and WarpBuild injects none into runner workloads. ### [What the account placement costs](#what-the-account-placement-costs)Copy Anchor Link WarpBuild charges $0.002 per runner minute for BYOC Linux runners and $0.002 per runner minute for BYOC Windows runners, listed with every other rate on the [pricing page](/pricing), checked on 2026-08-13\. AWS bills your account for the EC2 instances, EBS volumes, NAT gateways, and S3 storage in the table above at your own rates. ## [Related Questions](#related-questions)Copy Anchor Link ### [Can WarpBuild use a VPC I already have?](#can-warpbuild-use-a-vpc-i-already-have)Copy Anchor Link Yes, on AWS. Import mode takes an existing VPC, subnets, and security group as inputs, so the stack creates the S3 bucket and the runner-level resources and leaves your network untouched. Create mode is the path where CloudFormation builds the VPC, subnets, route tables, and security group for you. The choice is made once, when the stack is created, and the [AWS BYOC page](/byoc/aws) walks through both modes. ### [How do I tell WarpBuild resources apart in a shared account?](#how-do-i-tell-warpbuild-resources-apart-in-a-shared-account)Copy Anchor Link By name and by tag. S3 buckets are named `warpbuild-*`, EC2 instances and EBS volumes `warp-*`, and launch templates `tmpl-warp-*`. Every provisioned resource carries the `managed-by: warpbuild` tag, and runner instances also carry `warpbuild-github-org`, `warpbuild-runner-labels`, `warpbuild-runner-id`, `warpbuild-stack-id`, and `warpbuild-stack-name`. The naming and tagging tables live in the [configuration guide](/docs/ci/byoc/aws/config). ### [What does WarpBuild run outside my account?](#what-does-warpbuild-run-outside-my-account)Copy Anchor Link The control plane. It holds the GitHub App connection, the runner configuration, and the watch on the GitHub Actions job queue, then calls the AWS API through the IAM role you created to launch and terminate instances. Job workspaces, caches, logs, and artifacts stay on the instances and in the bucket inside your account. The [control plane definition](/glossary/control-plane) covers the split in general terms, and the [BYOC runner lifecycle](/byoc/runner-lifecycle) follows one instance from queue to termination. --- # What Happens to Runner Disks After a Job? | WarpBuild URL: https://www.warpbuild.com/answers/what-happens-to-runner-disks-after-a-job Description: The disk is destroyed with the job. Each WarpBuild runner gets its own encrypted volume, created on demand and destroyed after the build, with one exception. # What Happens to Runner Disks After a Job? The disk is destroyed with the job. Each WarpBuild runner gets its own encrypted volume, created on demand and destroyed after the build, with one exception. ## [Answer](#answer)Copy Anchor Link The disk is destroyed with the job. Each WarpBuild runner has its own encrypted storage volume that is created on demand and destroyed after each build, and the virtual machine that carried it is destroyed on the same schedule and never handed to a second job, both stated in the [runner security documentation](/docs/ci/security). That answers the security question and the debugging question at once. Nothing you write to the disk survives the job, so a credential file left behind by a login step goes away with the volume, and so does the build output you forgot to upload. One feature departs from that rule on purpose, and it is the only one: snapshot runners save the disk as an image under an alias you name. Disk size varies by label, and every size in the catalog follows the same lifetime. | Runner family | Disk in the catalog | State after the job ends | | --------------------------------------------------------- | ------------------- | ---------------------------------- | | warp-ubuntu-latest-x64-\* and warp-ubuntu-latest-arm64-\* | 150GB SSD | Destroyed with the virtual machine | | warp-macos-26-arm64-6x | 120GB SSD | Destroyed with the virtual machine | | warp-macos-26-arm64-12x | 270GB SSD | Destroyed with the virtual machine | | warp-windows-latest-x64-\* | 256GB SSD | Destroyed with the virtual machine | Sizes from the [runner catalog](/pricing), checked on 2026-08-13\. The per-job volume applies across all four platforms. ## [Detail](#detail)Copy Anchor Link ### [The two documented statements behind the answer](#the-two-documented-statements-behind-the-answer)Copy Anchor Link The security documentation records the disk lifetime in two separate paragraphs, and a reviewer will want both. Compute isolation: each runner runs in its own virtual machine, the machines are created on demand and destroyed after each build, and they are ephemeral and never reused. Storage protection: each runner has its own encrypted storage volume, created on demand and destroyed after each build, and when caching is enabled the cache is encrypted and stored in a location only your runner can reach. Put together, there is no host that ran your build this morning and someone else's build this afternoon, and no warm pool holding a working tree from an earlier run. That property is what container based self-hosted setups such as ARC have to configure and then keep configured, and GitHub's own [self-hosted runner guidance](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access) warns about untrusted code on machines that outlive a job. The attestation behind the platform is SOC 2 Type 2, requested through [trust.warpbuild.com](https://trust.warpbuild.com/). The [security review checklist](/enterprise/ci-security-review) puts these statements in the order reviewers ask for them. ### [What it means for secrets written to disk during a job](#what-it-means-for-secrets-written-to-disk-during-a-job)Copy Anchor Link Secrets reach the runner as environment variables, and steps then write them to files. A `docker login` step writes `.docker/config.json`. A cloud credential action writes a profile under `$HOME/.aws`. A cluster login writes a kubeconfig. `actions/checkout` writes a token into `.git/config` unless you pass `persist-credentials: false`. Every one of those files lives exactly as long as the volume does. WarpBuild does not access or store build secrets; they stay in your source code repository and reach only the runner environment for the duration of the job. The residual risk is a later step that copies a credential off the disk before the disk goes away. Three paths do that, and each is under your control: * A cache step writes whatever `path` names into cache storage, which outlives the job by design. Name package manager download directories, and keep home directory dotfiles out of the list. * An artifact upload sends named files to GitHub, where they follow your repository retention setting. * A snapshot save writes the entire disk into an image, covered next. [Do GitHub Actions runners store my source code](/answers/do-github-actions-runners-store-my-source-code) walks each persisted surface with its retention number, and [ephemeral storage](/glossary/ephemeral-storage) defines the underlying term. ### [The snapshot exception, stated precisely](#the-snapshot-exception-stated-precisely)Copy Anchor Link A snapshot is a saved image of the runner disk at the instant the `WarpBuilds/snapshot-save` action ran. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) sets out four facts worth reading as one unit: * **What it captures.** The disk as it stood at the save, including the working tree, dependency trees, compiler caches, and any credential file still present. The `/tmp` directory is the documented exception, since it is cleaned on reboot. * **How long it lives.** Snapshots are temporary and deleted after 15 days, rather than at the end of the job. * **How access is scoped.** The alias is a plain string in a `runs-on` label, so the scope is whoever can get that string into a workflow run. On public repositories the documentation warns that others may access the snapshot using the alias in a pull request workflow run. On private repositories, runners are provisioned at the organization level and GitHub may allocate a runner intended for snapshot jobs to a different job within the organization. * **Where it applies.** Snapshot labels work on WarpBuild Cloud Ubuntu runners, and are silently ignored on BYOC, Windows, and macOS runners. The mitigation is a cleanup step immediately before the save, which is the one place the disk lifetime is your responsibility instead of the platform's. Copy Text ``` name: build on: push: branches: [main] permissions: contents: read jobs: build: runs-on: warp-ubuntu-latest-x64-4x;snapshot.enabled=true steps: - uses: actions/checkout@v5 with: persist-credentials: false - name: Install dependencies run: npm ci - name: Build run: npm run build - name: Show what the image is about to hold run: | df -h / du -sh node_modules ~/.npm 2>/dev/null || true - name: Remove credentials from the disk run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot uses: WarpBuilds/snapshot-save@v1 with: alias: "web-app-main" fail-on-error: false ``` `snapshot.enabled=true` boots from the base image every time, so the published disk is rebuilt from a known starting point on each merge rather than accumulating whatever earlier runs left behind. `df -h` and `du -sh` before the cleanup give you a record in the job log of how large the image is going to be, which is the number the storage charge follows. [Snapshot runners](/runners/snapshot) covers the restore side and the label syntax. ### [What a preserved disk costs](#what-a-preserved-disk-costs)Copy Anchor Link Snapshot storage bills at $0.025 per snapshot-hour and each restore at $0.04 per job, from the [pricing page](/pricing), checked on 2026-08-13\. Because storage bills by the hour an alias exists, the monthly line depends on how many aliases you keep alive rather than how many jobs run. | Live aliases | Snapshot storage per 720 hour month | Restores at 880 jobs per month | Monthly total | | ------------ | ----------------------------------- | ------------------------------ | ------------- | | 1 | $18.00 | $35.20 | $53.20 | | 3 | $54.00 | $35.20 | $89.20 | | 10 | $180.00 | $35.20 | $215.20 | The restore column is flat because it bills per job, so the alias count is the lever on both the cost line and the retention line. Runners with no snapshot label carry neither charge, and their disks end at the job with nothing to bill and nothing to retain. ## [Related Questions](#related-questions)Copy Anchor Link ### [Is the runner disk wiped or destroyed at the end of a job?](#is-the-runner-disk-wiped-or-destroyed-at-the-end-of-a-job)Copy Anchor Link Destroyed. Each runner has its own encrypted storage volume that is created on demand and destroyed after each build, and the virtual machine holding it is destroyed on the same schedule and never handed to a second job, per the [runner security documentation](/docs/ci/security). There is no wipe step to trust, because the volume itself goes away. The [security review checklist](/enterprise/ci-security-review) records the same statement in questionnaire form. ### [What happens to a secret a step wrote to the runner disk?](#what-happens-to-a-secret-a-step-wrote-to-the-runner-disk)Copy Anchor Link It goes with the volume. A registry token in `.docker/config.json`, an AWS profile under `$HOME/.aws`, or a kubeconfig written mid-job lives as long as the volume does, and WarpBuild does not access or store build secrets. The exception is a later step that copies the file somewhere else, such as a cache entry, an uploaded artifact, or a snapshot image, which is what [do GitHub Actions runners store my source code](/answers/do-github-actions-runners-store-my-source-code) covers surface by surface. ### [What does a snapshot preserve, and who can boot from it?](#what-does-a-snapshot-preserve-and-who-can-boot-from-it)Copy Anchor Link A snapshot preserves the whole runner disk at the instant the save action ran, apart from `/tmp`, which is cleaned on reboot. It is stored under an alias and deleted after 15 days. Anyone who can put that alias in a `runs-on` label can boot from it, so on public repositories the [snapshot runners documentation](/docs/ci/features/snapshot-runners) warns that others may access the snapshot using the alias in a pull request workflow run. Run the documented cleanup before every save, and see [snapshot runners](/runners/snapshot) for the label syntax and [ephemeral storage](/glossary/ephemeral-storage) for the term the rest of the catalog follows. --- # What Happens When My Free Credits Run Out? | WarpBuild URL: https://www.warpbuild.com/answers/what-happens-when-my-free-credits-run-out Description: Signup includes $10 free credits. Once they are spent, usage keeps billing per minute at the rate for each runner label, with no subscription underneath it. # What Happens When My Free Credits Run Out? Signup includes $10 free credits. Once they are spent, usage keeps billing per minute at the rate for each runner label, with no subscription underneath it. Last verified: 2026-08-13 Signup includes $10 free credits, and once they are spent the account keeps running with usage billed per minute at the published rate for each runner label. There is no plan to pick and no subscription sitting underneath the balance, so the only change is that minutes start landing on an invoice instead of drawing down credit. ## [Answer](#answer)Copy Anchor Link The credit balance is a prepaid amount against the same rates that apply after it empties. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, which means the rate for a job on day one and the rate for the same job after the credits are gone are identical. Rates below come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13. How far $10 goes depends entirely on which label the jobs run on. The per-minute rate scales with vCPU count, so the label choice sets the drawdown speed. | Runner label | Shape | Rate per minute | Minutes covered by $10 | | --------------------------- | ------------- | --------------- | ---------------------- | | warp-ubuntu-latest-arm64-2x | 2 vCPU, 8 GB | $0.003 | about 3,333 | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | 2,500 | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | about 1,666 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | 1,250 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | 625 | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016 | 625 | | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08 | 125 | The arithmetic is one division per row. Ten dollars divided by $0.004 per minute is 2,500 minutes on the entry Linux x64 label. The same ten dollars divided by $0.08 per minute is 125 minutes on a 6 vCPU macOS runner. One macOS minute draws the balance down as fast as twenty entry Linux x64 minutes, which is why an iOS pilot reaches paid usage on far fewer runs than a Linux pilot. The entry Linux label is also where the list-price gap is easiest to check: `warp-ubuntu-latest-x64-2x` costs $0.004 per minute against $0.006 per minute for GitHub-hosted `ubuntu-latest` at the same 2 vCPU, 8 GB shape on private repositories, which is 33 percent lower list price ([GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). GitHub gives public repositories a 4 vCPU, 16 GB Linux shape at no charge, so that comparison applies to private repositories. ## [Detail](#detail)Copy Anchor Link ### [How far $10 goes on a pilot workflow](#how-far-10-goes-on-a-pilot-workflow)Copy Anchor Link A typical first workflow after the [quick start](/docs/ci/quick-start) runs three jobs on every pull request. Assume lint takes 2 minutes on the 2 vCPU Linux x64 label, unit tests take 6 minutes on the 4 vCPU label, and an ARM64 image build takes 5 minutes. | Job | Runner label | Minutes | Rate per minute | Cost per run | | ---------- | --------------------------- | ------- | --------------- | ------------ | | lint | warp-ubuntu-latest-x64-2x | 2 | $0.004 | $0.008 | | unit-tests | warp-ubuntu-latest-x64-4x | 6 | $0.008 | $0.048 | | arm-image | warp-ubuntu-latest-arm64-4x | 5 | $0.006 | $0.030 | | Total | 13 | $0.086 | | | At $0.086 per pull request run, $10 in credits covers 116 full runs before the balance reaches zero. A repository that opens 30 pull requests a week crosses that point in about four weeks. Adding a 12 minute macOS job at $0.08 per minute takes the same run to $1.046 and cuts the credits to 9 runs, so a pilot that includes iOS should expect to reach paid usage inside the first week. ### [Watching the balance draw down](#watching-the-balance-draw-down)Copy Anchor Link Estimating the burn rate matters less than measuring it. The [reports documentation](/docs/ci/features/reports) describes a Billing section with CI, Docker Builder, and Cache tabs. The CI tab shows a daily stacked cost chart that can be grouped by repository or by runner label, summary cards for total cost, runner cost, snapshot cost, and job count, and a table with one row per job execution carrying repository, job name, runner label, stack, execution time, billed time, and the runner and snapshot cost split. Every tab supports date range selection, filtering, and CSV export. Grouping the daily chart by runner label is the fastest way to see what is consuming the balance, since one macOS minute at $0.08 costs as much as twenty 2 vCPU Linux x64 minutes at $0.004\. The Jobs section adds duration, queue time, CPU, and memory percentiles per job, which finds the jobs paying for vCPU they never use before the credits are gone. ### [What the first paid invoice looks like](#what-the-first-paid-invoice-looks-like)Copy Anchor Link Nothing about the account changes when the balance empties. Jobs keep queuing on the same labels at the same rates, and the credits show up as a reduction on the invoice rather than as a plan boundary. Take the pilot workflow above at 400 runs in the first month. Runner cost is 400 multiplied by $0.086, or $34.40\. The $10 in signup credits comes off that total, leaving $24.40 on the first invoice. Cache storage at $0.20 per GB-month, cache operations at $0.0001 each, and snapshot restores at $0.04 per job are added alongside the runner line when those features are in use, per the [pricing page](/pricing). Billing runs on the last day of each month by default. Each account also carries a usage threshold based on its history, and crossing that threshold can trigger a charge to the payment method on file mid-cycle. Once real usage is visible, the [bill estimation walkthrough](/answers/how-do-i-estimate-my-github-actions-bill) turns one month of report data into a model, and the [minutes budget guide](/guides/github-actions-minutes-budget) turns that model into a rolling forecast. ### [The one case where credits do not apply](#the-one-case-where-credits-do-not-apply)Copy Anchor Link Accounts that subscribe through AWS Marketplace are the exception. The [AWS Marketplace billing documentation](/docs/ci/aws-marketplace-billing) states that AWS Marketplace billing does not support free credits, and that organization usage is billed daily at 12:15 AM UTC for the previous day. Because of that daily cadence, the totals shown in AWS Marketplace can lag the numbers on the WarpBuild billing dashboard. Teams that want the $10 starting balance should sign up directly and connect a payment method there instead. ## [Related Questions](#related-questions)Copy Anchor Link ### [How many GitHub Actions minutes do $10 in free credits cover?](#how-many-github-actions-minutes-do-10-in-free-credits-cover)Copy Anchor Link At $0.004 per minute on `warp-ubuntu-latest-x64-2x`, $10 covers 2,500 minutes. On the 2 vCPU Linux ARM64 label at $0.003 per minute it covers about 3,333 minutes, and on a 6 vCPU macOS runner at $0.08 per minute it covers 125 minutes. Every rate is listed on the [pricing page](/pricing). ### [Do I have to pick a plan when my free credits run out?](#do-i-have-to-pick-a-plan-when-my-free-credits-run-out)Copy Anchor Link No. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the account keeps running at the same per-minute rates once the balance reaches zero. The [minutes budget guide](/guides/github-actions-minutes-budget) covers how to forecast the monthly total that follows. ### [Where do I see how fast my credits are being spent?](#where-do-i-see-how-fast-my-credits-are-being-spent)Copy Anchor Link The Billing section of the [reports view](/docs/ci/features/reports) breaks cost down per job, per repository, and per runner label, with execution time, billed time, and the runner and snapshot split on every row. Group the daily chart by runner label to find the expensive corner, and export any tab to CSV to model it. The [bill estimation walkthrough](/answers/how-do-i-estimate-my-github-actions-bill) shows the arithmetic that turns those rows into a monthly number. --- # What Happens When Two Workflows Share a Concurrency Group? | WarpBuild URL: https://www.warpbuild.com/answers/what-happens-when-two-workflows-use-the-same-concurrency-group Description: Two workflows naming the same concurrency group serialize against each other, because a group is a repository-wide lane rather than a per-workflow setting. # What Happens When Two Workflows Share a Concurrency Group? Two workflows naming the same concurrency group serialize against each other, because a group is a repository-wide lane rather than a per-workflow setting. ## [Answer](#answer)Copy Anchor Link They serialize against each other. A concurrency group is a lane across the repository rather than a setting scoped to one workflow file, so when two workflows resolve the same group string, GitHub allows one run in that lane at a time and parks the other as pending, per the [concurrency reference in the workflow syntax docs](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency). The lane holds one running item and one pending item, so a third arrival cancels the pending run and takes its place. Two details turn this from a design choice into a surprise. First, the match is on the resolved string, and [GitHub's concurrency guide](https://docs.github.com/en/actions/using-jobs/using-concurrency) records that the group name is case insensitive, so `group: Deploy` in one file and `group: deploy` in another are the same lane. Second, `cancel-in-progress` belongs to the arriving run rather than to the group. A workflow that sets `cancel-in-progress: true` to clear its own stale runs will also cancel an in-flight run of a different workflow that happens to share the group name. Here is the sequence with two workflow files, `ci.yml` and `nightly.yml`, both carrying `concurrency: { group: build }`, with neither setting `cancel-in-progress`. | Time | Event | Lane state after the event | | ----- | --------------------------------- | ------------------------------------------- | | 09:00 | Push to main starts ci.yml run 41 | ci 41 running | | 09:02 | Schedule fires nightly.yml run 12 | ci 41 running, nightly 12 pending | | 09:05 | Second push starts ci.yml run 42 | nightly 12 canceled, ci 42 pending | | 09:11 | ci 41 finishes | ci 42 starts, nightly has to be retriggered | The nightly run never executes, and its logs show a cancellation rather than a failure, which is why teams chase the scheduler before they check the group name. ## [Detail](#detail)Copy Anchor Link ### [The naming convention that prevents accidental collisions](#the-naming-convention-that-prevents-accidental-collisions)Copy Anchor Link Most collisions come from a static group name pasted between files, such as a bare `concurrency: build` copied into three workflows. Build the group from the workflow identity and the ref instead, so each workflow gets its own lane per branch: Copy Text ``` name: ci on: pull_request: push: branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test ``` `github.workflow` resolves to the workflow name, so `ci.yml` and `nightly.yml` land in different lanes even on the same branch. Adding `github.ref` keeps a feature branch from parking a run on main. The `cancel-in-progress` expression cancels superseded pull request runs while letting main runs finish, so a deploy gate on main is never killed mid-flight. The same expression shape is covered in the guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits). ### [When the shared lane is the point](#when-the-shared-lane-is-the-point)Copy Anchor Link Sometimes two workflows should serialize. A release workflow and a hotfix workflow that both write to the same production environment want one lane, and they want the running item protected. Put the group at the job level, key it on the environment, and leave cancellation off: Copy Text ``` deploy: needs: test runs-on: warp-ubuntu-latest-x64-2x concurrency: group: deploy-production cancel-in-progress: false steps: - uses: actions/checkout@v4 - run: ./scripts/deploy.sh ``` Any job in any workflow that names `deploy-production` now queues behind the in-flight deploy instead of racing it. The one behavior to keep in mind is the single pending slot: if three deploys arrive while one runs, the middle one is canceled. Teams that need every request to land use a merge queue or an external lock instead of the concurrency key. The answer on [stopping a workflow from blocking deploys](/answers/how-do-i-stop-a-workflow-from-blocking-deploys) covers the reverse case, where an unrelated workflow is sitting in the deploy lane. For the term itself, see the [concurrency group glossary entry](/glossary/concurrency-group). ### [What a shared lane costs](#what-a-shared-lane-costs)Copy Anchor Link Serialization moves wall clock time and leaves billed minutes alone. In the timeline above, nightly 12 was canceled after zero compute, and ci 42 waited 6 minutes before starting. Both runs bill for the minutes they execute, whether they run together or one after the other. The labels in the two workflows above resolve to these machines, from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | runs-on label | OS | vCPU | RAM | Storage | Rate per minute | | ------------------------- | ------------ | ---- | ---- | --------- | --------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16GB | 150GB SSD | $0.008 | A 6-minute test job on `warp-ubuntu-latest-x64-4x` bills $0.048 whether it starts at 09:02 or 09:11\. Matched shape for shape, the rate sets the bill: `warp-ubuntu-latest-x64-2x` (2 vCPU, 8GB) costs $0.004 per minute against $0.006 per minute for GitHub-hosted `ubuntu-latest` on the same 2 vCPU, 8GB shape, a 33 percent lower list price, from [GitHub's per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. Full rates by runner type are on the [pricing page](/pricing). ### [Separating the group from runner capacity](#separating-the-group-from-runner-capacity)Copy Anchor Link A shared group and an exhausted runner pool produce similar screenshots and need different fixes. A run held by a group shows a waiting banner that names the group, which is the string to grep for across `.github/workflows`. A job held by capacity shows no banner and simply sits queued. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts as workflows fan out, with the scope stated in the [cloud runners documentation](/docs/ci/cloud-runners): features that are Generally Available support unlimited concurrency on Linux and Windows runners, while features in beta may carry limits. Because that ceiling is out of the picture, a queue on `warp-` labels usually points at the group name in your YAML. A job that stays queued with an empty log view is a third case, routing rather than queueing, and the [common issues checklist](/docs/ci/common-issues) covers the registration and permission checks for it. ## [Related Questions](#related-questions)Copy Anchor Link ### [Do concurrency groups apply across workflow files?](#do-concurrency-groups-apply-across-workflow-files)Copy Anchor Link Yes. GitHub matches on the resolved group string, so two workflow files that produce the same string share one lane, per the [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency). The group name is also case insensitive, so a job with `group: Deploy` and a job with `group: deploy` queue behind each other. ### [Which run waits when two workflows enter the same group?](#which-run-waits-when-two-workflows-enter-the-same-group)Copy Anchor Link The run already holding the lane keeps running, and the arriving run sits pending until the lane frees. A third arrival cancels the pending run and takes the pending slot, so a busy workflow can starve a quieter one that shares the group name. The timeline table above walks through that sequence minute by minute. ### [Can one workflow cancel another workflow's run?](#can-one-workflow-cancel-another-workflows-run)Copy Anchor Link Yes, when both name the same group and the arriving run sets `cancel-in-progress: true`. The setting belongs to the arriving run rather than to the group, per [GitHub's concurrency guide](https://docs.github.com/en/actions/using-jobs/using-concurrency), so a workflow that cancels its own stale runs will also cancel an in-flight run of a different workflow in the same lane. Scope the group with `${{ github.workflow }}` to keep that blast radius inside one file. ### [Does a shared group cost extra billed minutes?](#does-a-shared-group-cost-extra-billed-minutes)Copy Anchor Link No. Serialization changes wall clock time and leaves billed minutes alone, since each job still runs once for the same duration. The cost of a shared lane is engineers waiting on a pull request check. The guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) works the same arithmetic across a full matrix. Audit your group names, then price the suite behind them against the per-minute rates on the [pricing page](/pricing). --- # What Is a Remote Docker Builder? | WarpBuild URL: https://www.warpbuild.com/answers/what-is-a-remote-docker-builder Description: A remote Docker builder is a dedicated build VM outside the GitHub Actions runner that keeps a layer cache on its own disk, so repeat builds reuse layers. # What Is a Remote Docker Builder? A remote Docker builder is a dedicated build VM outside the GitHub Actions runner that keeps a layer cache on its own disk, so repeat builds reuse layers. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link A remote Docker builder is a dedicated build virtual machine that runs `docker build` outside the GitHub Actions runner and keeps a layer cache on its own disk between jobs. The runner checks out the repository and issues the build over a TLS connection, the builder does the work, and a repeat build reuses the layers already on that disk instead of rebuilding them. The problem it addresses comes from the runner lifecycle. A GitHub Actions runner is ephemeral, so the local Docker layer store is empty when the job starts and is discarded when the job ends. Every build therefore reinstalls the same base packages and recompiles the same unchanged stages. The usual workaround exports the layer cache to a registry or to the Actions cache at the end of a build and imports it at the start of the next one, which trades rebuild time for upload and download time over the network. A remote builder removes both the rebuild and the transfer. The layers stay on a disk attached to the machine that produced them, and the next build attaches to the same machine. Rates for every builder size are on the [pricing page](/pricing). Three facts define the model, and the rest of this page is the detail behind them. * **One builder profile is one builder virtual machine.** The profile carries the size, the disk, the architectures, and the cache. You create it once in the dashboard and reference it from a workflow by name. * **Several jobs on the same profile build in parallel against the same cache.** There is no limit on concurrent builds per profile, and the cache is shared between them, with layers from one in-flight build becoming available to the others after synchronization. * **A session is the billing unit.** A session runs from when the builder action starts until the job completes, and concurrent jobs on one profile share a single session billed from the first job start to the last job completion. The builder is independent of the runner it serves, so it also works from runners WarpBuild does not operate and from a local terminal through the API. The [Docker builders documentation](/docs/ci/docker-builders) is the reference for the behavior described below. ## [Detail](#detail)Copy Anchor Link ### [The profile is the unit of configuration](#the-profile-is-the-unit-of-configuration)Copy Anchor Link Everything about a builder lives on the profile: the size in vCPU and memory, the disk, the enabled architectures, and the layer cache that accumulates on that disk. A workflow selects a profile by passing `profile-name` to the WarpBuild action, and nothing else in the job changes. Two consequences follow from that. First, the builder size is decoupled from the runner size, so a 4 vCPU runner can drive a 192 vCPU builder. The runner is doing checkout, orchestration, and post-job steps while the builder does the compilation and layer work. Second, the cache is scoped to the profile rather than to a branch or a workflow, so every job pointing at `api-builder` shares one layer store. Splitting unrelated images across separate profiles keeps one team's churn from evicting another team's layers. ### [Sizing and the recommended 8 vCPU per build job](#sizing-and-the-recommended-8-vcpu-per-build-job)Copy Anchor Link The documented recommendation is roughly 8 vCPU and 16GB memory per concurrent build job. There is no enforced concurrency limit, so this is a sizing guide rather than a quota. The table below takes the published profile sizes and rates and applies that recommendation to get a concurrency target for each one. Rates come from the [Docker builders documentation](/docs/ci/docker-builders) and the [pricing page](/pricing), checked on 2026-08-13. | Profile size | vCPU | RAM | Disk | Price per minute | Concurrent build jobs at 8 vCPU and 16GB each | Architectures | | -------------------- | ---- | ----- | ----- | ---------------- | --------------------------------------------- | ------------------- | | 16 vCPU | 16 | 32GB | 100GB | $0.06 | 2 | amd64, arm64, multi | | 32 vCPU | 32 | 64GB | 200GB | $0.12 | 4 | amd64, arm64, multi | | 64 vCPU | 64 | 128GB | 200GB | $0.24 | 8 | amd64, arm64, multi | | 96 vCPU | 96 | 192GB | 600GB | $0.36 | 12 | amd64 | | 96 vCPU, large disk | 96 | 192GB | 2TB | $0.52 | 12 | amd64 | | 192 vCPU | 192 | 384GB | 600GB | $0.72 | 24 | amd64 | | 192 vCPU, large disk | 192 | 384GB | 2TB | $0.88 | 24 | amd64 | arm64 and multi-arch profiles cap at 64 vCPU, so the three largest rows are available for amd64-only profiles. Disk is the other axis worth reading carefully: a layer cache that holds several large base images and many build stages grows into that disk, and the 2TB variants exist for repositories whose image set outgrows 600GB. ### [Sessions, and why overlap matters to the bill](#sessions-and-why-overlap-matters-to-the-bill)Copy Anchor Link Session billing is the part that surprises people, so it is worth walking through the documented example. Two jobs share one x64 profile. J1 starts its builder at t1 and completes at t2\. J2 starts at t3 and completes at t4\. The order is t1 < t3 < t2 < t4, so the two jobs overlap. That is billed as one session from t1 to t4 rather than as two sessions. The practical reading: builds that overlap on one profile are cheap to add, and builds spread thinly across the day each carry their own session. A matrix of six images that all fire on a merge is one stretch of builder time. The same six images built one at a time across an afternoon is six stretches. Here is that arithmetic on a full month. The assumptions are stated so you can substitute your own measurements. * 500 image builds per month, each on a `warp-ubuntu-latest-x64-4x` runner at $0.008 per minute. * Each runner job runs 6 minutes end to end, including checkout, the build step, and post-job steps. * One builder profile at 32 vCPU, 64GB, 200GB disk, $0.12 per minute, sized for the 4 concurrent jobs above. * Overlapped case: builds arrive as 125 fan-outs of 4 jobs, and each fan-out holds the builder for an 8 minute session. * Staggered case: the same 500 builds arrive one at a time, and each holds the builder for a 5 minute session. | Line | Rate | Quantity | Monthly cost | | ----------------------------------------- | ----------------- | --------------------- | ------------ | | Runner, warp-ubuntu-latest-x64-4x | $0.008 per minute | 3,000 runner minutes | $24.00 | | Builder sessions, overlapped fan-outs | $0.12 per minute | 1,000 session minutes | $120.00 | | Overlapped total | $144.00 | | | | Builder sessions, staggered one at a time | $0.12 per minute | 2,500 session minutes | $300.00 | | Staggered total | $324.00 | | | Same 500 builds, same profile, $180.00 of difference from scheduling alone. Batching image builds into the same window is the single largest lever on a builder bill. Only the runner line has a GitHub-hosted equivalent, because GitHub-hosted runners have no remote builder product to price against. On that line, `warp-ubuntu-latest-x64-4x` at 4 vCPU and 16GB lists at $0.008 per minute where the 4-core GitHub-hosted Linux larger runner at 4 vCPU and 16GB lists at $0.012 per minute, a 33 percent lower list price, so the same 3,000 minutes would be $36.00\. GitHub publishes those rates in the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. ### [The minimal configuration](#the-minimal-configuration)Copy Anchor Link Create a builder profile in the dashboard, then swap the build action. The WarpBuild action is a drop-in replacement for `docker/build-push-action`, and the `docker/setup-buildx-action` step comes out when its only purpose was setting up a builder. Copy Text ``` name: build-image on: push: branches: [main] pull_request: jobs: image: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/api:${{ github.sha }} profile-name: "api-builder" timeout: 600000 ``` `timeout` is the wait in milliseconds for the builder to become ready and defaults to 10 minutes. On a runner WarpBuild does not operate, add `api-key: ${{ secrets.WARPBUILD_API_KEY }}` so the action can request a builder assignment. Teams that use Bake swap `docker/bake-action` for `Warpbuilds/bake-action` with the same `profile-name` input, and teams that need custom build steps use `Warpbuilds/docker-configure@v1`, which sets up the builder and outputs its connection details for later steps. One line usually comes out at the same time. With a remote builder, `cache-to` and `cache-from` are not required, because the builder holds the layers on its own disk and reuses them for later builds. Leaving a registry or Actions cache export in place means paying to move layers that the builder already has. The [caching documentation](/docs/ci/features/caching) covers the separate WarpBuild cache for dependency directories and build artifacts, which is a different mechanism and stays useful alongside a builder. For multi-architecture images, enable both architectures on the profile and pass the platforms: Copy Text ``` - name: Build and push multi-arch uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ghcr.io/acme/api:${{ github.sha }} profile-name: "api-builder-multi" ``` Each architecture runs on its own builder instance, so a multi-arch build opens two sessions on the profile, one per architecture, and both are billed independently until the post-action steps finish. A profile missing an architecture that a build requests produces an `exec format error`, which is the first thing to check when an arm64 build fails on a profile that was previously amd64 only. ### [Cache lifetime and resets](#cache-lifetime-and-resets)Copy Anchor Link The builder cache has a TTL of 10 days. A profile that goes unused for longer than that is reset automatically, and the next build repopulates it. Daily use keeps a profile warm indefinitely. Manual resets exist for the case where the cache holds something wrong, such as layers built from a base image that was later republished under the same tag. Fetch the profile ID from `GET /api/v1/builder-profiles`, then post to `/api/v1/builder-profiles/{id}/cache/reset`. The next build runs cold. One more behavior belongs here. The cache shared between concurrent builds is eventually consistent, so a layer produced by one in-flight build may not be visible to another build running at the same moment, and it becomes available to later builds after synchronization. Two jobs starting simultaneously on a cold profile can therefore each build the same base stage once. Warming a profile with a single build before a wide fan-out avoids that duplicate work. ### [Where the runner still matters](#where-the-runner-still-matters)Copy Anchor Link The builder does the image work, and the runner still does checkout, test steps, and anything else in the job. Both are billed, on two separate lines, because they are two separate resources. A job that spends 2 minutes on checkout and tests and 4 minutes waiting on the builder is paying runner rate for all 6 minutes and builder rate for the overlapping stretch. That makes the runner size a real choice rather than a formality. Undersizing the runner slows checkout and post-job steps that the builder cannot help with, and oversizing it pays a higher per-minute rate for time spent waiting on the builder. The [remote Docker builder catalog](/runners/docker-builders) lists every profile size with its rate, and the [setup guide](/guides/remote-docker-builders) walks through the migration from a registry-backed cache. ## [Related Questions](#related-questions)Copy Anchor Link ### [How long does the layer cache on a remote Docker builder last?](#how-long-does-the-layer-cache-on-a-remote-docker-builder-last)Copy Anchor Link The builder cache has a TTL of 10 days. A builder profile that goes unused for more than 10 days is reset automatically, and the next build after that reset runs cold and repopulates the cache on the profile disk. A profile that gets used daily keeps its layers, and you can force a reset yourself through the builder profile cache reset API. The [Docker builders documentation](/docs/ci/docker-builders) carries the reset endpoints. ### [Which architectures can one builder profile build?](#which-architectures-can-one-builder-profile-build)Copy Anchor Link A profile can be amd64, arm64, or multi-arch, and the architectures have to be enabled on the profile before a build requests them. arm64 and multi-arch profiles cap at 64 vCPU, so the 96 vCPU and 192 vCPU sizes are amd64 only. A multi-arch build runs each architecture on a separate builder instance, which produces one session per architecture, billed independently. The [builder catalog](/runners/docker-builders) shows which sizes carry which architectures. ### [What does a remote Docker builder cost?](#what-does-a-remote-docker-builder-cost)Copy Anchor Link Builders are billed per session at the rate of the profile size, from $0.06 per minute for 16 vCPU with 32GB and a 100GB disk up to $0.88 per minute for 192 vCPU with 384GB and a 2TB disk. The GitHub Actions runner is billed separately at its own per-minute rate. Every rate is on the [pricing page](/pricing). ### [Do I need a WarpBuild runner to use a remote Docker builder?](#do-i-need-a-warpbuild-runner-to-use-a-remote-docker-builder)Copy Anchor Link No. Remote Docker builders work with WarpBuild runners and with runners WarpBuild does not operate, including GitHub-hosted runners and local machines. On a runner WarpBuild does not operate, pass an API key through the `api-key` input so the action can request a builder assignment. The same API drives builders from a terminal or another platform, and [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) covers the workflow patterns. For the vendor-neutral definition of the underlying idea, see [remote build cache](/glossary/remote-build-cache). --- # What Is the Cheapest Runner Size? | WarpBuild URL: https://www.warpbuild.com/answers/what-is-the-cheapest-github-actions-runner-size Description: The 2 vCPU Linux ARM64 label at $0.003 per minute holds the lowest rate in the catalog, and here is the full ladder plus when a larger label bills less. # What Is the Cheapest Runner Size? The 2 vCPU Linux ARM64 label at $0.003 per minute holds the lowest rate in the catalog, and here is the full ladder plus when a larger label bills less. Last verified: 2026-08-13 The cheapest runner size is the 2 vCPU, 8 GB Linux ARM64 label `warp-ubuntu-latest-arm64-2x` at $0.003 per minute, the lowest rate in the catalog ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). The cheapest minute and the cheapest build are separate questions, because a job that runs twice as long on the small label bills exactly what the next size up would have billed and returns its answer later. ## [Answer](#answer)Copy Anchor Link Sorted by rate, the ladder looks like this. The last column prices a job that occupies the machine for 10 minutes. | Label | Shape | Per minute | 10 minute job | | ---------------------------- | --------------- | ---------- | ------------- | | warp-ubuntu-latest-arm64-2x | 2 vCPU, 8 GB | $0.003 | $0.030 | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | $0.040 | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | $0.060 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | $0.080 | | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | $0.012 | $0.120 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $0.160 | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016 | $0.160 | | warp-ubuntu-latest-arm64-16x | 16 vCPU, 64 GB | $0.024 | $0.240 | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | $0.320 | | warp-windows-latest-x64-8x | 8 vCPU, 32 GB | $0.032 | $0.320 | | warp-ubuntu-latest-arm64-32x | 32 vCPU, 128 GB | $0.048 | $0.480 | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064 | $0.640 | | warp-windows-latest-x64-16x | 16 vCPU, 64 GB | $0.064 | $0.640 | | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08 | $0.800 | | warp-windows-latest-x64-32x | 32 vCPU, 128 GB | $0.128 | $1.280 | | warp-macos-latest-arm64-12x | 12 vCPU, 28 GB | $0.16 | $1.600 | Every rate comes from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13\. Ubuntu 26.04 labels such as `warp-ubuntu-2604-arm64-2x` carry the same rates as their `latest` equivalents, so pinning a release changes nothing in the arithmetic. Read down the platform column and the practical answer narrows. A Windows job has no option below $0.016 per minute and a macOS job none below $0.08, so the size question only has a cheapest answer inside a single platform. Linux is where the choice is wide, and inside Linux the ARM64 line prices below the x64 line at every rung, as the table above shows. Pricing is purely usage based ([pricing page](/pricing)). There is no base subscription fee, no platform fee, and no seat fee, which means the rate column above is the whole comparison. Signup includes $10 free credits, worth 3,333 minutes on the $0.003 label. Against GitHub-hosted runners at the same shape, both 2 vCPU labels start lower. `warp-ubuntu-latest-arm64-2x` (2 vCPU, 8 GB) costs $0.003 per minute against $0.005 per minute for `ubuntu-24.04-arm` (2 vCPU, 8 GB on private repositories): 40 percent lower list price. `warp-ubuntu-latest-x64-2x` costs $0.004 per minute against $0.006 per minute for `ubuntu-latest` at the same shape: 33 percent lower list price. GitHub list prices checked on 2026-08-13 at the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Public repositories get a 4 vCPU, 16 GB GitHub-hosted shape at no charge, and no paid rate competes with free. ## [Detail](#detail)Copy Anchor Link ### [When the cheapest minute stops being the cheapest build](#when-the-cheapest-minute-stops-being-the-cheapest-build)Copy Anchor Link Each Linux rung doubles the vCPU count and doubles the rate, so cost parity sits at exactly half the duration. A larger label bills less than a smaller one only when it finishes the same work in under half the time. Here is the model on a Node test job, with the assumptions stated so you can substitute your own: 900 pull request runs a month, `npm ci` plus a suite that spreads across at most 4 workers, so scaling flattens above 4 vCPU. | Label | Rate | Assumed duration | Cost per run | Cost at 900 runs | | -------------------------- | ------ | ---------------- | ------------ | ---------------- | | warp-ubuntu-latest-x64-2x | $0.004 | 24 min | $0.096 | $86.40 | | warp-ubuntu-latest-x64-4x | $0.008 | 13 min | $0.104 | $93.60 | | warp-ubuntu-latest-x64-8x | $0.016 | 9 min | $0.144 | $129.60 | | warp-ubuntu-latest-x64-16x | $0.032 | 8 min | $0.256 | $230.40 | Price each step by the minutes it buys. Moving from 2x to 4x removes 11 minutes for $0.008 per run, which is $0.0007 per minute saved. The 8x step removes 4 more minutes for $0.04, or $0.01 per minute saved. The 16x step removes 1 minute for $0.112, or $0.112 per minute saved. Each added rung buys less time for more money, which is the shape of every workload with a serial critical path. Now change one assumption. A job that keeps every core busy, such as a compile with `-j$(nproc)` or a suite sharded inside the job, halves its duration at each rung: 40 minutes at 2x, 20 at 4x, 10 at 8x. Every row bills $0.16, so the invoice is flat and only wall clock separates them. Pick the largest label that still halves the time, and stop at the rung where it does not. Measure instead of guessing. The Reports section shows Duration P75 and P90 and Queue Time P75 and P90 for each repository, workflow, and job name combination, with filters by runner label and CSV export ([reports documentation](/docs/ci/features/reports)). Run one job on two labels for a week, compare the P75 rows, and multiply by the rate above. ### [What the $0.003 label actually holds](#what-the-0003-label-actually-holds)Copy Anchor Link The 2 vCPU ARM64 machine ships 8 GB of RAM and a 150 GB SSD. That fits lint, formatting, type checks, schema validation, docs builds, release tagging, deploy dispatch, and any job whose clock is dominated by network waits rather than compute. It fits large image builds poorly. A job that exhausts 8 GB and starts swapping runs far longer at $0.003 than it would at $0.006, and a job that gets killed for memory bills its minutes anyway and bills them again on the re-run. Cheap only stays cheap while the job completes. ### [The compatibility check before moving a job to ARM64](#the-compatibility-check-before-moving-a-job-to-arm64)Copy Anchor Link Four checks, in the order they tend to break a first attempt. 1. **Toolchain and prebuilt dependencies.** The Ubuntu 24.04 ARM64 image publishes its package list at [actions/partner-runner-images](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md). Any dependency without an aarch64 wheel or binary compiles from source at install time and adds minutes the lower rate has to cover. 2. **Container images.** Every image the job pulls needs a `linux/arm64` variant. Where one is missing, the step either fails outright or runs the amd64 image under QEMU emulation inside the same job, which is slow enough that keeping that job on an x64 label instead is usually the better call. 3. **Nested virtualization.** `/dev/kvm` and nested virtualization are unavailable on ARM64 runners, so Android emulator jobs and anything else needing KVM stay on x64 ([cloud runners documentation](/docs/ci/cloud-runners)). 4. **Work directory.** The Ubuntu 24.04 ARM64 image sets the work dir to `/runner/_work` rather than GitHub's `/home/runner/work/` ([cloud runners documentation](/docs/ci/cloud-runners)). Use `${{ github.workspace }}` in expressions and `$GITHUB_WORKSPACE` in shell steps and the difference stops mattering. Move one job first and leave the rest where it is. Copy Text ``` name: ci on: pull_request: jobs: lint: runs-on: warp-ubuntu-latest-arm64-2x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run lint - run: uname -m test: runs-on: warp-ubuntu-latest-arm64-8x needs: lint steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test ``` `uname -m` prints `aarch64` when the label resolved as expected. At 3 minutes and 900 runs a month, the lint job bills 2,700 minutes and $8.10. ### [What sits outside the per-minute rate](#what-sits-outside-the-per-minute-rate)Copy Anchor Link The label rate is the largest line on most invoices, and it is not the only one. Cache storage is $0.20 per GB-month, a cache write or restore is $0.0001 per operation, a snapshot restore is $0.04 per job, snapshot storage is $0.025 per snapshot-hour, and networking through Tailscale is $0 ([pricing page](/pricing), checked on 2026-08-13). Those numbers set their own break-even. A $0.04 snapshot restore equals about 13 minutes on the $0.003 label, so it earns its place on a job whose setup takes longer than that and wastes money on a job whose setup takes two minutes. ## [Related Questions](#related-questions)Copy Anchor Link **What is the cheapest GitHub Actions runner size?** The 2 vCPU, 8 GB Linux ARM64 label `warp-ubuntu-latest-arm64-2x` at $0.003 per minute, which is the lowest per-minute rate in the catalog. The x64 label at the same shape is $0.004, the smallest Windows label is $0.016, and the smallest macOS label is $0.08, so the cheapest size question only resolves inside one platform. Every ARM64 label, size, and rate is listed in the [Linux ARM64 runner catalog](/runners/linux-arm64). **Is a larger runner ever cheaper per build than the smallest one?** Yes, when the step up cuts wall clock by more than half. Each Linux rung doubles both the vCPU count and the rate, so 12 minutes at $0.008 on the 4 vCPU label and 6 minutes at $0.016 on the 8 vCPU label both bill $0.096\. Below 6 minutes the larger label bills less, above 6 minutes it bills more. The [larger runners cost guide](/guides/github-actions-larger-runners-cost) works the same arithmetic through every size class, and [whether fewer larger runners beat more small ones](/answers/is-it-cheaper-to-run-fewer-larger-runners) covers the matrix version of the question. **How much does the 2 vCPU label cost against a GitHub-hosted runner of the same shape?** `warp-ubuntu-latest-arm64-2x` (2 vCPU, 8 GB) costs $0.003 per minute against $0.005 per minute for `ubuntu-24.04-arm` (2 vCPU, 8 GB on private repositories): 40 percent lower list price. On x64, `warp-ubuntu-latest-x64-2x` is $0.004 against $0.006 for `ubuntu-latest`: 33 percent lower list price. GitHub list prices checked on 2026-08-13 at the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Price the ladder against your own job minutes on the [pricing page](/pricing), then confirm the duration on your own repository through the [reports documentation](/docs/ci/features/reports). Signup includes $10 free credits, with no base subscription fee, no platform fee, and no seat fee. --- # What Is the Difference Between a Job and a Step? | WarpBuild URL: https://www.warpbuild.com/answers/what-is-the-difference-between-a-job-and-a-step Description: A job gets its own runner and its own billed minutes, while steps run in order on that one machine, so runner size, parallelism, and cost are set per job. # What Is the Difference Between a Job and a Step? A job gets its own runner and its own billed minutes, while steps run in order on that one machine, so runner size, parallelism, and cost are set per job. A job is the unit that GitHub Actions schedules onto its own runner, and steps are the ordered commands that run on that one machine inside a job. That boundary is why [runs-on](/glossary/runs-on), machine size, parallelism, and billing are all decided per job, while steps only decide the order of work on a machine the job already holds. ## [Answer](#answer)Copy Anchor Link Jobs run in parallel by default and are serialized only by a `needs` edge, while the steps inside a job always run one after another on the same runner ([GitHub workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)). Everything that costs money or wall-clock time attaches to the job. | Property | Job | Step | | -------------- | ----------------------------------------------------------- | --------------------------------------------------------------- | | Machine | Gets its own runner, selected by runs-on | Runs on the runner the job already holds | | Parallelism | Runs alongside other jobs unless needs adds an edge | Runs in sequence, one at a time | | Billing | The billed unit: minutes are counted per job | No separate charge, its time is inside the job's minutes | | State | Starts clean, and the runner is destroyed when the job ends | Shares the working directory, installed tools, and $GITHUB\_ENV | | Failure | A failed job blocks every job that lists it in needs | A failed step ends the job unless continue-on-error is set | | Config surface | runs-on, strategy, container, services, environment | uses, run, with, env, if | The billing row is the one that surprises people. GitHub counts Actions minutes per job rather than per step, and rounds each job's run time up to the nearest minute ([GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked 2026-08-13). So five steps on one machine bill as one job's minutes. A job that runs `actions/checkout`, `setup-node`, `npm ci`, `npm run build`, and `npm test` for a total of 20.0 minutes on `warp-ubuntu-latest-x64-4x` at $0.008 per minute costs $0.160 per run, whatever the step count. Turning those five steps into three jobs does not divide that number, it adds runner boots and repeated setup work to it. ## [Detail](#detail)Copy Anchor Link ### [The cost of a job boundary, in minutes](#the-cost-of-a-job-boundary-in-minutes)Copy Anchor Link Take the workload above and assume these step durations: checkout 0.2 minutes, `setup-node` 0.5, `npm ci` 2.3, lint 0.8, build 4.2, and a 12.0 minute test suite. Every layout below runs the same work on `warp-ubuntu-latest-x64-4x` at $0.008 per minute ([cloud runners documentation](/docs/ci/cloud-runners), [pricing](/pricing)). | Layout | Jobs | Billed minutes per run | Cost per run | Wall clock | | --------------------------------- | ---- | ---------------------- | ------------ | ---------- | | All six steps in one job | 1 | 20.0 | $0.160 | 20.0 min | | Lint split into a parallel job | 2 | 23.0 | $0.184 | 19.2 min | | Lint, build, and four test shards | 6 | 35.0 | $0.280 | 13.2 min | Each new job repeats checkout, `setup-node`, and `npm ci`, which is 3.0 minutes of setup that the single-job layout paid once. The lint split buys 0.8 minutes of wall clock for 3.0 billed minutes. The sharded layout buys 6.8 minutes of wall clock for 15.0 billed minutes, which at 500 pull request runs a month is $60.00 more on the invoice and 3,400 minutes less waiting on results. Both trades can be correct, and both are decisions about jobs. ### [When to split steps into a second job](#when-to-split-steps-into-a-second-job)Copy Anchor Link Split when the work can run at the same time as everything else, when it needs a different machine, or when it should be retried without repeating the rest. ([cloud runners documentation](/docs/ci/cloud-runners)), so a different machine per job is a label change. Copy Text ``` name: ci on: pull_request: branches: [main] jobs: lint: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run lint build-and-test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run build - run: npm test package-macos: needs: build-and-test runs-on: warp-macos-latest-arm64-6x steps: - uses: actions/checkout@v4 - run: ./scripts/package-macos.sh ``` `lint` earns its own job because it shares no state with the build and can run on a smaller, cheaper label at the same time. `package-macos` earns one because it needs a macOS runner, which no step inside a Linux job can reach. `build` and `test` stay in one job for the opposite reason: `npm test` reads the `dist` directory that `npm run build` wrote to the same working directory. Moving `npm test` into its own job would start it on a clean runner and force an artifact upload and download to carry `dist` across the boundary, plus a second `npm ci`. The [guide to sharing data between jobs](/guides/share-data-between-github-actions-jobs) covers what that copy costs for job outputs, artifacts, and caches. ### [Reading the boundary in your own numbers](#reading-the-boundary-in-your-own-numbers)Copy Anchor Link The Reports section makes the unit explicit. In the CI billing tab, each row is a single job execution showing repository, job name, runner label, execution time, billed time, and cost, and the Jobs tab aggregates duration and queue time per unique repository, workflow, and job name combination ([reports documentation](/docs/ci/features/reports)). There is no per-step row, because there is no per-step charge. That is also the shape of the tuning loop. Sort the CI billing table by cost to find the job carrying the bill, check its duration percentiles in the Jobs tab, and then decide whether the fix is a smaller label, a bigger label, or a different job layout. The [guide to speeding up GitHub Actions](/guides/speed-up-github-actions) works through those levers in order, and every cost number on this page comes from published per-minute rates with the date they were checked. Signup includes [$10 free credits](/pricing), which is enough Linux minutes to run both layouts and compare the billed totals yourself. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does adding more steps to a GitHub Actions job cost more?](#does-adding-more-steps-to-a-github-actions-job-cost-more)Copy Anchor Link Only through the minutes those steps take. A job is billed for its own run time at the rate of the runner label it uses, so five steps on one machine bill as one job's minutes rather than five separate charges. Adding a second job adds a second runner and a second billed duration, including the checkout and dependency install that job has to repeat. Current per-minute rates for every label are on the [pricing page](/pricing). ### [When should I split GitHub Actions steps into a separate job?](#when-should-i-split-github-actions-steps-into-a-separate-job)Copy Anchor Link Split when the steps can run at the same time as the rest of the workflow, when they need a different runner label or operating system, or when they should be retried on their own. Keep steps together when each one depends on files the previous step wrote, because a second job starts on a clean runner and has to copy that state back through artifacts, outputs, or a cache. The [guide to speeding up GitHub Actions](/guides/speed-up-github-actions) covers the parallelism side, and [choosing a runner size](/answers/how-do-i-choose-a-github-actions-runner-size) covers the label each of those jobs should carry. ### [Do steps in a GitHub Actions job share the same filesystem?](#do-steps-in-a-github-actions-job-share-the-same-filesystem)Copy Anchor Link Yes. Every step in a job runs on the runner that job was assigned, so the working directory, installed tools, environment written to `$GITHUB_ENV`, and Docker daemon state all carry from one step to the next. Nothing survives the end of the job, since the runner is torn down once the last step finishes. The mechanisms for moving state past that boundary are in the [guide to sharing data between GitHub Actions jobs](/guides/share-data-between-github-actions-jobs). --- # What Is the Difference Between cache-from and cache-to? | WarpBuild URL: https://www.warpbuild.com/answers/what-is-the-difference-between-cache-from-and-cache-to Description: cache-from tells a Docker build where to read cache and cache-to where to write it. Set one without the other and the build reads a cache nothing refreshes. # What Is the Difference Between cache-from and cache-to? cache-from tells a Docker build where to read cache and cache-to where to write it. Set one without the other and the build reads a cache nothing refreshes. `cache-from` is the import side of a Docker build and `cache-to` is the export side: the first tells BuildKit where to read an existing layer cache, the second tells it where to write the cache the build produces. A build that sets only `cache-from` imports whatever already exists and publishes nothing, so the workflow reads as correctly configured while the cache it depends on goes stale and eventually disappears. ## [Answer](#answer)Copy Anchor Link Both flags take the same backend syntax, and neither one implies the other. `--cache-from` and `--cache-to` are documented as separate options on `docker buildx build`, each accepting a comma-separated backend specification, in the [buildx build reference](https://docs.docker.com/reference/cli/docker/buildx/build/), checked on 2026-08-13\. The equivalent inputs on [docker/build-push-action](https://github.com/docker/build-push-action) carry the same names and the same values. Four configurations show up in real workflows, and three of them are broken: * **Both flags, same reference.** The build reads the cache written by the previous run and writes a new one over it. This is the working configuration. * **`cache-from` only.** The import succeeds for as long as something at that reference exists, and nothing refreshes it. GitHub removes cache entries that have not been accessed in 7 days and evicts the least recently used entries once a repository passes its 10 GB cache allowance, per the [GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13\. A registry reference has no such expiry, so it keeps serving the layers it held when it was last written and the hit rate decays as the Dockerfile changes. * **`cache-to` only.** Every build pays the export and no build reads the result. * **Both flags, different references.** The build reads reference A and writes reference B, which is what a copy-paste edit to one line produces. The job log shows a successful export and a cold build on every run. The backend value is where most of the behavior lives. These are the common ones, from the [cache storage backends reference](https://docs.docker.com/build/cache/backends/), checked on 2026-08-13: | Backend value | Valid on | Where the cache lives | What the next run sees | | --------------------------------- | ------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | type=inline | cache-to only | Embedded in the image config that the build pushes | Read it back with cache-from type=registry,ref=. Min mode only | | type=registry,ref= | both | A separate tag in a container registry | Any builder with pull access to that registry, on any machine | | type=gha | both | The GitHub Actions cache service for the repository | The same branch and its base branch, under GitHub's cache scope rules and the 10 GB repository allowance | | type=local,dest= / src= | both | A directory on the runner disk | Nothing, unless a separate cache action carries the directory between jobs | | type=s3 / type=azblob | both | A bucket in your own cloud account | Whatever your bucket retention policy leaves in place | Two asymmetries in that table cause most of the confusion. `type=inline` is an export format and has no import counterpart, so the import line for an inline cache names the image tag through the registry backend. And `type=local` uses `dest=` when it writes and `src=` when it reads, so the two lines are not copies of each other. One prerequisite sits under all of it. Docker documents the default `docker` driver as supporting inline cache export only, so a workflow that wants the registry, gha, local, or s3 backend has to build on a `docker-container`, `kubernetes`, or `remote` builder, per the [builder drivers reference](https://docs.docker.com/build/builders/drivers/), checked on 2026-08-13\. That is what `docker/setup-buildx-action` creates when a workflow adds it. ## [Detail](#detail)Copy Anchor Link ### [mode=min and mode=max](#modemin-and-modemax)Copy Anchor Link `mode` is a parameter on `cache-to`, and it decides how much of the build gets exported. The default `mode=min` exports the layers of the final stage of the resulting image. `mode=max` exports the layers of every stage, including build stages whose output is copied forward and then discarded, per the [cache storage backends reference](https://docs.docker.com/build/cache/backends/), checked on 2026-08-13. For a single-stage Dockerfile the two modes are close to identical. For the multi-stage pattern that most application images use, the difference is the entire point of the cache. A Dockerfile that compiles in a `builder` stage and then copies one binary into a small runtime image exports almost nothing useful under `mode=min`, because the expensive stage never appears in the final image. The next build re-runs the compile, the job log shows a cache export that completed successfully, and the cache reference on disk is a few megabytes when the build stage was gigabytes. The cost of `mode=max` is transfer volume. Every intermediate layer is written on export and is a candidate for download on import, so the amount of data moved scales with the number of build stages instead of just the size of the final image. The worked example under What the two lines cost turns that into a per-build and per-month figure for one registry cache size. ### [Setting both flags against the same reference](#setting-both-flags-against-the-same-reference)Copy Anchor Link This is the working shape for a registry cache on GitHub Actions. The cache reference is separate from the image tags, the pull request reads the branch cache first and falls back to the default branch cache, and the export uses `mode=max` so the build stage is included. Copy Text ``` name: docker on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . push: ${{ github.event_name == 'push' }} tags: ghcr.io/${{ github.repository }}:${{ github.sha }} cache-from: | type=registry,ref=ghcr.io/${{ github.repository }}:cache-${{ github.ref_name }} type=registry,ref=ghcr.io/${{ github.repository }}:cache-main cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache-${{ github.ref_name }},mode=max ``` Three details in that block are worth copying. `cache-from` takes a list and the entries are tried in order, which is how a pull request gets a warm start before its own branch cache exists. The cache tag is `cache-` rather than an image tag, so cache blobs never end up in the tag list your deploys read. And `cache-to` names the branch reference, which means a pull request writes its own cache and leaves the default branch cache alone. Add `ignore-error=true` to the `cache-to` value when a failed export should leave the job green, for example when the registry credentials on a fork pull request cannot write. ### [The remote builder alternative](#the-remote-builder-alternative)Copy Anchor Link The other way to answer the question is to stop moving the cache. A remote Docker builder keeps the layer cache on the builder's own disk, so the import and export lines leave the workflow entirely: Copy Text ``` - - uses: docker/setup-buildx-action@v3 - - - uses: docker/build-push-action@v6 + - uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} - cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:cache-main - cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache-main,mode=max + profile-name: "super-fast-builder" ``` With Docker Builders the `cache-to` and `cache-from` options are not required, because a cached builder keeps the layers and reuses them for later builds. The [Docker builders documentation](/docs/ci/docker-builders) is the reference for every action input, and the builder cache has a 10 day TTL, so a profile that goes unused for more than 10 days is reset. For dependency caching that has nothing to do with Docker layers, the [WarpBuild caching documentation](/docs/ci/features/caching) covers the drop-in replacement for `actions/cache`. ### [What the two lines cost](#what-the-two-lines-cost)Copy Anchor Link Import and export are runner minutes, and they are the reason the flags are worth thinking about at all. The assumptions here are stated so you can substitute your own from your job log: a 3 GB `mode=max` registry cache that takes 1 minute to import and 1.5 minutes to export, 500 builds per month, on `warp-ubuntu-latest-x64-8x` at $0.016 per minute from the [pricing page](/pricing), checked on 2026-08-13. | Line | Minutes per build | Minutes per month | Cost per month | | --------------------- | ----------------- | ----------------- | -------------- | | Cache import | 1.0 | 500 | $8.00 | | Cache export | 1.5 | 750 | $12.00 | | Cache transfer, total | 2.5 | 1,250 | $20.00 | A remote builder replaces both lines with builder session minutes. The 16 vCPU, 32 GB, 100 GB disk profile is $0.06 per minute, billed per session from when the builder action starts until the job completes, and concurrent jobs on the same profile share one session, from the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13\. The runner is billed separately from the builder, and the builder size is independent of the runner size. Linux runners are the usual host for a Docker build job. The [Docker builds on GitHub Actions hub](/solutions/docker-builds-github-actions) covers the rest of the pipeline, and the [buildx cache backends guide](/guides/buildx-cache-backends-github-actions) works through which backend fits a given repository. ## [Related Questions](#related-questions)Copy Anchor Link ### [What happens if I set cache-from but not cache-to?](#what-happens-if-i-set-cache-from-but-not-cache-to)Copy Anchor Link The build imports whatever cache already sits at that reference and exports nothing, so the reference stops being refreshed while the Dockerfile and the lockfiles move on. On `type=gha` the entry disappears once GitHub evicts it under the 7 day access window or the 10 GB repository allowance, per the [GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching). On `type=registry` the tag keeps serving layers from whenever it was last written, so the hit rate falls run by run with no error in the job log. [Registry cache](/glossary/registry-cache) covers what that tag actually holds. ### [Does mode=max work with every cache backend?](#does-modemax-work-with-every-cache-backend)Copy Anchor Link No. Inline cache supports min mode only, because the cache metadata is embedded in the image config that gets pushed, which is covered in [inline cache](/glossary/inline-cache). The registry, gha, local, s3, and azblob backends all accept `mode=max`, which exports the layers of every build stage instead of only the layers of the final stage. ### [Do I still need both flags on a remote Docker builder?](#do-i-still-need-both-flags-on-a-remote-docker-builder)Copy Anchor Link No. With WarpBuild Docker Builders the `cache-to` and `cache-from` options are not required, because a cached builder keeps the layers on its own disk and reuses them for later builds. Removing both flags removes the import step and the export step from the job. The [buildx cache backends guide](/guides/buildx-cache-backends-github-actions) compares that setup against a tuned registry cache, and the [Docker builds on GitHub Actions hub](/solutions/docker-builds-github-actions) puts it in the context of a full pipeline. --- # GitHub-Hosted vs Self-Hosted Runners | WarpBuild URL: https://www.warpbuild.com/answers/what-is-the-difference-between-github-hosted-and-self-hosted-runners Description: GitHub operates the machine for GitHub-hosted runners and you operate it for self-hosted runners. Managed runners are a third shape. Full comparison here. # GitHub-Hosted vs Self-Hosted Runners GitHub operates the machine for GitHub-hosted runners and you operate it for self-hosted runners. Managed runners are a third shape. Full comparison here. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link GitHub-hosted runners are machines GitHub provisions, patches, and bills by the minute, and self-hosted runners are machines you provision, patch, and pay for yourself before registering them against a repository, organization, or enterprise. Managed runners are a third shape: a vendor operates the fleet and registers it with GitHub through the same self-hosted registration path, so GitHub records a self-hosted runner while the vendor carries the operational work. The runner software is the same in all three cases. What changes is ownership of the machine underneath it, and every practical difference follows from that one fact: who builds the image, who holds the capacity when no job is running, what the machine is allowed to reach on the network, and which bill the minutes land on. The table below compares the three shapes on the dimensions that decide the choice. The managed column describes WarpBuild, which registers itself as a self-hosted runner in the `Default` runner group (id `1`) of your GitHub organization, per the [WarpBuild public repositories documentation](/docs/ci/public-repos). | Dimension | GitHub-hosted | Self-hosted you operate | Managed runners (WarpBuild) | | -------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Who patches images | GitHub, on its own release cadence | You, including the toolchain versions each job expects | WarpBuild, on published images | | Who pays for idle capacity | Nobody. Billing starts and stops with the job | You. Instances bill for wall-clock time whether or not a job is running | Nobody. Billing is per minute of job time | | Isolation model | Fresh virtual machine per job, destroyed afterwards | Whatever you build. Container-based fleets such as ARC share a host kernel | Fresh virtual machine per job, destroyed afterwards, per the [security documentation](/docs/ci/security) | | Network placement | GitHub's network. Private services need a tunnel or an allow list | Your network, so private endpoints resolve directly | WarpBuild's cloud, or your own cloud account and network on BYOC | | Billing shape | Per minute on your GitHub bill, with included minutes on some plans | Cloud instance hours plus storage, plus the engineering time to run the fleet | Per minute, usage based, on the WarpBuild bill | | Public repository access | Available by default | Blocked until a runner group allows public repositories | Same runner group setting applies | | Dependabot updates | Run by default | Require the self-hosted Dependabot setting and a labeled runner | Same requirement as self-hosted | ## [Detail](#detail)Copy Anchor Link ### [How GitHub routes a job to each kind](#how-github-routes-a-job-to-each-kind)Copy Anchor Link Routing is a label match. `runs-on: ubuntu-latest` sends the job to GitHub's fleet. An array such as `runs-on: [self-hosted, linux, x64]` sends it to a registered runner carrying all three labels. A managed runner is requested by the label the vendor publishes, which for WarpBuild is a `warp-` prefixed label naming the image and the size. One workflow can mix all three, which is the fastest way to see the difference on a repository you already have. Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: on_github_hosted: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - run: make test on_self_hosted: runs-on: [self-hosted, linux, x64] steps: - uses: actions/checkout@v5 - run: make test on_managed: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - run: make test ``` The three jobs run the same commands. The `on_self_hosted` job fails to start when no registered runner carries all three labels, and it queues rather than erroring, which is the most common way a self-hosted migration stalls. The `on_managed` job needs no fleet of your own, because the runner is provisioned when the job is queued and destroyed when it finishes. ### [Who patches the image](#who-patches-the-image)Copy Anchor Link GitHub-hosted images ship a fixed toolchain set that GitHub updates on its own schedule. Your workflow inherits both the contents and the timing of those updates. A self-hosted fleet moves that job to you. Every preinstalled compiler, SDK, and CLI is yours to install, version, and keep current, and every image update is a change you have to roll out across the fleet without breaking in-flight workflows. Teams usually underestimate this line item, because it is continuous work rather than a one-time build. Managed runners keep the images on the vendor. WarpBuild publishes runner images and the software they carry in the [cloud runners documentation](/docs/ci/cloud-runners), and the workflow change is the label in `runs-on`. ### [Who pays for idle capacity](#who-pays-for-idle-capacity)Copy Anchor Link This is where the cost models separate. GitHub-hosted and managed runners bill for the minutes a job actually runs. A self-hosted instance you keep warm bills for wall-clock time, so its real cost per busy minute depends on how full the machine is. The arithmetic is short. Let `P` be the hourly price of the instance from your own cloud bill and `u` be utilization, meaning busy minutes divided by wall-clock minutes. The effective cost of one busy hour on that instance is `P / u`. A `warp-ubuntu-latest-x64-4x` runner at $0.008 per minute costs $0.48 per hour of job time ([pricing page](/pricing), checked on 2026-08-13), so a self-hosted 4 vCPU instance lands below that rate once `u` clears `P / 0.48`. | Your instance price per hour | Utilization needed to match $0.48 per busy hour | | ---------------------------- | ----------------------------------------------- | | $0.10 | 21 percent | | $0.15 | 31 percent | | $0.20 | 42 percent | | $0.25 | 52 percent | | $0.30 | 63 percent | Substitute the hourly price from your own cloud bill. Two things the table leaves out usually decide the answer. The first is engineering time: image builds, autoscaling, runner upgrades, and the on-call load when the fleet stalls mid-release. The second is the shape of the demand. Pull request traffic arrives in bursts during working hours and drops overnight, so a fleet sized for the peak sits near empty for most of the month, and a fleet sized for the average queues jobs at the peak. ### [Isolation and machine reuse](#isolation-and-machine-reuse)Copy Anchor Link A GitHub-hosted runner is a fresh virtual machine per job that is destroyed when the job ends, so nothing a job writes to disk reaches the next one. A self-hosted runner reuses its machine by default. State from a previous job stays on disk, which is useful for warm caches and dangerous for credentials, and it is the reason ephemeral registration exists. Container-based fleets built on ARC share a host kernel across pods, and the WarpBuild [security documentation](/docs/ci/security) is explicit that WarpBuild instead runs each job in its own virtual machine, created on demand and destroyed after the build, with an encrypted storage volume per runner. The [ephemeral runner definition](/glossary/ephemeral-runner) covers the registration modes in detail, and the [self-hosted runner definition](/glossary/self-hosted-runner) covers the neutral terminology. ### [Where the machine sits on the network](#where-the-machine-sits-on-the-network)Copy Anchor Link Self-hosted runners run inside your network, so a job can reach a private database, an internal package registry, or a service behind a VPN with no extra plumbing. That placement is the strongest argument for operating your own fleet, and it also covers data residency requirements that a shared multi-tenant fleet cannot answer. GitHub-hosted runners run in GitHub's network. Reaching a private endpoint from them means opening a path inward or tunneling outward. Managed runners can be either. WarpBuild runs jobs in its own cloud by default and in your cloud account under bring-your-own-cloud, which keeps the network boundary yours while the vendor keeps the operational work. [Hosted runners versus bring-your-own-cloud runners](/guides/hosted-vs-byoc-runners) walks through the tradeoff, and [can GitHub Actions runners run in my own cloud](/answers/can-github-actions-runners-run-in-my-own-cloud) answers the narrower question. ### [Public repositories and Dependabot](#public-repositories-and-dependabot)Copy Anchor Link Two GitHub behaviors apply to every self-hosted registration, including managed runners, and they surprise teams during migrations. Public repositories are blocked first. GitHub's runner group policy serves private repositories by default, so a workflow on a public repository never reaches your registered runners until an organization owner changes the policy. GitHub's own guidance recommends keeping self-hosted runners on private repositories, because a fork can propose a workflow change that then executes on your machine ([GitHub runner access documentation](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access), checked on 2026-08-13). For WarpBuild the fix is one checkbox. Open the organization's default runner group settings at `https://github.com/organizations/[YOUR_ORG]/settings/actions/runner-groups/1` and check `Allow public repositories`. The steps and the GitHub Enterprise variant are in the [public repositories documentation](/docs/ci/public-repos), and the isolation reasoning is in the [security documentation](/docs/ci/security). Dependabot is the second behavior. Dependabot update jobs run on GitHub-hosted runners unless an owner turns on the Dependabot on self-hosted runners setting and provides a runner carrying the `dependabot` label. The setting applies to private repositories, and when no runner with that label is available the update jobs queue indefinitely rather than failing ([GitHub Dependabot documentation](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/managing-dependabot-on-self-hosted-runners), checked on 2026-08-13). Moving your build jobs to self-hosted or managed runners therefore leaves the dependency update path where it was, which is the correct default for most teams. ### [What each shape costs to run](#what-each-shape-costs-to-run)Copy Anchor Link GitHub publishes its per-minute prices in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). A self-hosted fleet has no per-minute price at all, and its cost is the cloud bill plus the operating time modeled above. Managed runner rates are published per label. The table below is taken from the [pricing page](/pricing), checked on 2026-08-13. | Runner label | Platform | vCPU | RAM | Price per minute | | --------------------------- | ----------- | ---- | ----- | ---------------- | | warp-ubuntu-latest-x64-2x | Linux x64 | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | Linux x64 | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | Linux x64 | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-arm64-4x | Linux ARM64 | 4 | 16 GB | $0.006 | | warp-macos-latest-arm64-6x | macOS | 6 | 14 GB | $0.08 | | warp-windows-latest-x64-4x | Windows | 4 | 16 GB | $0.016 | Every cost number on this page carries its source and the date it was checked, and the same figures appear on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [Is a managed runner a self-hosted runner?](#is-a-managed-runner-a-self-hosted-runner)Copy Anchor Link To GitHub, yes. A managed runner registers through the same self-hosted registration path and appears under your organization's runner settings, so GitHub applies the self-hosted rules to it: runner group policies, the public repository setting, and the Dependabot behavior. The difference is who operates the machine underneath that registration. The [self-hosted runner definition](/glossary/self-hosted-runner) holds the neutral terminology. ### [Are GitHub-hosted runners ephemeral?](#are-github-hosted-runners-ephemeral)Copy Anchor Link Yes. GitHub creates a fresh virtual machine for each job and destroys it when the job finishes. Self-hosted runners are ephemeral only when you configure them that way, which is why a long-lived self-hosted fleet accumulates state across jobs unless you rebuild the machine between them. The [ephemeral runner definition](/glossary/ephemeral-runner) covers the registration modes and what each one leaves behind. ### [Can self-hosted runners be used on public repositories?](#can-self-hosted-runners-be-used-on-public-repositories)Copy Anchor Link Only after a runner group allows it. By default a runner group serves private repositories, so jobs on public repositories never reach your self-hosted runners until an organization owner changes that policy. GitHub also recommends against the combination, because a fork can propose a workflow change that then runs on your machine. The [public repositories documentation](/docs/ci/public-repos) has the exact setting and the isolation model that makes the combination safe on WarpBuild runners. ### [Should I run hosted runners or runners in my own cloud?](#should-i-run-hosted-runners-or-runners-in-my-own-cloud)Copy Anchor Link Hosted runners remove the capacity and image work and bill per job minute. Runners in your own cloud keep every job inside your account and network boundary, which matters for data residency and for reaching private services, and they move compute onto your cloud bill. [Hosted runners versus bring-your-own-cloud runners](/guides/hosted-vs-byoc-runners) works through the decision with the operating costs attached. --- # What Is the Smallest Windows Runner Size? | WarpBuild URL: https://www.warpbuild.com/answers/what-is-the-smallest-windows-runner-size-available Description: The smallest Windows GitHub Actions runner is 4 vCPU with 16GB of memory at $0.016 per minute. The 2 vCPU Windows tier was removed on June 8, 2026. # What Is the Smallest Windows Runner Size? The smallest Windows GitHub Actions runner is 4 vCPU with 16GB of memory at $0.016 per minute. The 2 vCPU Windows tier was removed on June 8, 2026. Last verified: 2026-08-13 The smallest Windows GitHub Actions runner size available on WarpBuild is 4 vCPU with 16GB of memory and 256GB of SSD storage, at $0.016 per minute ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). The 2 vCPU Windows tier was removed on June 8, 2026, so `warp-windows-latest-x64-4x` and its Windows Server 2025 equivalents are the entry shapes on every Windows image family ([changelog](/docs/ci/changelog)). ## [Answer](#answer)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners ([cloud runners documentation](/docs/ci/cloud-runners)). The Windows line is x86-64 only and runs four shapes, and the size multiplier in the label tracks vCPU count directly. | Size | vCPU | Memory | Storage | Per minute | Labels at this size | | ---- | ---- | ------ | --------- | ---------- | ---------------------------------------------------------------------------------------- | | 4x | 4 | 16GB | 256GB SSD | $0.016 | warp-windows-latest-x64-4x, warp-windows-2025-x64-4x, warp-windows-2025-vs2026-x64-4x | | 8x | 8 | 32GB | 256GB SSD | $0.032 | warp-windows-latest-x64-8x, warp-windows-2025-x64-8x, warp-windows-2025-vs2026-x64-8x | | 16x | 16 | 64GB | 256GB SSD | $0.064 | warp-windows-latest-x64-16x, warp-windows-2025-x64-16x, warp-windows-2025-vs2026-x64-16x | | 32x | 32 | 128GB | 256GB SSD | $0.128 | warp-windows-latest-x64-32x, warp-windows-2025-x64-32x, warp-windows-2025-vs2026-x64-32x | Every row comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13, and the [Windows runner catalog page](/runners/windows) carries the same rows with the alias column. Three labels disappeared in the June 2026 removal: `warp-windows-latest-x64-2x`, `warp-windows-2025-x64-2x`, and `warp-windows-2022-x64-2x` ([changelog, June 8, 2026](/docs/ci/changelog)). The removal covered all three image families at once, so a job that used to run on a 2 vCPU Windows machine moves up to 4x or larger. Storage holds at 256GB of SSD across the current range, from 4x through 32x, so the disk a build sees does not change when a job moves up a size. Picking the entry size is one line of YAML: Copy Text ``` name: windows-tests on: pull_request: jobs: test: runs-on: warp-windows-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 with: path: ~\AppData\Local\NuGet\v3-cache key: nuget-${{ hashFiles('**/packages.lock.json') }} - run: dotnet restore Contoso.sln - run: dotnet test Contoso.sln --configuration Release ``` Rates for every platform are on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [What the entry size costs against the GitHub-hosted equivalent](#what-the-entry-size-costs-against-the-github-hosted-equivalent)Copy Anchor Link `warp-windows-latest-x64-4x` (4 vCPU, 16 GB) costs $0.016 per minute against $0.022 per minute for the 4-core Windows larger runner (4 vCPU, 16 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13 in the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), with shapes from the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). At a round monthly volume, 8,000 Windows minutes at the entry size is $128.00 on WarpBuild against $176.00 at the GitHub list rate, a difference of $48.00 a month on identical hardware. The arithmetic holds at each rung of the ladder, and the [larger runners cost guide](/guides/github-actions-larger-runners-cost) works through the 8x, 16x, and 32x rows the same way. ### [When 4 vCPU is enough](#when-4-vcpu-is-enough)Copy Anchor Link The entry size fits jobs whose wall-clock time is dominated by input and output rather than compute: NuGet restores, packaging and signing steps, single-project builds, smoke tests, and anything that spends most of its minutes waiting on the network. It also fits fleets that get parallelism from a job matrix, because eight 4 vCPU jobs at $0.016 each cost the same per minute as two 16 vCPU jobs at $0.064 while finishing eight units of work instead of two. Rates double with each step up the ladder, so a larger runner pays for itself only when it cuts wall-clock time by more than half. A 20 minute job at 4x costs $0.320\. The same job needs to finish in under 10 minutes at 8x to cost less, since 12 minutes at $0.032 per minute is $0.384 and 9 minutes is $0.288\. Time the job at both sizes on a branch before the change lands on the default lane. ### [When the build is memory bound rather than CPU bound](#when-the-build-is-memory-bound-rather-than-cpu-bound)Copy Anchor Link Memory scales at 4GB per vCPU across the whole Windows range, so 16GB at 4x, 32GB at 8x, 64GB at 16x, and 128GB at 32x ([cloud runners documentation](/docs/ci/cloud-runners)). Buying memory means buying vCPU with it, which is the usual reason a Windows job sits on a size larger than its core count would suggest. Three symptoms point at memory rather than cores. MSBuild with `/m` starts one node per core and each node holds its own project state, so a large solution can exhaust 16GB before it saturates 4 vCPU ([MSBuild command-line reference](https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-command-line-reference)). Test runners that fork a process per assembly multiply resident memory the same way. Native link steps and large asset pipelines allocate in single large blocks and fail outright rather than slowing down, which shows up as an exit without a useful log line. Measure before moving. CI observability records CPU and memory percentiles per job, alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger in the product surface ([cloud runners documentation](/docs/ci/cloud-runners)). A job holding a 90th percentile memory figure near 16GB on a 4x runner belongs on 8x; a job holding 6GB with CPU pinned at 100 percent belongs on 8x for a different reason and should be timed against the doubling rate first. One Windows-specific constraint shapes the choice. WarpBuild caches are not supported for Windows-based runners ([cloud runners documentation](/docs/ci/cloud-runners)), so Windows jobs use `actions/cache` in the workflow for NuGet packages and build output, as in the snippet above. Cache restore time counts as billed minutes at whatever size the job runs, which is another argument for keeping restore-heavy jobs at 4x and spending the size budget on the compile step. ## [Related Questions](#related-questions)Copy Anchor Link **What is the smallest Windows GitHub Actions runner size on WarpBuild?** 4 vCPU with 16GB of memory and 256GB of SSD storage, at $0.016 per minute. The 2 vCPU Windows runners were removed on June 8, 2026, so 4x is the entry shape in all three Windows image families ([cloud runners documentation](/docs/ci/cloud-runners)). The [Windows runner catalog](/runners/windows) lists every label at every size. **Can I still use `warp-windows-latest-x64-2x`?** No. The `warp-windows-latest-x64-2x`, `warp-windows-2025-x64-2x`, and `warp-windows-2022-x64-2x` labels were removed on June 8, 2026 ([changelog](/docs/ci/changelog)). A workflow still naming one of them needs an edit to a 4x or larger label. **Is there a smaller runner than the smallest Windows size?** Yes, on Linux. The catalog starts at 2 vCPU with 8GB on Linux ARM64 at $0.003 per minute and Linux x64 at $0.004 per minute, while the Windows floor is 4 vCPU with 16GB at $0.016 per minute ([cloud runners documentation](/docs/ci/cloud-runners)). Jobs with no Windows dependency, such as linting and documentation builds, belong on those Linux labels. The [cheapest GitHub Actions runner size answer](/answers/what-is-the-cheapest-github-actions-runner-size) compares the entry shape on each platform. Price your own Windows minutes against the ladder on the [pricing page](/pricing), then pick a label from the [Windows runner catalog](/runners/windows). --- # Which macOS Versions Do Runners Support? | WarpBuild URL: https://www.warpbuild.com/answers/what-macos-versions-do-github-actions-runners-support Description: WarpBuild macOS runners cover macOS 14, macOS 15, and macOS 26, all on ARM64 across five labels from $0.08 per minute. macOS 13 was removed June 8, 2026. # Which macOS Versions Do Runners Support? WarpBuild macOS runners cover macOS 14, macOS 15, and macOS 26, all on ARM64 across five labels from $0.08 per minute. macOS 13 was removed June 8, 2026. Last verified: 2026-08-13 WarpBuild macOS runners support three macOS versions today: macOS 14, macOS 15, and macOS 26, all on ARM64, across five `runs-on` labels. macOS 13 runners were removed on June 8, 2026, so a workflow still naming `warp-macos-13-arm64-6x` queues without a match until it moves to one of the three remaining images ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link Five labels cover the three supported versions. Every row comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | macOS version | Runner label | Shape | Storage | Per minute | Alias | | ------------- | ----------------------- | ------------- | --------- | ---------- | --------------------------- | | macOS 26 | warp-macos-26-arm64-6x | 6 vCPU, 14GB | 120GB SSD | $0.08 | none | | macOS 26 | warp-macos-26-arm64-12x | 12 vCPU, 28GB | 270GB SSD | $0.16 | none | | macOS 15 | warp-macos-15-arm64-6x | 6 vCPU, 14GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x | | macOS 15 | warp-macos-15-arm64-12x | 12 vCPU, 28GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x | | macOS 14 | warp-macos-14-arm64-6x | 6 vCPU, 14GB | 120GB SSD | $0.08 | none | Each image is built from the matching upstream GitHub runner image and carries the same tooling ([preinstalled software documentation](/docs/ci/preinstalled-software)), so the software list for a version is the published upstream list for that image. macOS 26 carries one addition: the image ships the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released ([macOS 26 tooling](/docs/ci/cloud-runners#macos-26-tooling)). The per-image toolchain breakdown is on the [Xcode versions answer](/answers/what-xcode-versions-are-available-on-github-actions-runners). Two facts constrain the version choice. The macOS line is ARM64 only, so there is no Intel image behind any of the five labels. And macOS runners cannot run Docker or nested virtualization, so container builds belong on Linux runners or on remote Docker builders while the macOS job stays with Xcode, Swift, and simulator work ([cloud runners documentation](/docs/ci/cloud-runners)). WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. ## [Detail](#detail)Copy Anchor Link ### [The cadence behind the version list](#the-cadence-behind-the-version-list)Copy Anchor Link Version support moves on a published schedule, and the [changelog](/docs/ci/changelog) carries the dated entry for every image addition, alias move, and removal. The macOS entries since the current lineup started: | Date | Change | | ----------------- | ----------------------------------------------------------------- | | November 6, 2024 | macOS 15 ARM64 added as warp-macos-15-arm64-6x | | September 2, 2025 | The macos-latest tag moved to macOS 15 from macOS 14 | | November 24, 2025 | macOS 15 12x added at 12 vCPU and 28GB, $0.16 per minute | | December 10, 2025 | macOS 26 added with 6x and 12x labels, aliases kept on macOS 15 | | February 11, 2026 | 12x storage raised to 270GB SSD from 120GB | | June 8, 2026 | macOS 13 runners removed | | June 23, 2026 | Xcode 27.0 and its simulator runtimes added to the macOS 26 image | Two patterns fall out of that list. A new macOS image arrives as a versioned label first, with the `latest` aliases left where they are, which gives a repository a window to test the new image on one job before anything else moves. And a removal is announced ahead of the date it lands: the June 8, 2026 removal followed a deprecation notice sent on May 5, 2026 ([changelog](/docs/ci/changelog)). ### [Pin a version label when the SDK matters](#pin-a-version-label-when-the-sdk-matters)Copy Anchor Link The alias `warp-macos-latest-arm64-6x` resolves to `warp-macos-15-arm64-6x` today, in sync with GitHub's `macos-latest` tag. It resolved to macOS 14 before September 2, 2025, per the dated entry in the [changelog](/docs/ci/changelog). A job on the alias therefore changes images on a date set by the image roadmap rather than by your release calendar, which is the wrong tradeoff for a build whose signing, deployment target, or simulator destination depends on a specific SDK set. Pin the versioned label on release-critical jobs and let a second matrix leg track the newer image: Copy Text ``` name: ios-ci on: pull_request: push: branches: [main] jobs: build: strategy: fail-fast: false matrix: include: - runner: warp-macos-15-arm64-6x role: release - runner: warp-macos-26-arm64-6x role: forward-check runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - name: Record the image and toolchain run: | sw_vers xcodebuild -version xcrun simctl list runtimes - name: Build and test run: | xcodebuild test \ -scheme AppScheme \ -destination "platform=iOS Simulator,name=iPhone 16 Pro" ``` `sw_vers` prints the macOS build the job actually got, which is the record you want in the log when a test starts failing on one leg and passing on the other. `fail-fast: false` keeps the pinned release leg green while the forward-check leg surfaces breakage on the newer image. ### [Rates do not vary by version](#rates-do-not-vary-by-version)Copy Anchor Link macOS pricing is set by size, so a macOS 14 minute and a macOS 26 minute cost the same at the same vCPU count: $0.08 per minute at 6 vCPU and $0.16 per minute at 12 vCPU ([cloud runners documentation](/docs/ci/cloud-runners)). Moving a job to a newer image changes the toolchain and nothing on the invoice. Against GitHub's published list price, `warp-macos-latest-arm64-6x` at $0.080 per minute with 6 vCPU and 14GB compares with $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14GB: $0.022 lower per minute, 22 percent lower list price, on a runner carrying one more vCPU and the same memory ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). A 30 minute build is $2.40 against $3.06. ## [Related Questions](#related-questions)Copy Anchor Link **Which macOS versions can a GitHub Actions job run on today?** macOS 14, macOS 15, and macOS 26, across five WarpBuild labels, all on ARM64\. macOS 13 runners were removed on June 8, 2026, so a job that still names `warp-macos-13-arm64-6x` has to move to one of the three remaining images. The full lineup with shapes and rates is on the [macOS runner hub](/runners/macos). **What does `warp-macos-latest-arm64-6x` resolve to?** macOS 15, in sync with GitHub's `macos-latest` tag. The alias moves when the latest image moves, so pin `warp-macos-15-arm64-6x` or `warp-macos-26-arm64-6x` when a release train depends on a specific SDK set. Alias moves are dated in the [changelog](/docs/ci/changelog). **Is there a macOS 27 runner image?** Not yet. The macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. The [Xcode versions answer](/answers/what-xcode-versions-are-available-on-github-actions-runners) covers what that means for a pinned `DEVELOPER_DIR` path. Pick a version label from the [macOS runner hub](/runners/macos), check the shape on the [macOS runner sizes page](/runners/macos/sizes), and price your own macOS minutes on the [pricing page](/pricing). --- # Which Metrics Does the API Expose for GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/what-metrics-does-the-api-expose-for-github-actions Description: The reports endpoints cover CI billing, Docker builder billing, cache billing, per-job percentiles, and queue timings, in JSON or CSV, from one API key. # Which Metrics Does the API Expose for GitHub Actions? The reports endpoints cover CI billing, Docker builder billing, cache billing, per-job percentiles, and queue timings, in JSON or CSV, from one API key. ## [Answer](#answer)Copy Anchor Link The API exposes the same reporting surface the dashboard renders: CI billing, Docker builder billing, cache billing, a per-job metrics report, and queue timings, served from `https://api.warpbuild.com/api/v1` with an API key that starts with `wkey-` and carries the CI scope. Every report endpoint takes `start_date` and `end_date` as required RFC3339 timestamps, sorts with `sort_by` and `sort_order`, pages with `page` and `per_page`, and accepts `format=csv`. The sections below map to the [Reports documentation](/docs/ci/features/reports), and full request and response definitions are in the [API reference](/docs/api). | Endpoint | What it returns | Page size | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- | | GET /reports/billing/ci | One row per job execution with repo, job\_name, runner\_label, stack, execution\_time, billed\_time, runner\_cost, snapshot\_cost, and total\_cost, plus a daily chart and a summary | per\_page default 50, max 200 | | GET /reports/billing/docker-builder | One row per builder session with profile, arch, duration, and cost, plus a summary carrying total\_cost and total\_sessions | per\_page default 50, max 200 | | GET /reports/billing/cache | One row per cache billing entry with type, cost, and timestamp, plus a summary splitting storage\_cost from operations\_cost | per\_page default 50, max 200 | | GET /reports/jobs | One row per repository, workflow, and job name with run\_count, success\_rate, and p75 and p90 for duration, queue time, CPU, and memory | per\_page default 10, max 50 | | GET /reports/queue-timings | One row per runner label and stack with run\_count, queue\_time\_p75, and queue\_time\_p90, plus a daily chart | per\_page default 10, max 50 | | GET /jobs/daywise-costs | One row per day with date, amount, and cumulative\_amount | Not paginated | | GET /org\_metrics/job\_runner\_recommendations | Jobs whose utilization crossed an upgrade or downgrade threshold, with the workflow, the job, and the recommended change | per\_page with page | Two constraints belong with that table. The CPU and memory percentiles in the jobs report come from runner telemetry, so they require [observability](/docs/ci/features/observability) to be enabled and show a dash for jobs without it. And every operation in the API is [annotated alpha](/docs/api#stability), with an `X-WarpBuild-API-Stability` header on every response, so pin only to documented behavior and log that header in your client. ## [Detail](#detail)Copy Anchor Link ### [Which report answers which question](#which-report-answers-which-question)Copy Anchor Link * Money by job, repository, or runner label: the CI billing report. Its rows carry `vcs_job_id` and `run_id`, which are the GitHub Actions job id and workflow run id from the [workflow jobs REST API](https://docs.github.com/en/rest/actions/workflow-jobs), checked on 2026-08-13, so billing rows join to your own run data by id. * Money for image builds and for cache: the Docker builder and cache billing reports, which are separate because they bill on session duration and on storage plus operations rather than on runner minutes. * Slow jobs: the jobs report, where `duration_p90` beside `queue_time_p90` separates a job that got slower from a job that started later. * Waiting jobs: the queue timings report, aggregated per runner label and stack rather than per job. * Burn against a budget: daywise costs, which is the smallest response of the set and the easiest to graph. ### [A worked pull of the queue timings report](#a-worked-pull-of-the-queue-timings-report)Copy Anchor Link One request, filtered to stock runner labels and sorted by the p90 column: Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/reports/queue-timings' \ -H 'Authorization: Bearer wkey-xxxx' \ -H 'Accept: application/json' \ --data-urlencode 'start_date=2026-08-04T00:00:00Z' \ --data-urlencode 'end_date=2026-08-11T00:00:00Z' \ --data-urlencode 'categories=stock' \ --data-urlencode 'sort_by=queue_time_p90' \ --data-urlencode 'sort_order=desc' \ --data-urlencode 'per_page=50' ``` The response has four top-level keys: `organization_id`, `chart`, `table`, and `available_filters`. Read them in that order. Copy Text ``` { "chart": { "bucket_interval_seconds": 86400, "daily": [{ "date": "2026-08-04", "job_count": 812, "total_seconds": 9744 }] }, "table": { "items": [ { "runner_label": "warp-ubuntu-latest-x64-8x", "stack": "hosted-us", "stack_kind": "warp", "run_count": 1240, "queue_time_p75": 9.4, "queue_time_p90": 21.8 } ], "page": 1, "per_page": 50, "total_pages": 1, "total_rows": 6 }, "available_filters": { "runner_labels": ["warp-ubuntu-latest-x64-8x"], "categories": ["stock", "custom-warp", "custom-byoc"], "stack_ids": [] } } ``` `chart.daily` gives one point per day with `date`, `job_count`, and `total_seconds`, so average wait for a day is `total_seconds / job_count`. In the point above that is 9744 / 812 = 12.0 seconds. `table.items` is where the label-level answer lives: `queue_time_p75` and `queue_time_p90` are per runner label and stack, so a single label with a high p90 next to a healthy fleet points at that label rather than at the platform. `stack_kind` separates hosted runners from BYOC runners in the same table. Page with `table.next` until it stops advancing, or ask for `format=csv` and skip the loop. ### [Creating the key, and what else it unlocks](#creating-the-key-and-what-else-it-unlocks)Copy Anchor Link Four steps, per the [automation documentation](/docs/ci/api-keys/automation): 1. Open the API keys page in dashboard settings at `app.warpbuild.com/settings/api-keys`. 2. Create a key and grant the CI scope. The automation documentation recommends granting CI and Cache together when the same key also drives runner and runner image automation. 3. Copy the value at creation time. It starts with `wkey-` and is displayed once, so write it into your secret manager in the same step. 4. Send it as `Authorization: Bearer wkey-xxxx` with `Accept: application/json`. That same key reaches past reporting. The automation endpoints list stacks at `GET /stacks`, create and update runner images at `/runner-images`, and create, list, and delete runners at `/runners`, which is how teams build a custom image, run a test suite against it, and promote it without a browser. One operational note from those docs: remove unused custom runners, because job pickup times can increase when many runners are present. ### [Turning the rows into money](#turning-the-rows-into-money)Copy Anchor Link The billing reports return cost per row directly, so the arithmetic is only needed when you model a change. Rates are from the [pricing page](/pricing), checked on 2026-08-13, and billing is per minute. | Runner label | vCPU | RAM | USD per minute | | --------------------------- | ---- | ----- | -------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | | warp-windows-latest-x64-4x | 4 | 16 GB | $0.016 | Sum `billed_time` per `runner_label` from the CI billing report, convert to minutes, and multiply by the rate. A repository with 1,150 billed minutes on `warp-ubuntu-latest-x64-8x` in a week is 1,150 x $0.016 = $18.40, which should match the sum of `total_cost` over the same rows. ## [Related Questions](#related-questions)Copy Anchor Link ### [Which endpoints return GitHub Actions metrics?](#which-endpoints-return-github-actions-metrics)Copy Anchor Link Five report endpoints plus two smaller ones: `GET /reports/billing/ci`, `GET /reports/billing/docker-builder`, `GET /reports/billing/cache`, `GET /reports/jobs`, `GET /reports/queue-timings`, `GET /jobs/daywise-costs`, and `GET /org_metrics/job_runner_recommendations`. All of the report endpoints take `start_date` and `end_date` as RFC3339 timestamps and accept `format=csv`. The request and response shapes are worked through in [pulling GitHub Actions metrics through the API](/guides/github-actions-metrics-api), and [GitHub Actions observability](/solutions/github-actions-observability) is the wider view of what the platform reports across an estate. ### [Which API key scope do these endpoints need?](#which-api-key-scope-do-these-endpoints-need)Copy Anchor Link The CI scope. Create the key on the API keys page in dashboard settings, send it as an `Authorization: Bearer` header, and store the value at creation time because it is displayed once. The [automation documentation](/docs/ci/api-keys/automation) recommends granting CI and Cache together when the same key also drives runner and runner image automation. ### [Can I get the same numbers as CSV?](#can-i-get-the-same-numbers-as-csv)Copy Anchor Link Yes. Every report endpoint accepts `format=csv`, and the export carries every row matching the current filters rather than the visible page. JSON responses page instead, with `per_page` capped at 200 on the three billing reports and 50 on the jobs and queue timings reports. A scheduled pull that writes both formats is the shape of [a weekly GitHub Actions health report](/guides/weekly-ci-health-report-github-actions). ### [Why are the CPU and memory columns empty?](#why-are-the-cpu-and-memory-columns-empty)Copy Anchor Link CPU and memory percentiles in the jobs report come from runner telemetry, so they require [observability](/docs/ci/features/observability) to be enabled. Jobs without telemetry show a dash for those columns while `run_count`, `success_rate`, the duration percentiles, and the queue time percentiles still populate. If you would rather ask these questions in a chat window than in a client, see [can an AI agent query my GitHub Actions metrics?](/answers/can-an-ai-agent-query-my-github-actions-metrics). --- # Which Operating Systems Can GitHub Actions Runners Use? | WarpBuild URL: https://www.warpbuild.com/answers/what-operating-systems-can-github-actions-run Description: GitHub Actions runners cover four platforms: Linux x64, Linux ARM64, macOS, and Windows. WarpBuild ships Ubuntu, macOS, and Windows Server images for each one. # Which Operating Systems Can GitHub Actions Runners Use? GitHub Actions runners cover four platforms: Linux x64, Linux ARM64, macOS, and Windows. WarpBuild ships Ubuntu, macOS, and Windows Server images for each one. Last verified: 2026-08-13 GitHub Actions runners cover four operating system platforms: Linux on x86-64, Linux on ARM64, macOS on ARM64, and Windows on x86-64\. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners across those four platforms, with the Ubuntu release, the macOS version, and the Windows Server release selected by runner label ([WarpBuild cloud runners](/docs/ci/cloud-runners), checked on 2026-08-13). This page lists the image families that exist on each platform today, the label shape that selects them, the deprecations and removals with their dates, and which workload belongs on which platform. ## [Answer](#answer)Copy Anchor Link There are three ways to put an operating system under a GitHub Actions job, and the platform list is the same in all three. **GitHub-hosted runners.** GitHub publishes labels for Ubuntu, Windows Server, and macOS, on x86-64 and on ARM64\. The label list and the per-minute rates are on the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). **Self-hosted runners.** The runner agent ships releases for Linux x64, Linux ARM64, macOS, and Windows on [github.com/actions/runner](https://github.com/actions/runner), so a machine you own on any of those platforms can register against a repository or organization and advertise labels. You own the image, the tooling on it, and the cleanup between jobs. **Managed runners.** A provider registers ephemeral virtual machines against your GitHub organization and you pick the operating system by label. WarpBuild runs this shape, and each label encodes the platform, the image version, the architecture, and the size. Here is the coverage on the managed side. Every row comes from the [cloud runners documentation](/docs/ci/cloud-runners) and the [feature matrix](/docs/ci/feature-matrix), checked on 2026-08-13. | Platform | Architecture | Image families | Label shape | Sizes | Per minute | | ----------- | ------------ | ---------------------------------------- | ---------------------------------- | -------------------- | ---------------- | | Linux x64 | x86-64 | Ubuntu 22.04, Ubuntu 24.04, Ubuntu 26.04 | warp-ubuntu--x64- | 2, 4, 8, 16, 32 vCPU | $0.004 to $0.064 | | Linux ARM64 | ARM64 | Ubuntu 24.04, Ubuntu 26.04 | warp-ubuntu--arm64- | 2, 4, 8, 16, 32 vCPU | $0.003 to $0.048 | | macOS | ARM64 | macOS 14, macOS 15, macOS 26 | warp-macos--arm64- | 6, 12 vCPU | $0.08 to $0.16 | | Windows | x86-64 | Windows Server 2022, Windows Server 2025 | warp-windows--x64- | 4, 8, 16, 32 vCPU | $0.016 to $0.128 | Three absences in that table matter when you plan a matrix. Ubuntu 22.04 exists on x64 and has no ARM64 counterpart. Windows runners are x86-64 across every size. macOS runners are ARM64 and run on WarpBuild cloud only, so they have no bring-your-own-cloud equivalent. A job selects its operating system on one line, so a matrix that fans a build across all four platforms is a list of labels: Copy Text ``` name: cross-platform-ci on: pull_request: push: branches: [main] jobs: build: strategy: fail-fast: false matrix: include: - platform: linux-x64 runner: warp-ubuntu-latest-x64-4x - platform: linux-arm64 runner: warp-ubuntu-latest-arm64-4x - platform: macos runner: warp-macos-15-arm64-6x - platform: windows runner: warp-windows-latest-x64-4x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.24" cache: true - name: Record the platform run: | go version go env GOOS GOARCH - name: Build run: go build -o dist/ ./cmd/... - name: Test run: go test ./... - uses: actions/upload-artifact@v4 with: name: dist-${{ matrix.platform }} path: dist/ ``` `go env GOOS GOARCH` prints `linux amd64`, `linux arm64`, `darwin arm64`, and `windows amd64` on the four legs, which is worth keeping in the log while you are adding or moving labels. `fail-fast: false` keeps the other three legs running when one platform breaks. ## [Detail](#detail)Copy Anchor Link ### [Linux x64 carries the widest image set](#linux-x64-carries-the-widest-image-set)Copy Anchor Link Three Ubuntu releases and five sizes each give 15 labels. The `warp-ubuntu-latest-x64-` labels resolve to Ubuntu 24.04 and carry `warp-ubuntu-2404-x64-` aliases, so a workflow that wants a release pinned across a rollout should name the versioned label. Ubuntu 26.04 uses `warp-ubuntu-2604-x64-` and Ubuntu 22.04 uses `warp-ubuntu-2204-x64-`. Every size carries 150GB of SSD storage, 4GB of memory per vCPU, and the same tooling that GitHub-hosted images install ([cloud runners documentation](/docs/ci/cloud-runners)). Storage is ephemeral and is deleted when the runner terminates, so anything you want to keep goes to an artifact, a cache, or a registry. Two capabilities live on this platform and nowhere else in the cloud catalog. Nested virtualization and `/dev/kvm` access, which Android emulator jobs need, are enabled with the `nested-virtualization.enabled=true` dynamic label on x86-64 Linux. Cache is enabled by default on Linux runners. The full label list with per-size rates is on the [Linux x64 runner catalog](/runners/linux-x64). ### [Linux ARM64 covers two Ubuntu releases](#linux-arm64-covers-two-ubuntu-releases)Copy Anchor Link Ubuntu 24.04 and Ubuntu 26.04, five sizes each, ten labels. The `warp-ubuntu-latest-arm64-` labels resolve to Ubuntu 24.04 with `warp-ubuntu-2404-arm64-` aliases, and Ubuntu 26.04 uses `warp-ubuntu-2604-arm64-`. Two documented differences account for most first-run failures on this platform. The arm64 images for Ubuntu 24.04 set the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/` for the same instance, so any step with a hardcoded path breaks. Use `${{ github.workspace }}` in YAML and `$GITHUB_WORKSPACE` in shell steps. Workloads that require nested virtualization stay on the x86-64 line ([cloud runners documentation](/docs/ci/cloud-runners)). The Ubuntu 24.04 ARM64 images are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the package list is published at [actions/partner-runner-images](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md). Read it before you assume a language version is present, because ARM64 images and x86-64 images do not always ship the same set. The [Linux ARM64 runner catalog](/runners/linux-arm64) has the labels and the rates. ### [macOS runs on ARM64 with three images](#macos-runs-on-arm64-with-three-images)Copy Anchor Link macOS 14, macOS 15, and macOS 26, in 6 vCPU and 12 vCPU sizes, five labels in total. The `warp-macos-latest-arm64-` aliases resolve to macOS 15, in sync with GitHub's own `macos-latest` tag, so an iOS job that must stay on one image should name `warp-macos-15-arm64-6x` or `warp-macos-26-arm64-6x` directly. The macOS 26 labels ship Xcode 27.0 (build 27A266a) on top of the Xcode versions in the upstream GitHub macOS 26 image, with the iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 simulator runtimes. That addition exists while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released ([cloud runners documentation](/docs/ci/cloud-runners)). Plan a re-pin of your `DEVELOPER_DIR` path at that switch. Two limits shape how a macOS job fits into a pipeline. macOS runners do not support nested virtualization and cannot run Docker, so a workflow that builds an iOS app and then packages a container image needs two jobs on two platforms. High concurrency on the macOS fleet is arranged with support rather than assumed from the Linux and Windows behavior. Move the container half of the pipeline to a Linux runner or a remote Docker builder while the [macOS runner](/runners/macos) keeps the Xcode, Swift, and simulator work. ### [Windows covers two Server releases](#windows-covers-two-server-releases)Copy Anchor Link Windows Server 2022 sits behind `warp-windows-latest-x64-` with `warp-windows-2022-x64-` aliases, and Windows Server 2025 uses `warp-windows-2025-x64-`. Sizes run from 4 vCPU to 32 vCPU, each with 256GB of SSD storage and 4GB of memory per vCPU. A third label family exists on the same Windows Server 2025 base image. The `warp-windows-2025-vs2026-x64-` labels carry Visual Studio 2026 while the other Windows Server 2025 labels carry Visual Studio 2022\. Those labels are transitional and track GitHub's own rollout of the [Windows Server 2025 with Visual Studio 2026 image](https://github.com/actions/runner-images/issues/14016), so a .NET job that needs the new toolchain names a `vs2026` label today and can move back to the plain 2025 label once Visual Studio 2026 becomes the default. Windows images carry the same tooling as GitHub-hosted Windows runners and the same ephemeral storage rule. Cache is a Linux feature and is not available on Windows runners. The [Windows runner catalog](/runners/windows) lists every label and rate. ### [The removals and deprecations that change a label](#the-removals-and-deprecations-that-change-a-label)Copy Anchor Link Three dated changes retire labels that older workflow files still name. All three come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Change | Platform | Date | What the workflow does instead | | ---------------------------------------- | ----------- | -------------- | -------------------------------------------------------------------------------------------------------- | | arm64 images for ubuntu-22.04 deprecated | Linux ARM64 | March 31, 2025 | Move to warp-ubuntu-latest-arm64- on Ubuntu 24.04 or warp-ubuntu-2604-arm64- on Ubuntu 26.04 | | macOS 13 runners removed | macOS | June 8, 2026 | Move to a macOS 14, macOS 15, or macOS 26 label | | Windows 2 vCPU runners removed | Windows | June 8, 2026 | Use a 4 vCPU or larger Windows label | Ubuntu 22.04 stays available on the x64 side, so the first row is an architecture change rather than a release change. Cloud spot runner labels were also removed on June 8, 2026, and spot capacity now comes from bring-your-own-cloud runners on AWS, GCP, and Azure. ### [Which operating system a given job wants](#which-operating-system-a-given-job-wants)Copy Anchor Link Platform choice usually follows the toolchain rather than preference. Container image builds, most language test suites, and anything that needs `/dev/kvm` belong on Linux x64\. Builds that ship aarch64 binaries or `linux/arm64` container layers belong on Linux ARM64, where the compiler emits native instructions and the test binary you assert against is the binary you ship. Xcode builds, Swift packages, and simulator tests belong on macOS, which is the only platform that can run them. MSBuild, .NET Framework targets, and Windows-native installers belong on Windows. Mixed pipelines split by job rather than by workflow. One repository can run its unit tests on `warp-ubuntu-latest-x64-8x`, its aarch64 integration tests on `warp-ubuntu-latest-arm64-8x`, its iOS build on `warp-macos-15-arm64-6x`, and its desktop installer on `warp-windows-2025-x64-8x`, in one file, with each job priced at its own per-minute rate. ### [Operating system coverage on your own cloud](#operating-system-coverage-on-your-own-cloud)Copy Anchor Link Bring-your-own-cloud runners run inside your AWS, GCP, or Azure account and cover a subset of the same images. Linux x64 carries Ubuntu 22.04, 24.04, and 26.04 on all three clouds, and Linux ARM64 carries Ubuntu 24.04 and 26.04 on all three. Windows runners are available on AWS and Azure. macOS has no bring-your-own-cloud form and runs on WarpBuild cloud only ([feature matrix](/docs/ci/feature-matrix), checked on 2026-08-13). The billing shape differs from the cloud runners above. Bring-your-own-cloud runners carry a $0.002 per minute WarpBuild fee and the compute itself is billed by your own cloud account, which is where custom VM images, static IPs, and spot capacity come from. Both models are on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link **Which Linux distributions can a GitHub Actions runner use?** On x64 the images are Ubuntu 22.04, Ubuntu 24.04, and Ubuntu 26.04, five sizes each from 2 vCPU to 32 vCPU. On ARM64 the images are Ubuntu 24.04 and Ubuntu 26.04, also five sizes each. The `latest` labels resolve to Ubuntu 24.04 on both architectures. Labels and rates are on the [Linux x64 runner catalog](/runners/linux-x64). **Which Windows versions can GitHub Actions runners use?** Windows Server 2022 and Windows Server 2025, both on x86-64, in sizes from 4 vCPU to 32 vCPU. A transitional set of Windows Server 2025 labels carries Visual Studio 2026 while the other 2025 labels carry Visual Studio 2022\. Windows 2 vCPU runners were removed on June 8, 2026\. The [Windows runner catalog](/runners/windows) lists every label. **Which macOS versions can GitHub Actions runners use?** macOS 14, macOS 15, and macOS 26, all on ARM64, in 6 vCPU and 12 vCPU sizes. macOS 13 runners were removed on June 8, 2026, so a workflow still pinned to that image has to move to one of the three that remain. Image contents and Xcode versions are on the [macOS runner catalog](/runners/macos). **Is Ubuntu 22.04 available on ARM64 runners?** No. The arm64 images for ubuntu-22.04 were deprecated on March 31, 2025\. Ubuntu 22.04 remains available on x64 labels, and ARM64 jobs run on the Ubuntu 24.04 or Ubuntu 26.04 ARM64 labels documented on the [Linux ARM64 runner catalog](/runners/linux-arm64). Pick the label for the platform each job needs from the four catalogs above, check the image caveats in the [cloud runners documentation](/docs/ci/cloud-runners), and price the matrix against your own job minutes on the [pricing page](/pricing). --- # What Permissions Does BYOC Need in My AWS Account? | WarpBuild URL: https://www.warpbuild.com/answers/what-permissions-does-byoc-need-in-my-aws-account Description: A cloud connection creates one IAM role in your AWS account covering EC2, spot, network, storage, and CloudFormation. Runner workloads carry a separate profile. # What Permissions Does BYOC Need in My AWS Account? A cloud connection creates one IAM role in your AWS account covering EC2, spot, network, storage, and CloudFormation. Runner workloads carry a separate profile. ## [Answer](#answer)Copy Anchor Link WarpBuild BYOC on AWS needs one IAM role in your account, created by a CloudFormation template that the cloud connection page hands you. That role carries fine-grained EC2 permissions for launching and terminating runners, a spot service-linked role grant, network describe permissions, S3 and snapshot permissions for cache and logs, and CloudFormation permissions for stack upgrades, all listed in full in the [AWS configuration guide](/docs/ci/byoc/aws/config). Your build steps never assume that role. Runner permissions come from a second grant: an IAM instance profile you create and attach per runner in the Custom Runner configuration, per the [instance profile guide](/docs/ci/byoc/aws/instance-profile). The permissions available to runner instances are exactly the ones you provide through security groups and instance profiles, and WarpBuild adds none of its own to runner workloads, per the [security hardening guide](/docs/ci/byoc/aws/security). | Grant | Who uses it | What it covers | Where it is defined | | ------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------ | | Integration role warpbuild- | The WarpBuild control plane | Creating stacks, launching and terminating runner instances, writing cache and runner logs | CloudFormation template from the cloud connection page | | Instance profile ARN | The GitHub Actions job on the runner | Whatever your steps call in AWS: registry pushes, bucket reads, a deploy role | Custom Runner configuration, one profile per runner | BYOC runs on AWS, GCP, and Azure, and the role structure below is the AWS shape ([BYOC documentation](/docs/ci/byoc)). The macOS fleet is hosted by WarpBuild, so no AWS grant is involved in a macOS job. ## [Detail](#detail)Copy Anchor Link ### [The five permission contexts](#the-five-permission-contexts)Copy Anchor Link The [AWS configuration guide](/docs/ci/byoc/aws/config) organizes the role into named policy contexts. Reviewing them by purpose is faster than reading the action list top to bottom. | Policy context | Representative actions | What it supports | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | FineGrainedEC2Permissions | ec2:RunInstances, ec2:CreateFleet, ec2:RequestSpotInstances, ec2:CreateLaunchTemplate, ec2:TerminateInstances, ec2:DescribeInstanceTypes, ec2:CreateImage, ec2:CreateTags | Launching just-in-time runners, reading which instance types the region and availability zones offer, creating custom runner configurations and launch templates, describing instances by Name tag, attaching roles to runners | | SpotServiceLinkedRolePermissions | iam:CreateServiceLinkedRole, iam:AttachRolePolicy, iam:PutRolePolicy, iam:PassRole | Spot capacity through the AWS service-linked role, scoped to arn:aws:iam::\*:role/aws-service-role/spot.amazonaws.com/AWSServiceRoleForEC2Spot | | NetworkPermissions | ec2:DescribeVpcs, ec2:DescribeSubnets, ec2:DescribeRouteTables, ec2:DescribeSecurityGroups, ec2:DescribeRegions | Stack creation in import mode, where you hand WarpBuild existing network resources. Region listing is used in create mode too | | StoragePermissions | s3:PutObject, s3:GetObject, s3:ListBucket, s3:PutLifecycleConfiguration, ec2:CreateSnapshot | The runner flow with the WarpBuild cache action, pushing runner system logs, and stack creation in import mode | | CloudFormationPermissions | CloudFormation changeset actions | Connection and stack upgrades in create mode | Two details in that policy usually decide a security review. First, `iam:PassRole` appears once in the default document, inside the spot block, scoped to the single service-linked role ARN above rather than to `*`. Second, the EC2, network, and storage statements use `Resource: "*"`, which is the line most reviewers ask about, and the customization path below is the documented answer. ### [What the role creates, by name](#what-the-role-creates-by-name)Copy Anchor Link Every resource the integration role provisions follows a naming pattern, so you can write account policies and cost reports against it ([AWS configuration guide](/docs/ci/byoc/aws/config)). | Resource type | Naming pattern | | ---------------- | -------------- | | S3 buckets | warpbuild-\* | | EC2 instances | warp-\* | | EBS volumes | warp-\* | | Launch templates | tmpl-warp-\* | WarpBuild also adds a `managed-by: warpbuild` tag to provisioned resources, along with tags carrying the stack name, stack ID, runner ID, runner labels, and GitHub organization. Stack resources pick up that tagging from CloudFormation template version 1.3 in create mode, and runners are tagged in all cases. That tag is the hook for [AWS IAM tag-based access control](https://docs.aws.amazon.com/IAM/latest/UserGuide/access%5Ftags.html) if you want a boundary policy that limits the role to resources it created. ### [Narrowing the default policy](#narrowing-the-default-policy)Copy Anchor Link The configuration guide documents two customization paths. You can modify the CloudFormation template on the AWS redirect page before applying the connection role stack, or you can modify the permissions on the role after it is provisioned, at the name `warpbuild-`. Removing a context removes the feature it supports, so decide per context: * Drop the network context and import mode stops working, since selecting an existing VPC, subnets, and security group requires describing them. * Drop the spot context and spot-backed runner sets stop launching, because AWS creates spot capacity through its service-linked role. * Drop the storage context and the cache action and runner log upload paths break. * Drop the CloudFormation context and connection and stack upgrades stop applying. Treat the role as a versioned artifact. The guide states that updates may be required when new WarpBuild features need new permissions, so re-read the policy at each stack upgrade rather than assuming a hand-edited role stays complete. Teams that keep IAM in code have a path for this: Terraform support exists for BYOC on AWS, documented in the [Terraform guide](/docs/ci/api-keys/terraform). ### [The instance profile is the workload half](#the-instance-profile-is-the-workload-half)Copy Anchor Link An instance profile is configured at the runner level, under Custom Runner configuration and the Instance Profile ARN field, so a deploy runner and a unit-test runner under the same stack can carry different AWS authority. Attaching one takes a single grant on the integration role: `iam:PassRole` for the role you want attached, conditioned on the service. Copy Text ``` aws iam put-role-policy \ --role-name warpbuild- \ --policy-name PassRolePolicy \ --policy-document '{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "iam:PassRole", "Resource": "arn:aws:iam:::role/", "Condition": { "StringEquals": { "iam:PassedToService": "ec2.amazonaws.com" } } } ] }' ``` The [instance profile guide](/docs/ci/byoc/aws/instance-profile) carries the same command plus an `aws iam simulate-principal-policy` call that confirms the grant resolves before you point a runner at the profile. The integration role name is on the WarpBuild connections page in the format `warpbuild-`. Scoped examples for one bucket and one registry live on the [instance profiles page](/byoc/aws/instance-profiles), and the full policy walkthrough is on the [IAM permissions page](/byoc/aws/iam-permissions). ### [What the grant costs you](#what-the-grant-costs-you)Copy Anchor Link Nothing on the WarpBuild side changes with the size of the policy. BYOC runners bill $0.002 per runner minute to WarpBuild and the compute lands on your own cloud account ([pricing page](/pricing), checked on 2026-08-13). The rest of the AWS setup, including stacks, subnets, and custom runner sizing, is on the [BYOC on AWS page](/byoc/aws). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does WarpBuild get permissions inside my running jobs?](#does-warpbuild-get-permissions-inside-my-running-jobs)Copy Anchor Link No. The permissions available to runner instances are the ones you provide through security groups and an IAM instance profile, and WarpBuild adds none of its own to runner workloads, per the [security hardening guide](/docs/ci/byoc/aws/security). The integration role that creates stacks and launches instances is a separate grant that your build steps never assume. ### [Can I narrow the default IAM policy?](#can-i-narrow-the-default-iam-policy)Copy Anchor Link Yes. Modify the CloudFormation template on the AWS redirect page before you apply the connection role stack, or modify the role after it is created at the name `warpbuild-` ([AWS configuration guide](/docs/ci/byoc/aws/config)). The `managed-by: warpbuild` tag lets you bound access further with [AWS IAM tag-based access control](https://docs.aws.amazon.com/IAM/latest/UserGuide/access%5Ftags.html). The context-by-context breakdown of what each removal costs is on the [IAM permissions page](/byoc/aws/iam-permissions). ### [What extra permission does attaching an instance profile need?](#what-extra-permission-does-attaching-an-instance-profile-need)Copy Anchor Link One `iam:PassRole` grant on the `warpbuild-` integration role, scoped to the role ARN you want attached and conditioned on `iam:PassedToService` equal to `ec2.amazonaws.com`, per the [instance profile guide](/docs/ci/byoc/aws/instance-profile). Verify it with `aws iam simulate-principal-policy` before attaching the profile to a runner. Worked examples are on the [instance profiles page](/byoc/aws/instance-profiles). ### [Which resources does the integration role create in my account?](#which-resources-does-the-integration-role-create-in-my-account)Copy Anchor Link S3 buckets named `warpbuild-*`, EC2 instances and EBS volumes named `warp-*`, and launch templates named `tmpl-warp-*`, per the [AWS configuration guide](/docs/ci/byoc/aws/config). Every provisioned resource carries the `managed-by: warpbuild` tag, plus tags for the stack name, stack ID, runner ID, runner labels, and GitHub organization, which is what makes cost attribution in your own billing tools line up with runner labels. The [BYOC on AWS page](/byoc/aws) covers the stack objects those resources belong to. --- # Which Simulator Runtimes Ship on macOS Runners? | WarpBuild URL: https://www.warpbuild.com/answers/what-simulator-runtimes-ship-on-macos-runners Description: WarpBuild macOS 26 runners carry the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes with Xcode 27.0. Which runtimes each image holds and how to check. # Which Simulator Runtimes Ship on macOS Runners? WarpBuild macOS 26 runners carry the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes with Xcode 27.0\. Which runtimes each image holds and how to check. The macOS 26 image ships the iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 simulator runtimes alongside Xcode 27.0 (build 27A266a), and it also carries every runtime already present in the upstream GitHub macOS 26 arm64 image it is built from. The macOS 15 and macOS 14 images carry the runtimes that shipped with their own Xcode versions, so the set a test job sees is decided by the image label in `runs-on` rather than by anything in the repository. This page maps the runtimes to each macOS label, gives the command that prints the inventory from inside a job, and prices what an in-job runtime download adds to a run. ## [Answer](#answer)Copy Anchor Link A simulator runtime travels with the toolchain release that bundles it, so the question of which runtimes a runner carries reduces to which macOS image the label selects. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the macOS fleet registers five labels across three images, listed in the [cloud runners documentation](/docs/ci/cloud-runners) and re-checked on 2026-08-13. | macOS image | Labels | Simulator runtimes on the image | Published list | | ----------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | macOS 26 | warp-macos-26-arm64-6x, warp-macos-26-arm64-12x | iOS 27.0 (24A434), tvOS 27.0 (24J360), watchOS 27.0 (24R362), visionOS 27.0 (24M362), bundled with Xcode 27.0, plus the runtimes in the upstream image | [WarpBuild macOS 26 tooling](/docs/ci/cloud-runners#macos-26-tooling) and the [macos-26-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md) | | macOS 15 | warp-macos-15-arm64-6x, warp-macos-15-arm64-12x (aliases warp-macos-latest-arm64-6x, warp-macos-latest-arm64-12x) | The runtimes bundled with the Xcode versions in the upstream GitHub macOS 15 arm64 image | [macos-15-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-arm64-Readme.md) | | macOS 14 | warp-macos-14-arm64-6x | The runtimes bundled with the Xcode versions in the upstream GitHub macOS 14 arm64 image | [macos-14-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md) | Two facts about that first row are worth stating plainly. Xcode 27.0 is a WarpBuild addition on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. The four 27.0 runtimes are the ones that come with Xcode 27.0, so a job on either macOS 26 label can build against the 27.0 SDKs and boot a 27.0 simulator with no download step in the workflow. For macOS 15 and macOS 14, the upstream image readmes are the authority, because GitHub rebuilds those images on its own cadence and the bundled runtime versions move with the Xcode versions in each rebuild. The [WarpBuild preinstalled software documentation](/docs/ci/preinstalled-software) links the readme for each image and is the place to start when a version question comes up mid-incident. macOS 13 runners were removed on June 8, 2026, so no runtime answer exists for that image any more. ## [Detail](#detail)Copy Anchor Link ### [Printing the inventory from inside the job](#printing-the-inventory-from-inside-the-job)Copy Anchor Link One command answers the question on the machine that is about to run the tests: Copy Text ``` - name: Record the simulator runtimes this runner carries run: xcrun simctl list runtimes ``` Each row prints the platform, the marketing version, the build number, a runtime identifier shaped like `com.apple.CoreSimulator.SimRuntime.iOS-27-0`, and an availability field that says whether the runtime is usable. The identifier is the stable handle for `xcrun simctl create` when a job builds a device explicitly. [Simulator runtime](/glossary/simulator-runtime) covers the fields and the device-type pairing in full. Keeping that step in the workflow costs a second and turns an image update into a visible log diff instead of a test suite that quietly changes what it targets. ### [When a destination names a runtime the image does not carry](#when-a-destination-names-a-runtime-the-image-does-not-carry)Copy Anchor Link `xcodebuild` resolves the destination specifier before it compiles anything. A destination naming a runtime the machine lacks fails at that resolution step with an error that no destination matches the specifier, followed by a list of the destinations it did find. The suite never runs, and the failure arrives buried in build output rather than as a clear message about a missing runtime. Writing `OS=latest` produces the other failure mode. It resolves to the newest runtime installed on that machine, so an image update that adds a runtime retargets the whole suite with no commit. Snapshot tests recorded against the older guest system start failing, and tests that wait on permission alerts start hanging. A cheap guard makes both cases loud: Copy Text ``` - name: Fail early when the pinned runtime is missing env: SIM_RUNTIME: "27.0" run: | xcrun simctl list runtimes | grep -q "iOS ${SIM_RUNTIME}" || { echo "iOS ${SIM_RUNTIME} simulator runtime is not installed on this image" exit 1 } ``` The [iOS simulator tests guide](/guides/ios-simulator-tests-github-actions) wires this into a full `xcodebuild test` job with result bundles. ### [What adding a runtime at job time costs](#what-adding-a-runtime-at-job-time-costs)Copy Anchor Link A runtime the image does not carry can be fetched inside the job with `sudo xcodebuild -downloadPlatform iOS`, or `xcodebuild -downloadAllPlatforms` for the full set. Runtimes are multi-gigabyte downloads, so that step adds transfer minutes to every run of the job and competes for disk on a runner whose storage is ephemeral and deleted when the runner terminates. The arithmetic is per minute of added download time, at the rates on the [pricing page](/pricing) checked on 2026-08-13\. The monthly column assumes 40 pull request builds per weekday across 22 weekdays, which is 880 jobs. | Runner label | Rate per minute | Added cost per job, per download minute | Added cost per month, per download minute | | ----------------------- | --------------- | --------------------------------------- | ----------------------------------------- | | warp-macos-26-arm64-6x | $0.08 | $0.08 | $70.40 | | warp-macos-26-arm64-12x | $0.16 | $0.16 | $140.80 | | warp-macos-15-arm64-6x | $0.08 | $0.08 | $70.40 | | warp-macos-15-arm64-12x | $0.16 | $0.16 | $140.80 | | warp-macos-14-arm64-6x | $0.08 | $0.08 | $70.40 | A four-minute download on the 6 vCPU shape is therefore $281.60 a month on that workload, which is the number to weigh against pinning the job to an image that already carries the runtime. Disk is the second constraint: the 6 vCPU labels carry a 120GB SSD and the 12 vCPU labels carry 270GB, so a job that needs several runtimes resident at once belongs on the larger shape. The [macOS runner catalog](/runners/macos) carries the sizes and rates for every label, and [simulator runtimes on WarpBuild macOS runners](/runners/macos/simulator-runtimes) goes deeper on the per-image sets. ### [Keeping the set stable](#keeping-the-set-stable)Copy Anchor Link Two habits keep the answer stable over time. Pin the image label rather than the `latest` alias, since `warp-macos-latest-arm64-6x` points at `warp-macos-15-arm64-6x` and moves when that alias moves. Pin the runtime version in one environment variable and reference it from every destination, so the whole matrix moves in a single commit. Snapshot runners and remote Docker builders sit on the Linux side of the WarpBuild product surface, which also includes CI observability, an MCP server, and the Action Debugger. macOS runners do not support nested virtualization and cannot run Docker, so simulator work stays on the macOS labels and container builds go elsewhere. ## [Related Questions](#related-questions)Copy Anchor Link ### [Which simulator runtimes do WarpBuild macOS runners carry?](#which-simulator-runtimes-do-warpbuild-macos-runners-carry)Copy Anchor Link The macOS 26 labels `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` carry the iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 runtimes bundled with Xcode 27.0 (build 27A266a), plus every runtime already present in the upstream GitHub macOS 26 arm64 image. The macOS 15 and macOS 14 labels carry the runtimes bundled with the Xcode versions in their own upstream images. The [macOS runner catalog](/runners/macos) lists all five labels with their sizes and rates. ### [How do I check which simulator runtimes a runner has before the tests run?](#how-do-i-check-which-simulator-runtimes-a-runner-has-before-the-tests-run)Copy Anchor Link Run `xcrun simctl list runtimes` as a step in the job. Each row prints the platform, the marketing version, the build number, a runtime identifier, and an availability field, so the inventory lands in the job log and an image change shows up as a log diff. [Simulator runtime](/glossary/simulator-runtime) explains the fields and how a runtime pairs with a device type. ### [What happens when a destination names a runtime the image does not carry?](#what-happens-when-a-destination-names-a-runtime-the-image-does-not-carry)Copy Anchor Link The job fails at destination resolution, before any compilation, with an error that no destination matches the specifier. A destination written with `OS=latest` behaves differently and resolves to the newest runtime on the machine, which silently retargets the suite after an image update. The [iOS simulator tests guide](/guides/ios-simulator-tests-github-actions) shows the guard step that turns both cases into a clear failure in seconds. ### [What does downloading a simulator runtime inside a job cost?](#what-does-downloading-a-simulator-runtime-inside-a-job-cost)Copy Anchor Link Each added download minute costs $0.08 per job on the 6 vCPU macOS labels and $0.16 per job on the 12 vCPU labels, from the [pricing page](/pricing) checked on 2026-08-13, and multi-gigabyte runtimes also compete for the 120GB SSD on the 6 vCPU shape. [Simulator runtimes on WarpBuild macOS runners](/runners/macos/simulator-runtimes) lists which image already carries the runtime a matrix needs. --- # Which Windows Versions Do Runners Support? | WarpBuild URL: https://www.warpbuild.com/answers/what-windows-versions-do-github-actions-runners-support Description: WarpBuild Windows GitHub Actions runners ship Windows Server 2022 and 2025 images on x86-64, in four sizes from 4 to 32 vCPU, starting at $0.016 per minute. # Which Windows Versions Do Runners Support? WarpBuild Windows GitHub Actions runners ship Windows Server 2022 and 2025 images on x86-64, in four sizes from 4 to 32 vCPU, starting at $0.016 per minute. Last verified: 2026-08-13 WarpBuild Windows GitHub Actions runners run two Windows Server versions: Windows Server 2022, which the `warp-windows-latest-x64-` labels resolve to today, and Windows Server 2025, which has its own dated labels. Every Windows label is x86-64 and every image family runs the same four shapes, 4, 8, 16, and 32 vCPU, priced from $0.016 to $0.128 per minute ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Windows line splits into three image families across the two Windows Server versions, and the twelve labels below are the full set. | Runner label | Windows version | vCPU | Memory | Storage | Per minute | | -------------------------------- | ----------------------------- | ---- | ------ | --------- | ---------- | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16GB | 256GB SSD | $0.016 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32GB | 256GB SSD | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64GB | 256GB SSD | $0.064 | | warp-windows-latest-x64-32x | Windows Server 2022 | 32 | 128GB | 256GB SSD | $0.128 | | warp-windows-2025-x64-4x | Windows Server 2025 | 4 | 16GB | 256GB SSD | $0.016 | | warp-windows-2025-x64-8x | Windows Server 2025 | 8 | 32GB | 256GB SSD | $0.032 | | warp-windows-2025-x64-16x | Windows Server 2025 | 16 | 64GB | 256GB SSD | $0.064 | | warp-windows-2025-x64-32x | Windows Server 2025 | 32 | 128GB | 256GB SSD | $0.128 | | warp-windows-2025-vs2026-x64-4x | Windows Server 2025 (VS 2026) | 4 | 16GB | 256GB SSD | $0.016 | | warp-windows-2025-vs2026-x64-8x | Windows Server 2025 (VS 2026) | 8 | 32GB | 256GB SSD | $0.032 | | warp-windows-2025-vs2026-x64-16x | Windows Server 2025 (VS 2026) | 16 | 64GB | 256GB SSD | $0.064 | | warp-windows-2025-vs2026-x64-32x | Windows Server 2025 (VS 2026) | 32 | 128GB | 256GB SSD | $0.128 | Every row comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The [Windows runner catalog page](/runners/windows) carries the same rows with the alias column and the per-size cost models. Selecting a version is one line of YAML: Copy Text ``` name: windows-build on: push: branches: [main] pull_request: jobs: build-2022: runs-on: warp-windows-latest-x64-8x steps: - uses: actions/checkout@v4 - run: dotnet build Contoso.sln --configuration Release build-2025: runs-on: warp-windows-2025-x64-8x steps: - uses: actions/checkout@v4 - run: dotnet build Contoso.sln --configuration Release ``` Both jobs run the same steps on the same shape at the same rate. The only variable that changes between them is the Windows Server version underneath, which is what makes a two-job matrix the cheapest way to test a version move before the production lane follows. Per-minute rates for every platform are on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [What each image family carries](#what-each-image-family-carries)Copy Anchor Link The Windows version is one half of the answer and the Visual Studio generation on top of it is the other half. The three families line up like this: | Image family | Windows version | Visual Studio | Alias | Status | | ----------------------------------- | ------------------- | ------------------ | ---------------------------- | --------------------------------------- | | warp-windows-latest-x64- | Windows Server 2022 | Visual Studio 2022 | warp-windows-2022-x64- | What latest resolves to today | | warp-windows-2025-x64- | Windows Server 2025 | Visual Studio 2022 | none | Newer platform, same toolset generation | | warp-windows-2025-vs2026-x64- | Windows Server 2025 | Visual Studio 2026 | none | Transitional | Sources: the [cloud runners documentation](/docs/ci/cloud-runners) for labels and images, checked on 2026-08-13\. Toolset versions for the Windows Server 2022 image are in the [preinstalled software documentation](/docs/ci/preinstalled-software), which links to GitHub's own image readme in [actions/runner-images](https://github.com/actions/runner-images/blob/main/images/windows/Windows2022-Readme.md). ### [The latest alias and the pinned labels](#the-latest-alias-and-the-pinned-labels)Copy Anchor Link `warp-windows-latest-x64-` resolves to Windows Server 2022 today. The `warp-windows-2022-x64-` aliases point at the same machines, so a repository that wants the version held steady through a future `latest` rollover writes the dated alias in `runs-on`. Windows Server 2025 has no `latest` alias at all: `warp-windows-2025-x64-` is already a pinned label, which is why moving to it is a deliberate edit rather than something that happens under a running workflow ([cloud runners documentation](/docs/ci/cloud-runners)). ### [The vs2026 labels are transitional](#the-vs2026-labels-are-transitional)Copy Anchor Link The `warp-windows-2025-vs2026-x64-` labels use the same Windows Server 2025 base image with Visual Studio 2026 installed in place of Visual Studio 2022, while the plain `warp-windows-2025-x64-` labels stay on Visual Studio 2022 ([cloud runners documentation](/docs/ci/cloud-runners)). Visual Studio 2026 may become the default for the Windows Server 2025 runners in a later update, mirroring [GitHub's own rollout of the Windows Server 2025 with Visual Studio 2026 image](https://github.com/actions/runner-images/issues/14016). Treat the `vs2026` label as a way to prove a Visual Studio 2026 toolset on a branch or a scheduled job, and keep the production lane on a dated label until it passes. The [Visual Studio images page](/runners/windows/visual-studio-images) goes through the toolset differences step by step. ### [The 2 vCPU size was removed](#the-2-vcpu-size-was-removed)Copy Anchor Link Windows 2 vCPU runners were removed on June 8, 2026, so 4 vCPU is the smallest Windows shape in the catalog and any workflow still naming a `2x` Windows label needs an edit ([cloud runners documentation](/docs/ci/cloud-runners)). The removal applies to all three image families, so there is no Windows Server 2025 escape hatch at that size. The [smallest Windows runner size answer](/answers/what-is-the-smallest-windows-runner-size-available) covers what to move those jobs to. ### [Versions that are not in the catalog](#versions-that-are-not-in-the-catalog)Copy Anchor Link The Windows catalog carries Windows Server 2022 and Windows Server 2025 only. Windows Server 2019 has no label, and neither do the client editions of Windows. Every Windows label is x86-64 as well, so a job that needs a different architecture goes to the Linux or macOS lines ([cloud runners documentation](/docs/ci/cloud-runners)). GitHub publishes its own list of hosted Windows images in the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), which is the place to check which upstream image a workflow is being moved off. ### [What the version choice costs](#what-the-version-choice-costs)Copy Anchor Link Nothing. The rate depends only on the shape, so a Windows Server 2025 runner costs the same as the Windows Server 2022 runner of the same size. At the entry shape, `warp-windows-latest-x64-4x` (4 vCPU, 16 GB) costs $0.016 per minute against $0.022 per minute for the 4-core Windows larger runner (4 vCPU, 16 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13 in the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), with shapes from the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Worked at a round volume: 10,000 Windows minutes a month on the 4 vCPU shape is $160.00 at the WarpBuild rate against $220.00 at the GitHub list rate, a $60.00 monthly difference at identical hardware. Running the same 10,000 minutes on Windows Server 2025 instead of Windows Server 2022 changes neither number. ### [The surrounding product surface](#the-surrounding-product-surface)Copy Anchor Link Snapshot runners list Linux x64 and Linux ARM64 as their platforms ([snapshot runners documentation](https://www.warpbuild.com/docs/ci/features/snapshot-runners)), so a Windows job selects a plain `warp-windows` label and reaches for `actions/cache` in the workflow for NuGet packages and build output. ## [Related Questions](#related-questions)Copy Anchor Link **Which Windows version does `warp-windows-latest-x64` resolve to?** Windows Server 2022\. The `warp-windows-2022-x64-` aliases point at the same machines, so write the dated alias when you want the version to stay fixed through a future `latest` rollover. The [Windows runner catalog](/runners/windows) lists every alias. **Is Windows Server 2019 available?** No. The Windows catalog carries Windows Server 2022 and Windows Server 2025 images only. A workflow pinned to a 2019 image moves to `warp-windows-latest-x64-` or `warp-windows-2025-x64-`, both documented in the [cloud runners reference](/docs/ci/cloud-runners). **What is the difference between the 2025 and 2025-vs2026 labels?** The base operating system is the same Windows Server 2025 image. The `warp-windows-2025-x64-` labels carry Visual Studio 2022 and the `warp-windows-2025-vs2026-x64-` labels carry Visual Studio 2026, which makes the vs2026 labels the way to test the newer toolset before it becomes the default. All three families run the same four shapes at the same rates, from 4 vCPU with 16GB at $0.016 per minute to 32 vCPU with 128GB at $0.128 per minute, and the [Visual Studio images page](/runners/windows/visual-studio-images) has the toolset details. Pick a version from the [Windows runner catalog](/runners/windows), price it against your own Windows minutes on the [pricing page](/pricing), and check the toolset on the [Visual Studio images page](/runners/windows/visual-studio-images) before you move a build. --- # What Xcode Versions Are on GitHub Actions Runners? | WarpBuild URL: https://www.warpbuild.com/answers/what-xcode-versions-are-available-on-github-actions-runners Description: The Xcode version comes from the macOS runner image. WarpBuild macOS 26 labels ship Xcode 27.0 (build 27A266a) plus the 27.0 simulator runtimes. # What Xcode Versions Are on GitHub Actions Runners? The Xcode version comes from the macOS runner image. WarpBuild macOS 26 labels ship Xcode 27.0 (build 27A266a) plus the 27.0 simulator runtimes. Last verified: 2026-08-13 The Xcode versions a GitHub Actions job can use come from the macOS image the runner boots, so the `runs-on` label picks the toolchain set. On WarpBuild macOS runners, the macOS 26 labels ship Xcode 27.0 (build 27A266a) with the iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 simulator runtimes on top of the Xcode versions already in the upstream GitHub macOS 26 image ([macOS 26 tooling](/docs/ci/cloud-runners#macos-26-tooling), checked on 2026-08-13). ## [Answer](#answer)Copy Anchor Link Every WarpBuild macOS image is built from the matching upstream GitHub runner image and carries the same tooling ([preinstalled software documentation](/docs/ci/preinstalled-software)), so the Xcode list on a given image is the published upstream list for that image, with one addition on macOS 26. | macOS image | WarpBuild labels | Xcode on the image | Authoritative version list | | ----------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | macOS 26 | warp-macos-26-arm64-6x, warp-macos-26-arm64-12x | The Xcode versions in the upstream GitHub macOS 26 arm64 image, plus Xcode 27.0 (build 27A266a) installed by WarpBuild | [macos-26-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md) and [macOS 26 tooling](/docs/ci/cloud-runners#macos-26-tooling) | | macOS 15 | warp-macos-15-arm64-6x, warp-macos-15-arm64-12x, plus the aliases warp-macos-latest-arm64-6x and warp-macos-latest-arm64-12x | The Xcode versions in the upstream GitHub macOS 15 arm64 image | [macos-15-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-arm64-Readme.md) | | macOS 14 | warp-macos-14-arm64-6x | The Xcode 15 line, with 15.4 as the image default | [macos-14-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md) | Four simulator runtimes come bundled with the Xcode 27.0 install on macOS 26: iOS 27.0 (24A434), tvOS 27.0 (24J360), watchOS 27.0 (24R362), and visionOS 27.0 (24M362) ([macOS 26 tooling](/docs/ci/cloud-runners#macos-26-tooling)). A job on either macOS 26 label can compile against the 27.0 SDKs and boot a 27.0 simulator destination with no runtime download step in the workflow. Xcode 27.0 on macOS 26 is a WarpBuild addition on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 runner image is in beta. A dedicated macOS 27 image follows once that upstream image is released, at which point the Xcode 27.0 bundle path on the macOS 26 image stops being the place to pin, so any workflow that hardcodes it needs one edit. One label is gone from this list: macOS 13 runners were removed on June 8, 2026 ([cloud runners documentation](/docs/ci/cloud-runners)). A workflow that still names a macOS 13 label queues without a match, so move those jobs to macOS 14, macOS 15, or macOS 26. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The full macOS lineup, with sizes and rates for each of the five labels above, is on the [macOS runner hub](/runners/macos). ## [Detail](#detail)Copy Anchor Link ### [Exact patch versions live in the image readme](#exact-patch-versions-live-in-the-image-readme)Copy Anchor Link GitHub regenerates the runner image readmes on every image release, so a specific patch version such as 16.2 or 16.4 belongs there rather than on a page that refreshes on a 90 day cadence. The three readme links in the table above are the per-build inventory for each image, and the [preinstalled software documentation](/docs/ci/preinstalled-software) keeps the readme link for each WarpBuild image in one place. macOS 14 is the narrowest of the three images. The [macos-14-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md) lists the Xcode 15 line with 15.4 as the default, and WarpBuild tracks that image as published. A job that needs Xcode 16 or later moves up an image rather than reaching for a version macOS 14 no longer carries. ### [Selecting a version inside the job](#selecting-a-version-inside-the-job)Copy Anchor Link The label decides which Xcode versions exist on the machine. `DEVELOPER_DIR` decides which one the build uses. Copy Text ``` name: ios-pr on: pull_request: branches: [main] jobs: build-and-test: runs-on: warp-macos-26-arm64-6x env: DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer steps: - uses: actions/checkout@v4 - name: Record the toolchain in the job log run: | ls -1 /Applications | grep Xcode xcodebuild -version xcrun simctl list runtimes - name: Build and test run: | xcodebuild test \ -scheme AppScheme \ -destination "platform=iOS Simulator,name=iPhone 16 Pro,OS=27.0" ``` `DEVELOPER_DIR` is scoped to the job, needs no `sudo`, and applies to `xcodebuild`, `swift`, and every `xcrun` call in the steps that follow. The `ls -1 /Applications | grep Xcode` step prints the bundle names the image actually carries, which is the fastest way to confirm a pin before a compile error does it for you. Two aliases need care in Xcode-sensitive workflows. `warp-macos-latest-arm64-6x` and `warp-macos-latest-arm64-12x` resolve to the macOS 15 labels, in sync with GitHub's `macos-latest` tag, so a job on an alias moves to a different image and a different Xcode set the day the alias moves ([cloud runners documentation](/docs/ci/cloud-runners)). Pin the versioned label when the toolchain matters. ### [What the image choice costs](#what-the-image-choice-costs)Copy Anchor Link macOS rates are flat per minute by size and do not vary with the image, so a macOS 26 job and a macOS 14 job at the same vCPU count cost the same. | Runner | Shape | Per minute | 30-minute build | | ---------------------------------------------------------------------- | -------------- | ---------- | --------------- | | warp-macos-26-arm64-6x, warp-macos-15-arm64-6x, warp-macos-14-arm64-6x | 6 vCPU, 14 GB | $0.080 | $2.40 | | warp-macos-26-arm64-12x, warp-macos-15-arm64-12x | 12 vCPU, 28 GB | $0.160 | $4.80 | | Largest GitHub-hosted macOS ARM64 runner (macos\_xl) | 5 vCPU, 14 GB | $0.102 | $3.06 | WarpBuild rates come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing). The GitHub list price comes from the [GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. On the matched Apple Silicon pair, $0.080 against $0.102 is $0.022 lower per minute, 22 percent lower list price, on a runner that carries one more vCPU and the same memory (GitHub pricing, checked 2026-08-13). ## [Related Questions](#related-questions)Copy Anchor Link **Which Xcode version does a WarpBuild macOS runner give me?** The one carried by the image behind the label. macOS 26 labels carry the upstream GitHub macOS 26 Xcode set plus Xcode 27.0 (build 27A266a) with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes. macOS 15 labels carry the upstream macOS 15 set, and `warp-macos-14-arm64-6x` carries the Xcode 15 line with 15.4 as the default. The per-image breakdown is on the [Xcode image coverage page](/runners/macos/xcode-images). **How do I select a specific Xcode version inside a workflow?** Set `DEVELOPER_DIR` to the `Contents/Developer` path of the bundle you want, or run `sudo xcode-select -s` on that path before the build step. Print `xcodebuild -version` in the same job so the log records the toolchain that compiled the artifact. The [Xcode selection answer](/answers/how-do-i-select-an-xcode-version-in-github-actions) covers both forms and the failure modes of each. **Is there a macOS 27 runner image?** Not yet. Xcode 27.0 is a WarpBuild addition on top of the upstream GitHub macOS 26 image while the upstream macOS 27 image is in beta, and a dedicated macOS 27 image follows once that image is released. Pick a label on the [macOS runner hub](/runners/macos), check the shape against the [macOS runner sizes page](/runners/macos/sizes), and price your own Xcode minutes on the [pricing page](/pricing). --- # Where Do GitHub Actions Runners Run? | WarpBuild URL: https://www.warpbuild.com/answers/where-do-github-actions-runners-run Description: WarpBuild GitHub Actions runners run on ephemeral VMs in WarpBuild infrastructure in the US or EU, or inside your own AWS, GCP, or Azure account on BYOC. # Where Do GitHub Actions Runners Run? WarpBuild GitHub Actions runners run on ephemeral VMs in WarpBuild infrastructure in the US or EU, or inside your own AWS, GCP, or Azure account on BYOC. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link WarpBuild runs every GitHub Actions job on an ephemeral virtual machine, created for that job either in WarpBuild-operated infrastructure or inside your own cloud account on BYOC. US and EU are the regions WarpBuild names for the hosted fleet, and on BYOC the machine starts in whichever region of your AWS, GCP, or Azure account you pinned when the stack was created. That answer splits into three placements. A security or procurement review normally needs all three named, because the machine, the storage under it, and the bill land in different places. | Placement | Where the runner VM executes | Where caches, artifacts, and logs land | Who bills the compute | | --------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------- | | Hosted, default | WarpBuild infrastructure | WarpBuild-managed storage for that fleet | WarpBuild, per runner minute | | Hosted, region pinned | WarpBuild infrastructure in the US or EU region you asked for, on the enterprise tier | The same region as the runner | WarpBuild, per runner minute | | BYOC | An instance in your own AWS account, GCP project, or Azure subscription, in the stack region | An object storage bucket in your account, in the stack region | Your cloud provider, plus a $0.002 per minute WarpBuild fee | Every label in that catalog resolves to a machine under one of those three rows. Two boundaries stay fixed no matter which row you pick. GitHub still holds the repository, the workflow definitions, and the secrets, because a runner is a machine that polls GitHub and streams results back to it. The WarpBuild control plane still queues jobs, starts and destroys runner VMs, and meters usage, which it does from outside the runner region. The [BYOC setup documentation](/docs/ci/byoc) covers the account connection side, and the [pricing page](/pricing) carries the per-minute rate for every label. ## [Detail](#detail)Copy Anchor Link ### [The machine a job gets, and how long it lives](#the-machine-a-job-gets-and-how-long-it-lives)Copy Anchor Link Each runner runs in its own virtual machine. The VMs are created on demand and destroyed after each build, so nothing survives from one job into the next. Each runner also gets its own encrypted storage volume, created on demand and destroyed after the build, and when caching is enabled the cache is encrypted and stored in a location that only your runner can reach. WarpBuild does not access or store any build secrets; secrets live in your source code repository and reach only the runner environment. The [WarpBuild security documentation](/docs/ci/security) is the reference for all four of those controls. For a security review, that turns into four recorded facts: * **Isolation unit.** One virtual machine per job, with no shared tenancy between builds. * **Machine lifetime.** Created at job start, destroyed at job end. There is no long-lived registered runner accumulating state across pull requests. * **Storage lifetime.** The volume is encrypted, is created with the machine, and is destroyed with it. Cache storage sits behind the same boundary. * **Secret handling.** Build secrets pass from GitHub into the runner environment and are never stored by WarpBuild. Those four answers are the ones a reviewer writes down, and the [security review checklist for GitHub Actions runners](/enterprise/ci-security-review) walks the rest of the questionnaire. ### [Where each class of build data sits](#where-each-class-of-build-data-sits)Copy Anchor Link "Where does it run" is really a question about five data classes with five different holders. Naming them one at a time is what closes a review. | Data class | Where it sits | Held by | | ------------------------------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------- | | Source checkout and build workspace | The runner VM disk, destroyed with the VM | WarpBuild on hosted runners, your cloud account on BYOC | | Dependency and build cache | Encrypted cache storage for hosted runners; the stack object storage bucket on BYOC | WarpBuild on hosted runners, your cloud account on BYOC | | Workflow artifacts and job logs | The same storage as the cache, then streamed to GitHub | Same as above, plus GitHub | | Job scheduling, runner lifecycle, usage metering | The WarpBuild control plane, outside the runner region | WarpBuild | | Repository, workflow definitions, secrets | GitHub | GitHub | The fourth row is the one enterprise conversations spend the most time on, because it is the part that stays outside the region you pinned. It carries orchestration metadata: which job is queued, which label it asked for, when a VM started, when it terminated, and the counters that produce your invoice. If a GitHub IP allow list or a GitHub Enterprise Server instance restricts inbound traffic, the control plane egress IPs listed in the security documentation are what your network team needs. ### [The hosted fleet, by platform](#the-hosted-fleet-by-platform)Copy Anchor Link Hosted runners execute in WarpBuild infrastructure across four platforms. Labels, sizes, and rates below come from the WarpBuild runner catalog and the [pricing page](/pricing), checked on 2026-08-13. | Platform | Labels in the catalog | Images | Sizes | Rate per minute | | ----------- | --------------------- | ---------------------------- | -------------------- | ---------------- | | Linux x64 | 15 | Ubuntu 22.04, 24.04, 26.04 | 2, 4, 8, 16, 32 vCPU | $0.004 to $0.064 | | Linux ARM64 | 10 | Ubuntu 24.04, 26.04 | 2, 4, 8, 16, 32 vCPU | $0.003 to $0.048 | | macOS | 5 | macOS 14, 15, 26 | 6 and 12 vCPU | $0.08 and $0.16 | | Windows | 12 | Windows Server 2022 and 2025 | 4, 8, 16, 32 vCPU | $0.016 to $0.128 | On the enterprise tier, hosted cloud runners can be deployed on region-specific infrastructure so that the data you choose stays in the US or the EU region you asked for. One documented exception belongs in any residency scope: region-specific infrastructure does not apply to macOS instances, so Xcode jobs keep running on the shared macOS fleet. Teams usually resolve that by scoping the residency commitment to the repositories carrying regulated data. The full path-by-path breakdown lives on [US data residency for GitHub Actions runners](/enterprise/data-residency-us) and [EU data residency for GitHub Actions runners](/enterprise/data-residency-eu). ### [BYOC is the strongest residency answer](#byoc-is-the-strongest-residency-answer)Copy Anchor Link BYOC moves the placement decision inside a boundary you already own and already audit. The runner instance, the disk under it, and the object storage bucket that holds cache, container image layer cache, logs, and workflow artifacts are all resources in your own cloud account, in a region you selected, visible in your own console and on your own bill. Evidence for an auditor comes from your infrastructure rather than from a vendor statement. Setup is three steps in the [BYOC setup documentation](/docs/ci/byoc): connect the cloud account, which creates the role or service account WarpBuild uses; create a stack, which fixes the region, the VPC, and the object storage location; then create custom runners with the instance types, disks, and IP configuration you want. The stack name, storage location, and region cannot be changed after creation, so the region decision happens before any runner exists. That property is useful during a review, because the constraint is enforced by the stack rather than by a setting somebody can flip later. The AWS path is documented end to end at [run GitHub Actions runners in your own AWS account](/byoc/aws). One per-cloud caveat is worth stating plainly. BYOC on Azure is currently enabled for East US only, and additional regions go through [support@warpbuild.com](mailto:support@warpbuild.com). An Azure-only estate therefore has no EU BYOC placement today without that conversation, while AWS and GCP stacks can be created in an EU region directly. ### [The workflow file barely changes](#the-workflow-file-barely-changes)Copy Anchor Link Placement is configured at the account and stack level, so hosted jobs keep the same `warp-` labels wherever the fleet sits. BYOC jobs change the label, because a custom runner registers under the runner ID shown on the custom runners page, always prefixed with `warp-custom-`. .github/workflows/ci.yml Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: test: strategy: fail-fast: false matrix: runner: - warp-ubuntu-latest-x64-8x - warp-ubuntu-latest-arm64-8x - warp-windows-latest-x64-8x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - run: make test regulated-build: runs-on: warp-custom-eu-release-16x steps: - uses: actions/checkout@v4 - name: Build release artifacts run: make release ``` The `test` job runs on the hosted fleet. The `regulated-build` job runs on an instance in the account and region behind the `eu-release` stack. Naming stacks after their purpose keeps the workflow file readable as a record of where each job executes, which is what a reviewer will ask you to show. ### [What each placement costs](#what-each-placement-costs)Copy Anchor Link Rates come from the [pricing page](/pricing), checked on 2026-08-13. | Line | Hosted runner | BYOC runner | | --------------------------------------------- | ------------------------------- | --------------------------------------------------- | | warp-ubuntu-latest-x64-4x, 4 vCPU and 16 GB | $0.008 per minute | $0.002 per minute in WarpBuild fees | | warp-ubuntu-latest-arm64-4x, 4 vCPU and 16 GB | $0.006 per minute | $0.002 per minute in WarpBuild fees | | warp-windows-latest-x64-4x, 4 vCPU and 16 GB | $0.016 per minute | $0.002 per minute in WarpBuild fees | | Instance time, disks, and object storage | Included in the per-minute rate | Billed by your cloud provider at your account rates | Every cost claim here carries a number, a source, and a date, which is the standard the rest of the site holds to. For the hosted column: `warp-ubuntu-latest-x64-4x` costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner at the same 4 vCPU and 16 GB shape, which is 33 percent lower list price ([GitHub billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). For the BYOC column the arithmetic depends on your own rate card, since WarpBuild charges the $0.002 per minute fee and the instance time lands on your cloud bill. ### [Evidence a reviewer can collect](#evidence-a-reviewer-can-collect)Copy Anchor Link Request the report and the supporting security documentation there. Pair it with two artifacts you generate yourself: the stack region and bucket location from your own cloud console on BYOC, and the workflow files showing which labels each repository uses. Those three pieces answer where the runners run in a form an auditor can keep. ## [Related Questions](#related-questions)Copy Anchor Link ### [Where do WarpBuild GitHub Actions runners run?](#where-do-warpbuild-github-actions-runners-run)Copy Anchor Link On ephemeral virtual machines. Hosted runners execute in WarpBuild-operated infrastructure, and US and EU are the regions WarpBuild names publicly. BYOC runners execute on instances inside your own AWS, GCP, or Azure account, in the region you pinned when you created the stack. The [BYOC setup documentation](/docs/ci/byoc) covers the account connection and the stack. ### [Is a runner machine reused between jobs?](#is-a-runner-machine-reused-between-jobs)Copy Anchor Link No. Each runner runs in its own virtual machine, the VMs are created on demand and destroyed after each build, and each runner gets its own encrypted storage volume that is created and destroyed the same way. WarpBuild does not access or store any build secrets. The [security documentation](/docs/ci/security) states each of those controls. ### [Can I pin GitHub Actions runners to the US or the EU?](#can-i-pin-github-actions-runners-to-the-us-or-the-eu)Copy Anchor Link Yes. On the enterprise tier, hosted cloud runners can be deployed on region-specific infrastructure in the US or the EU. On BYOC the region is fixed when the stack is created and cannot be changed afterward, so the placement decision happens before any runner exists. See [US data residency for GitHub Actions runners](/enterprise/data-residency-us) and [EU data residency for GitHub Actions runners](/enterprise/data-residency-eu). ### [Which placement answers a data residency review most directly?](#which-placement-answers-a-data-residency-review-most-directly)Copy Anchor Link BYOC. The instance, the disk under it, and the object storage bucket holding caches, artifacts, and logs are all resources in your own cloud account, in a region you chose, visible in your own console and your own bill. BYOC on Azure is currently enabled for East US only. The [AWS BYOC page](/byoc/aws) walks the full setup, and the [security review checklist for GitHub Actions runners](/enterprise/ci-security-review) covers the questionnaire around it. --- # Where Is My Build Data Stored? | WarpBuild URL: https://www.warpbuild.com/answers/where-is-my-build-data-stored Description: Build work happens on the runner, on an encrypted volume destroyed with the machine. US and EU are the regions WarpBuild names, and BYOC keeps storage yours. # Where Is My Build Data Stored? Build work happens on the runner, on an encrypted volume destroyed with the machine. US and EU are the regions WarpBuild names, and BYOC keeps storage yours. Build work happens on the runner, on an encrypted volume created for that job and destroyed with the virtual machine when the build ends ([runner security documentation](/docs/ci/security)). US and EU are the regions WarpBuild names publicly for that infrastructure, and BYOC moves the runner and its storage bucket into your own cloud account. ## [Answer](#answer)Copy Anchor Link The question has one answer for the job itself and a short list of exceptions. Everything a build produces starts on the runner volume; the bytes that outlive the job are the ones a step deliberately sends somewhere else. | Data class | Where it sits during the job | Where it sits afterward | | ------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Checkout, dependency trees, compiled output | The runner's encrypted volume, inside the per-job virtual machine | Destroyed with the virtual machine after the build ([security docs](/docs/ci/security)) | | Cache entries a cache step saved | Written from the runner over HTTPS | WarpBuild cache storage on hosted runners, or the stack object storage bucket in your cloud account on BYOC ([BYOC documentation](/docs/ci/byoc)) | | Workflow artifacts and run logs | Uploaded from the runner | GitHub storage under your repository, 90 days by default ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13) | | Build secrets | Present in the runner environment for the length of the job | Nowhere on the WarpBuild side. WarpBuild does not access or store any build secrets, which stay in your source code repository ([security docs](/docs/ci/security)) | | Snapshot images | Written by the snapshot save action if you enabled snapshot runners | WarpBuild snapshot storage, covered in [what happens to runner disks after a job](/answers/what-happens-to-runner-disks-after-a-job) | The volume in row one has a published size per platform, which is the number to check when a build writes large intermediate output. The per-job disk differs across them. | Platform | Per-job disk | Example label | | -------------------- | ------------ | --------------------------- | | Ubuntu x64 and ARM64 | 150GB SSD | warp-ubuntu-latest-x64-4x | | macOS 6x | 120GB SSD | warp-macos-latest-arm64-6x | | macOS 12x | 270GB SSD | warp-macos-latest-arm64-12x | | Windows | 256GB SSD | warp-windows-latest-x64-4x | Sizes come from the [WarpBuild runner catalog](/docs/ci/cloud-runners), checked on 2026-08-13\. Every label in the table resolves to a fresh virtual machine with a fresh volume, so the disk starts empty on each run regardless of size. ## [Detail](#detail)Copy Anchor Link ### [Reading a workflow as a storage map](#reading-a-workflow-as-a-storage-map)Copy Anchor Link The clearest way to see placement is to annotate a workflow by destination. Three steps in this file write to three different places. Copy Text ``` name: build on: pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Restore dependency cache uses: WarpBuilds/cache@v1 with: path: ~/.npm key: npm-${{ hashFiles('**/package-lock.json') }} - name: Build run: npm ci && npm run build - name: Upload the build output uses: actions/upload-artifact@v4 with: name: dist path: dist/ retention-days: 7 ``` `actions/checkout` writes the repository onto the runner volume. `npm ci` and `npm run build` write `node_modules/` and `dist/` next to it. All of that is inside the per-job virtual machine and goes away when the job ends. The cache step is the first exit. It copies `~/.npm` into cache storage, which is WarpBuild cache storage on hosted runners and the object storage bucket in your own account on BYOC. The upload step is the second exit, and it does not touch WarpBuild at all: artifacts land in GitHub storage under the repository, retained 90 days by default unless `retention-days` shortens it ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). Choosing what goes into `path` is the whole control surface for the first exit. Naming a package manager download directory keeps registry archives in cache storage and keeps your working tree out of it. ### [Region control on hosted runners](#region-control-on-hosted-runners)Copy Anchor Link US and EU are the regions WarpBuild names publicly. On enterprise plans, cloud runners can be deployed on region-specific infrastructure so the data you select stays in that region, and that option does not cover macOS instances. The [US data residency page](/enterprise/data-residency-us) and the [EU data residency page](/enterprise/data-residency-eu) carry the wording a written residency clause needs. The attestation behind the storage model is SOC 2 Type 2 with three Trust Services Criteria: Security, Availability, and Confidentiality. Request the report and the supporting security documentation at [trust.warpbuild.com](https://trust.warpbuild.com/). The [security review checklist for GitHub Actions runners](/enterprise/ci-security-review) puts that evidence next to the four other questions reviewers ask in the same session. ### [The BYOC path when the data plane has to be yours](#the-byoc-path-when-the-data-plane-has-to-be-yours)Copy Anchor Link BYOC runs on AWS, GCP, and Azure. A WarpBuild Stack creates the VPC, subnets, and one object storage bucket inside your account, and the bucket holds cache, container image layer caches, workflow artifacts the platform writes, and runner logs ([BYOC documentation](/docs/ci/byoc)). Runner instances launch in the same account and terminate when the job finishes. Two properties of that setup matter for a residency answer. The stack name, object storage location, and region cannot be changed after creation, so placement is decided once, before any runner exists. And the bucket sits under your own IAM, your own encryption settings, your own access logging, and your own lifecycle policy, which is where retention becomes a decision you make rather than a setting you read. ### [What placement costs](#what-placement-costs)Copy Anchor Link A hosted `warp-ubuntu-latest-x64-4x` job bills at $0.008 per minute, and BYOC Linux and Windows runners carry a WarpBuild fee of $0.002 per minute with the compute billed by your own cloud account ([pricing page](/pricing), checked on 2026-08-13). Every cost number here carries a source link and a checked-on date, and the same numbers appear on the pricing page. ## [Related Questions](#related-questions)Copy Anchor Link **Which regions does WarpBuild name for its own runner infrastructure?** US and EU. Enterprise plans can pin hosted cloud runners to region-specific infrastructure so the data you choose stays in that region, and that option does not apply to macOS instances. Start with the [US data residency page](/enterprise/data-residency-us) or the [EU data residency page](/enterprise/data-residency-eu). **What is stored outside the runner during a normal build?** Three things. Cache entries go to WarpBuild cache storage on hosted runners or to your stack bucket on BYOC, workflow artifacts and run logs go to GitHub storage under your repository at a 90 day default ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), and snapshot images exist only if you turned snapshot runners on. **How do I keep every stored byte inside an account I control?** Run BYOC. The runner instances and the object storage bucket that holds cache, container image layer caches, and telemetry are created inside your own AWS, GCP, or Azure account, in the region you pick when you create the stack ([BYOC documentation](/docs/ci/byoc)). Hand a reviewer the [security review checklist](/enterprise/ci-security-review) alongside your own account evidence, and check per-minute rates on the [pricing page](/pricing). --- # Why Are My GitHub Actions Builds Slow? | WarpBuild URL: https://www.warpbuild.com/answers/why-are-github-actions-slow Description: GitHub Actions builds are slow for four reasons: queue wait before the job starts, cold caches, undersized runners, and emulated builds. Measure each one. # Why Are My GitHub Actions Builds Slow? GitHub Actions builds are slow for four reasons: queue wait before the job starts, cold caches, undersized runners, and emulated builds. Measure each one. Last verified: 2026-08-13 GitHub Actions builds are slow for four reasons, and they show up in this order for most teams: the job waits in the queue before it starts, the cache is cold so dependencies download and rebuild from scratch, the runner is undersized for the work, and the build emulates a second CPU architecture instead of building natively. Its Queue Timings and Jobs reports split queue wait from execution time so you can tell which of the four applies to your repository before changing a line of workflow YAML. ## [Answer](#answer)Copy Anchor Link The word "slow" collapses two different measurements. The number a developer feels is the wall clock gap between pushing a commit and seeing a green check. The number a workflow file controls is execution time, which starts when a runner picks the job up. Everything between the push and the pickup is queue wait, and queue wait is invisible in the step timings GitHub prints in the job log. Rank the four causes in this order, because that is the order in which they are cheap to check. | Rank | Cause | Symptom you see | Where to confirm it | | ---- | -------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------- | | 1 | Queue wait before the job starts | Job sits in "Queued" with no logs, step timings look normal | Queue Timings report, queue time P75 and P90 per runner label | | 2 | Cold cache | Dependency install and compile steps take the same time on every run | Cache hit or miss output in the job log, cache billing table | | 3 | Undersized runner | One long step pinned at high CPU or memory, everything else fast | Jobs report duration percentiles plus CPU and memory percentiles | | 4 | Emulated build | A multi architecture Docker build step dominates the job | Build step duration compared against a native single architecture run | Queue wait ranks first because it is the only cause that a workflow change cannot fix. If jobs are queuing behind a fixed pool of runners, a faster machine, a warmer cache, and a native build all shorten a number that nobody is waiting on. Fix the queue first, then measure again. Concurrency is the structural version of that problem. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That removes queue wait as a variable and lets the remaining three causes show up cleanly in the Jobs report. This page is the diagnosis half of the topic. Once you know which cause you have, the task by task fixes live in the [guide to speeding up GitHub Actions workflows](/guides/speed-up-github-actions). ## [Detail](#detail)Copy Anchor Link Every number below comes from one of four sources: the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), the [WarpBuild reports documentation](/docs/ci/features/reports), the WarpBuild runner catalog and per minute rates published on the [WarpBuild pricing page](/pricing), or GitHub's published list prices in the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. ### [Cause 1: queue wait before the job starts](#cause-1-queue-wait-before-the-job-starts)Copy Anchor Link A GitHub Actions job stays queued until an online runner in scope advertises every label in `runs-on`. Two things keep it there. Either no runner carries the label set, which is usually a typo, or every runner that does carry it is busy with another job. The first case never resolves and the job eventually expires. The second case resolves at a rate set by your runner pool, which is why queue wait grows superlinearly during the busy part of the day and disappears overnight. Queue wait is the cause most often misread as a slow build, because GitHub's job page shows total elapsed time and the step list underneath it shows execution time. The gap between them is queue wait, and nothing in the workflow file changes it. The [Queue Timings report](/docs/ci/features/reports) reports this directly. Each row is a unique runner label and stack combination, with run count, queue time P75, and queue time P90\. A daily bar chart shows average queue time next to total job count per day, so a queue that grows with volume looks different from a queue that grows because a label went stale. ### [Cause 2: cold caches](#cause-2-cold-caches)Copy Anchor Link A cold cache makes every run pay the full cost of dependency resolution, download, and compilation. The symptom is a dependency step whose duration barely varies between a run that changed one line and a run that changed a thousand. WarpBuild caching is enabled by default on Linux runners and is not supported on Windows runners, per the [cloud runners documentation](/docs/ci/cloud-runners). The `WarpBuilds/cache` action is a drop in replacement for `actions/cache@v4`, so the migration is one line. Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore dependency cache uses: WarpBuilds/cache@v1 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - run: npm ci - run: npm test ``` Three cache failures account for most cold cache reports. A key built from a timestamp or a commit SHA never hits. A key built from a lock file hits only when the lock file is unchanged, which is correct but means a dependency bump costs a full cold run. And a cache that is evicted between runs behaves exactly like a cache that was never written. Confirm by comparing the cache step output across two consecutive runs of the same workflow on the same branch. Cache work is billed separately from runner minutes: | Item | Rate | Unit | | ---------------------- | ------- | ----------------- | | Cache storage | $0.20 | per GB month | | Cache write or restore | $0.0001 | per operation | | Snapshot restore | $0.04 | per job | | Snapshot storage | $0.025 | per snapshot hour | | Networking (Tailscale) | $0 | per minute | Worked example. A repository holding 40 GB of cache and running 2,000 jobs per month, each job doing one restore and one write, pays 40 x $0.20 = $8.00 in storage and 2,000 x 2 x $0.0001 = $0.40 in operations, for $8.40 per month. Sizing and eviction behavior are covered in the [guide to the GitHub Actions cache size limit](/guides/github-actions-cache-size-limit). When the cold start is the machine image rather than the dependency cache, snapshot runners capture a runner VM mid workflow and boot later jobs from that snapshot. The label carries the snapshot key: Copy Text ``` jobs: integration-tests: runs-on: warp-ubuntu-latest-x64-4x;snapshot.key=my-project-snapshot steps: - uses: actions/checkout@v4 - run: ./scripts/integration-tests.sh ``` At $0.04 per snapshot restore, 2,000 jobs per month cost $80.00 in restores, plus $0.025 per snapshot hour, or $18.25 for one snapshot held for a 730 hour month. That is $98.25 per month on top of runner minutes, or about $0.049 per job. At $0.008 per minute on a 4 vCPU runner, snapshots earn their place when they remove more than about 6 minutes of setup per job. ### [Cause 3: undersized runners](#cause-3-undersized-runners)Copy Anchor Link A job is runner bound when one step pins CPU, memory, or disk throughput while wall clock time stretches. The WarpBuild [observability recommendations view](/docs/ci/features/observability) flags this with fixed thresholds: | Metric | Under provisioned threshold | Alert label | | -------------------------- | -------------------------------------------- | --------------------- | | Max sustained CPU | 80 percent or higher | High CPU Usage | | Max memory utilization | 80 percent or higher | High Memory Usage | | Max filesystem utilization | 80 percent or higher | High Filesystem Usage | | Max disk I/O | 80 percent or higher of supported throughput | High Disk IO | Resizing helps only when the work parallelizes. A single threaded test suite on a 32 vCPU runner finishes at the same wall clock as it does on a 4 vCPU runner and costs eight times as much per minute, because the Linux x64 catalog is priced linearly: | Runner label | vCPU | RAM | Storage | Per minute | Per vCPU minute | | -------------------------- | ---- | ------ | --------- | ---------- | --------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | $0.002 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | $0.002 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | $0.002 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | $0.002 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | $0.002 | The per vCPU minute column is the whole decision. Doubling cores doubles the per minute rate, so a resize is cost neutral exactly when it halves wall clock time, better than neutral when the job scales past that, and worse when it does not. Measure peak CPU in the Jobs report before you resize, and measure duration P75 after. ### [Cause 4: emulated builds](#cause-4-emulated-builds)Copy Anchor Link The fourth cause is a multi architecture Docker build running one of its architectures under emulation. A single x64 runner producing a `linux/amd64` and `linux/arm64` image builds the ARM64 half through QEMU, which executes the same compiler work through a translation layer. The build succeeds and the timings look terrible, and the job log gives no obvious signal beyond a long build step. The structural fix is native hardware for each architecture. Split the build into a matrix and let each leg run on its own architecture: Copy Text ``` name: docker on: push: branches: [main] jobs: build: strategy: fail-fast: false matrix: include: - platform: linux/amd64 runner: warp-ubuntu-latest-x64-8x - platform: linux/arm64 runner: warp-ubuntu-latest-arm64-8x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: ${{ matrix.platform }} tags: user/app:${{ github.sha }} profile-name: "release-builder" ``` Worked example with illustrative durations you should replace with your own numbers from the Jobs report. Suppose the single job emulated build takes 30 minutes on `warp-ubuntu-latest-x64-8x`. That is 30 x $0.016 = $0.48 per build, and 30 minutes of wall clock. Suppose each native leg takes 8 minutes. The x64 leg costs 8 x $0.016 = $0.128, the ARM64 leg costs 8 x $0.012 = $0.096, the pair costs $0.224 per build, and wall clock is the longer leg at 8 minutes rather than the sum. The arithmetic is what matters here, not the assumed durations: two shorter native jobs beat one long emulated job on both axes whenever emulation costs more than the ratio of the two per minute rates. Remote Docker builders take the layer cache off the ephemeral runner entirely. Each builder profile is a dedicated build VM with a persistent layer cache on local disk, and multiple jobs share one profile in parallel. Builder rates run from $0.06 to $0.88 per minute on the [pricing page](/pricing). ### [The measurement path: split queue time from execution time](#the-measurement-path-split-queue-time-from-execution-time)Copy Anchor Link This is the one procedure worth running before any workflow change. It takes about ten minutes. 1. Open the [Queue Timings report](/docs/ci/features/reports) and set the date range to the last 30 days. Record queue time P75 and P90 for each runner label your repository uses. 2. Open the Jobs report for the same range. Each row is a unique repository, workflow, and job name combination, with run count, success rate, duration P75 and P90, queue time P75 and P90, CPU P75 and P90, and memory P75 and P90. 3. Subtract. If queue time P90 is a large share of total elapsed time, you have a capacity problem and no workflow edit will help. Go to the [guide to reducing GitHub Actions queue times](/guides/github-actions-queue-times). 4. If duration P90 dominates, sort the Jobs table by duration P90 and take the top three jobs. Read their CPU P90 and memory P90. 5. High CPU and low duration variance points at cause 3, undersized runners. Low CPU with a long dependency step points at cause 2, cold caches. A long Docker build step with a multi architecture platform list points at cause 4. 6. Export the table to CSV and keep it. The same export after the change is the before and after record. CPU and memory columns require observability to be enabled, and jobs without telemetry show a dash. The time series chart plots one metric at P75 or P90 over time for the jobs on the current table page, which is how a regression that arrived with a specific merge becomes visible. Continuous tracking of these metrics is covered in [GitHub Actions observability](/solutions/github-actions-observability). ### [What the delay costs](#what-the-delay-costs)Copy Anchor Link Queue wait and execution time have different price tags. Queue wait costs engineer attention and merge latency and consumes no runner minutes. Execution time is billed per minute at the rate for the runner label. Matched shapes against GitHub-hosted runners, with GitHub list prices from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13: | Shape | WarpBuild label | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | | --------------------- | --------------------------- | -------------------- | ---------------------------------------- | ----------------- | | 2 vCPU, 8 GB | warp-ubuntu-latest-x64-2x | $0.004 | ubuntu-latest on private repositories | $0.006 | | 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | $0.008 | 4-core Linux larger runner | $0.012 | | 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | $0.016 | 8-core Linux larger runner | $0.022 | | 16 vCPU, 64 GB | warp-ubuntu-latest-x64-16x | $0.032 | 16-core Linux larger runner | $0.042 | | 32 vCPU, 128 GB | warp-ubuntu-latest-x64-32x | $0.064 | 32-core Linux larger runner | $0.082 | | 2 vCPU, 8 GB ARM64 | warp-ubuntu-latest-arm64-2x | $0.003 | ubuntu-24.04-arm on private repositories | $0.005 | | 4 vCPU, 16 GB Windows | warp-windows-latest-x64-4x | $0.016 | 4-core Windows larger runner | $0.022 | GitHub gives public repositories a 4 vCPU, 16 GB Linux shape at no charge, so the ubuntu-latest row above compares the private repository shape, which is the one paying teams run. The macOS comparison does not line up on shape: `warp-macos-latest-arm64-6x` carries 6 vCPU and 14 GB at $0.08 per minute, while the largest GitHub-hosted macOS ARM64 runner carries 5 vCPU and 14 GB at $0.102 per minute, checked on 2026-08-13. The rate is one half of the bill and the minute count is the other. Per-size arithmetic for every label is on the [pricing page](/pricing). Worked monthly model. Take 2,000 jobs per month at 10 minutes of execution each on a 4 vCPU Linux runner, which is 20,000 billed minutes. * `warp-ubuntu-latest-x64-4x`: 20,000 x $0.008 = $160.00 per month. * GitHub-hosted 4-core Linux larger runner: 20,000 x $0.012 = $240.00 per month. * Difference on the same shape and the same minute count: $80.00 per month. Now suppose the diagnosis says cause 3 and the job scales across cores. Moving to `warp-ubuntu-latest-x64-8x` at $0.016 per minute, with execution dropping from 10 minutes to 6, gives 2,000 x 6 = 12,000 minutes x $0.016 = $192.00 per month. That is $32.00 more than the 4 vCPU line for 4 minutes less wall clock on every job, which is the trade the per vCPU minute table predicts. Run the same arithmetic with your own duration percentiles from step 2 above before you commit to a size. Full rates by runner type are on the [WarpBuild pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [How do I tell whether a GitHub Actions job is slow or only queued?](#how-do-i-tell-whether-a-github-actions-job-is-slow-or-only-queued)Copy Anchor Link Read the Queue Timings report and the Jobs report side by side. Queue Timings gives you queue time P75 and P90 per runner label and stack, and Jobs gives you duration P75 and P90 for the same job. If queue time moves and duration stays flat, you have a capacity problem. If duration moves and queue time stays flat, you have a build problem. [The guide to GitHub Actions queue times](/guides/github-actions-queue-times) covers the capacity case. ### [Does a larger runner always make a GitHub Actions job finish sooner?](#does-a-larger-runner-always-make-a-github-actions-job-finish-sooner)Copy Anchor Link No. A larger runner helps only when the job saturates the cores it already has. WarpBuild Linux x64 runners are priced at $0.002 per vCPU minute at every size from 2 vCPU to 32 vCPU, so doubling cores doubles the per minute rate and pays for itself only if wall clock time drops by about half. Check peak CPU in the Jobs report before resizing, then follow [the guide to speeding up GitHub Actions](/guides/speed-up-github-actions). ### [Why is my GitHub Actions cache not making builds faster?](#why-is-my-github-actions-cache-not-making-builds-faster)Copy Anchor Link The usual causes are a cache key that changes on every run, an eviction that happened before the next run started, or a Windows job, where WarpBuild caching is not supported. Compare cache hit steps in two consecutive runs of the same workflow, then read [the guide to the GitHub Actions cache size limit](/guides/github-actions-cache-size-limit). ### [What do larger GitHub Actions runners cost on WarpBuild?](#what-do-larger-github-actions-runners-cost-on-warpbuild)Copy Anchor Link Linux x64 runners run from $0.004 per minute at 2 vCPU and 8 GB to $0.064 per minute at 32 vCPU and 128 GB. Linux ARM64 runs from $0.003 to $0.048 per minute, Windows from $0.016 to $0.128 per minute, and macOS at $0.08 or $0.16 per minute. Full rates are on [the WarpBuild pricing page](/pricing). --- # Why Do Windows Runners Take Longer to Start? | WarpBuild URL: https://www.warpbuild.com/answers/why-are-windows-github-actions-runners-slower-to-start Description: Windows GitHub Actions runners start slower because the image is larger and the boot path does more work, so every job pays a higher fixed cost. # Why Do Windows Runners Take Longer to Start? Windows GitHub Actions runners start slower because the image is larger and the boot path does more work, so every job pays a higher fixed cost. Last verified: 2026-08-13 Windows GitHub Actions runners take longer to start than Linux runners because the Windows image is larger and the boot path does more work before the runner agent is ready to accept a job. That cost is fixed per job rather than proportional to the work a job does, so the lever that moves it is the number of Windows jobs a workflow starts rather than the contents of each one. ## [Answer](#answer)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and every WarpBuild runner is a fresh ephemeral virtual machine per job, per the [cloud runners documentation](/docs/ci/cloud-runners). A Windows job and a Linux job therefore pay the same kind of bill: boot a machine, register it, then run steps. The Windows side of that bill is larger at every stage, and the catalog shows one reason directly. Windows shapes carry a 256GB SSD volume while the Linux x64 shapes carry 150GB, and the Windows image ships the same tooling as the GitHub-hosted Windows runners, which is a Visual Studio and .NET toolchain rather than an apt tree. Start time splits into three phases. Only the third one is under the control of the workflow file. | Phase | What happens | What moves it | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | Image boot | A fresh Windows Server virtual machine boots from the runner image and brings up its services | The image itself. No workflow change touches this phase | | Runner registration | The runner agent starts, registers for the label set in runs-on, and takes the queued job | Nothing in the workflow file | | First-step warm up | actions/checkout, toolchain setup, and package restore run against a cold 256GB volume with antivirus scanning files as they land | Checkout depth, cache configuration, and how much restore work the first steps do | Phases one and two are why a Windows job that does nothing still bills more than a Linux job that does nothing. Phase three is where most teams lose the largest block of time, because Windows jobs restore packages over the network on every run: WarpBuild caches are not supported on Windows-based runners, so Windows jobs use `actions/cache` the same way they would on a GitHub-hosted Windows runner. The full Windows label list, image families, and shapes are on the [Windows runners page](/runners/windows). ## [Detail](#detail)Copy Anchor Link ### [Measure it before changing anything](#measure-it-before-changing-anything)Copy Anchor Link The number most teams quote for start time is the gap between pushing a commit and seeing the first log line, and that gap contains two separate things: queue wait, which depends on capacity, and startup, which depends on the platform. Separate them with the [reports documentation](/docs/ci/features/reports) surfaces. 1. Open the Queue Timings report and set the range to 30 days. Each row is a unique runner label and stack with run count, queue time P75, and queue time P90\. Record the rows for your `warp-windows-` labels and for your Linux labels. 2. Open the Jobs report for the same range. Each row is a unique repository, workflow, and job name combination with duration P75 and P90 alongside queue time P75 and P90. 3. Run a probe workflow so that one row in the Jobs report contains startup and nothing else. Copy Text ``` name: runner-start-probe on: schedule: - cron: "0 * * * *" workflow_dispatch: jobs: probe: strategy: fail-fast: false matrix: runner: - warp-ubuntu-latest-x64-4x - warp-windows-latest-x64-4x runs-on: ${{ matrix.runner }} steps: - run: echo "runner ready" ``` The probe job has one step, so its duration P75 in the Jobs report is the fixed cost of starting a job on that label, and the difference between the two legs is the platform-dependent part. Queue time for the same rows comes from Queue Timings, which keeps capacity out of the measurement. Export both tables to CSV and keep them as the before record. Hourly probes produce about 720 jobs per leg per month. At one billed minute each that is 720 x $0.016 = $11.52 on the Windows leg and 720 x $0.008 = $5.76 on the Linux leg. Continuous versions of these numbers come from CI observability in the WarpBuild product surface, which also covers snapshot runners, remote Docker builders, an MCP server, and the Action Debugger. Snapshot runners are the usual answer to a slow boot, and they do not apply here: they are supported only on WarpBuild Cloud Ubuntu runners, and snapshot labels on a Windows runner are silently ignored. ### [The workflow-level fix: fewer, larger Windows jobs](#the-workflow-level-fix-fewer-larger-windows-jobs)Copy Anchor Link Because startup is charged per job start, a wide matrix of short Windows jobs pays for it repeatedly. Billing is per minute of runner time, and the 2 vCPU Windows shapes were removed on June 8, 2026, so 4 vCPU is the smallest shape a short Windows job can start on. Write S for the startup minutes you measured with the probe and W for the work minutes per unit of work. A matrix of N short jobs bills N x (S + W). Consolidating the same work into N/5 jobs bills (N/5) x (S + 5W), and the difference is pure startup. Worked with S = 1.5 minutes and W = 2 minutes, on `warp-windows-latest-x64-8x` at $0.032 per minute, for a lane that runs 200 times per month: | Job shape | Jobs per run | Billed minutes per run | Monthly minutes | Monthly cost | | ------------------- | --------------- | ---------------------- | --------------- | ------------ | | 40 short jobs | 40 | 40 x 3.5 = 140 | 28,000 | $896.00 | | 8 consolidated jobs | 8 | 8 x 11.5 = 92 | 18,400 | $588.80 | | Difference | 32 fewer starts | 48 minutes | 9,600 | $307.20 | Substitute your own S and W from step 3 above. The rule the table encodes is that consolidation is worth doing when startup is a large share of billed minutes per job, which at S = 1.5 and W = 2 is 43 percent. It also has a wall-clock cost: 40 short jobs running in parallel finish in about 3.5 minutes, while 8 consolidated jobs finish in about 11.5 minutes. Consolidation wins on the bill and on queue pressure, and it loses on wall clock whenever the short jobs really were running in parallel. Sharding by test-suite duration rather than by project count keeps the consolidated jobs balanced. The second lever is the first-step warm up. Shallow checkouts, a `packages.lock.json` key for `actions/cache`, and `dotnet build --no-restore` after a restore step all shrink phase three without touching phases one and two. ### [What the minutes cost](#what-the-minutes-cost)Copy Anchor Link Per-minute rates decide what the startup minutes are worth. `warp-windows-latest-x64-8x` (8 vCPU, 32 GB) costs $0.032 per minute against $0.042 per minute for the 8-core Windows larger runner (8 vCPU, 32 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13, from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) with shapes from the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Every Windows rate is on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does a bigger Windows runner start faster?](#does-a-bigger-windows-runner-start-faster)Copy Anchor Link No. The boot path is the same on every Windows shape, so moving from 4 vCPU to 16 vCPU shortens the work a job does and leaves the fixed startup cost where it was. Size the runner from CPU and memory percentiles in the Jobs report, and pick the label from the [Windows runner catalog](/runners/windows). ### [Can snapshot runners cut Windows start time?](#can-snapshot-runners-cut-windows-start-time)Copy Anchor Link No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, and snapshot labels on a Windows runner are silently ignored, so the job runs normally without them. On Windows the levers are job count and first-step work, which the [guide to GitHub Actions cold starts](/guides/github-actions-cold-starts) covers across platforms. ### [How do I tell whether a Windows job is slow to start or only queued?](#how-do-i-tell-whether-a-windows-job-is-slow-to-start-or-only-queued)Copy Anchor Link Read the Queue Timings report and the Jobs report for the same 30 days. Queue Timings gives queue time P75 and P90 per runner label and stack, and Jobs gives duration P75 and P90 for the job. A high queue time with flat duration is a capacity problem, covered in the [guide to reducing GitHub Actions queue times](/guides/github-actions-queue-times). A flat queue time with a long duration on a one-step probe job is startup cost. --- # Why Does a Base Image Update Invalidate My Cache? | WarpBuild URL: https://www.warpbuild.com/answers/why-do-my-docker-builds-lose-cache-after-a-base-image-update Description: Every layer after FROM is chained to the base image, so a new base digest invalidates all of them. Pin the digest and schedule the rebuild on your terms. # Why Does a Base Image Update Invalidate My Cache? Every layer after FROM is chained to the base image, so a new base digest invalidates all of them. Pin the digest and schedule the rebuild on your terms. A Docker build cache is a chain, and the bottom link is the base image that `FROM` resolves to. When the base image content changes, the `FROM` layer gets a new cache key, every instruction stacked above it inherits a different parent, and the rest of the Dockerfile rebuilds from scratch. ## [Answer](#answer)Copy Anchor Link BuildKit keys each layer on its parent layer plus the instruction that produced it, and it documents the consequence directly: once one layer is invalidated, all downstream layers are invalidated too, and for `FROM` the trigger is a change in the base image itself, per the [Docker cache invalidation reference](https://docs.docker.com/build/cache/invalidation/), checked on 2026-08-13\. A base image update is the worst case for that rule, because `FROM` sits at position one. The part that surprises people is that nothing in the repository has to change for this to fire. `FROM ubuntu:24.04` is a tag, and a tag is a mutable pointer that the registry can repoint at a new digest whenever the publisher pushes a rebuild. The Dockerfile line reads the same in `git log` while the content it resolves to is different, per the [Dockerfile FROM reference](https://docs.docker.com/reference/dockerfile/#from), checked on 2026-08-13. Here is the same Dockerfile across an upstream tag repoint: Copy Text ``` Build on Monday ubuntu:24.04 -> sha256:aaaa [1] FROM ubuntu:24.04 key: sha256:aaaa HIT [2] RUN apt-get install -y libvips key: [1] + command HIT [3] COPY package-lock.json . key: [2] + file digest HIT [4] RUN npm ci key: [3] + command HIT [5] COPY . . key: [4] + context HIT Build on Tuesday ubuntu:24.04 -> sha256:bbbb [1] FROM ubuntu:24.04 key: sha256:bbbb MISS <- chain root moved [2] RUN apt-get install -y libvips parent [1] changed MISS [3] COPY package-lock.json . parent [2] changed MISS [4] RUN npm ci parent [3] changed MISS [5] COPY . . parent [4] changed MISS ``` Steps 2 through 5 are byte-identical between the two builds. They rebuild anyway, because a layer's identity includes the identity of everything underneath it. Two things do survive the change: | Element | What its cache key includes | After a base digest change | | ----------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------- | | FROM node:22@sha256:... | The resolved base image digest | Rebuilt | | RUN apt-get install -y libvips | Parent layer plus the command string | Rebuilt | | COPY package-lock.json . | Parent layer plus the checksum of the copied files | Rebuilt | | RUN --mount=type=cache,target=/root/.npm npm ci | Parent layer plus the command; the mounted directory lives outside the layer chain | Layer rebuilt, mounted directory reused | | A second stage whose own FROM did not move | That stage's base digest plus its own instructions | Reused, until it copies from a rebuilt stage | Cache mounts are the useful exception. A directory mounted with `RUN --mount=type=cache` is persisted by the builder between builds and is never part of a layer, per the [Dockerfile cache mount reference](https://docs.docker.com/reference/dockerfile/#run---mounttypecache), checked on 2026-08-13, so a package manager download cache or a compiler cache comes back warm even when every layer above the base is being rebuilt. The catch is that the mount lives on the builder, so it survives only where the builder survives. Multi-stage Dockerfiles are the other place where the blast radius is smaller than it looks. Each stage has its own `FROM` and its own chain, described in the [multi-stage build reference](https://docs.docker.com/build/building/multi-stage/), checked on 2026-08-13\. Bumping a slim runtime base rebuilds the runtime stage and leaves an expensive compile stage untouched. ## [Detail](#detail)Copy Anchor Link ### [Pin the digest so the invalidation happens when you decide](#pin-the-digest-so-the-invalidation-happens-when-you-decide)Copy Anchor Link `FROM` accepts a digest, and a digest addresses immutable content: Copy Text ``` FROM ubuntu:24.04@sha256:3d1556a8a18cf5307b121e0a98e93f1ddf1f3f8e092f1fddfd941254785b95d7 ``` The tag stays in the line as a readable label and the digest is what the builder resolves. Upstream can repoint `ubuntu:24.04` as often as it wants; your build keeps hitting the same chain root until a commit changes the digest. [Image digest](/glossary/image-digest) covers what that string addresses and why it is stable. Read the current digest for a tag with buildx, using the [imagetools inspect reference](https://docs.docker.com/reference/cli/docker/buildx/imagetools/inspect/), checked on 2026-08-13: Copy Text ``` docker buildx imagetools inspect ubuntu:24.04 --format '{{.Manifest.Digest}}' ``` For a multi-architecture tag that returns the digest of the index manifest, which is the right thing to pin: it resolves to the correct per-platform manifest on both amd64 and arm64 builds. ### [A scheduled workflow that opens the bump pull request](#a-scheduled-workflow-that-opens-the-bump-pull-request)Copy Anchor Link Pinning without a refresh schedule freezes the base image, including its unpatched packages. The pairing that works is a digest pin plus a job on a cron that resolves the tag, rewrites the `FROM` line, and opens a pull request. Scheduled workflows use the `schedule` event, documented in the [GitHub Actions events reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13. Copy Text ``` name: base-image-update on: schedule: - cron: "0 6 * * 1" workflow_dispatch: permissions: contents: write pull-requests: write jobs: bump-base-image: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - name: Resolve the published digest for ubuntu:24.04 id: resolve run: | digest=$(docker buildx imagetools inspect ubuntu:24.04 \ --format '{{.Manifest.Digest}}') echo "digest=$digest" >> "$GITHUB_OUTPUT" - name: Rewrite the FROM line run: | sed -i -E \ "s|^FROM ubuntu:24\.04@sha256:[0-9a-f]{64}|FROM ubuntu:24.04@${{ steps.resolve.outputs.digest }}|" \ Dockerfile git diff --stat -- Dockerfile - name: Open a pull request uses: peter-evans/create-pull-request@v7 with: branch: chore/base-image-ubuntu-2404 commit-message: "chore: bump ubuntu:24.04 to ${{ steps.resolve.outputs.digest }}" title: "Bump ubuntu:24.04 base image" body: | Scheduled base image refresh. Merging this rebuilds every layer above FROM. That is the intended trade: the rebuild is paid here, on one reviewed pull request, rather than on whichever feature branch happened to build first after upstream repointed the tag. ``` The action opens no pull request when the digest is unchanged, per the [create-pull-request repository](https://github.com/peter-evans/create-pull-request), so a quiet week produces a no-op run at the 2 vCPU rate. Run the same job for every base image the repository pins, including the ones inside multi-stage builds, and give each one its own branch name so the pull requests stay reviewable on their own. ### [What the two approaches cost](#what-the-two-approaches-cost)Copy Anchor Link The rebuild itself is fixed work. The variable is how many separate cache scopes pay for it. Assumptions here are stated so you can substitute your own from a job log: 600 Docker builds a month, a warm build at 4 minutes and a full cold rebuild at 22 minutes, upstream repointing the tag 4 times in the month, and roughly 7 active branch cache scopes on a busy repository. The runner is `warp-ubuntu-latest-x64-16x` at $0.032 per minute from the [pricing page](/pricing), checked on 2026-08-13. | Line | Unpinned tag | Digest pinned, weekly bump | | ---------------------------- | --------------------------------------------------------- | ------------------------------------------ | | Full cold rebuilds per month | 28 | 4 | | Extra runner minutes | 504 | 72 | | Extra runner cost per month | $16.13 | $2.30 | | Where the rebuild lands | On whichever pull requests build first after each repoint | On the bump pull request | | Who waits 22 minutes | Whoever opened those pull requests | The reviewer of one scheduled pull request | The dollars are small and the scheduling is the point. Twenty-eight surprise 22-minute builds spread across a month land on people who changed one line of application code and have no idea why their build went cold. The branch-scope multiplier disappears when the layer cache stops being per branch. A WarpBuild remote Docker builder profile maps to a dedicated builder virtual machine that keeps its layer cache on local disk and shares it across every job pointed at that profile, per the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13\. One build repopulates the chain after a base change and the jobs behind it read the warm layers, so the cold rebuild count tracks the number of digest changes rather than the number of branches. That builder cache has a 10 day TTL, so a profile left unused for more than 10 days is reset and the next build is cold for the same reason a base bump is. Builder sizes start at 16 vCPU, 32 GB, and 100 GB of disk at $0.06 per minute on the [pricing page](/pricing), checked on 2026-08-13, and the builder is billed separately from the runner. For dependency caching that has nothing to do with Docker layers, the [WarpBuild caching documentation](/docs/ci/features/caching) covers the drop-in replacement for `actions/cache`. Linux runners are the usual host for a Docker build job. The [Docker builds on GitHub Actions hub](/solutions/docker-builds-github-actions) covers the rest of the pipeline. ## [Related Questions](#related-questions)Copy Anchor Link ### [Does pinning by digest stop base image security updates from reaching my image?](#does-pinning-by-digest-stop-base-image-security-updates-from-reaching-my-image)Copy Anchor Link Pinning freezes the base image until a commit changes the digest, so the update arrives on the scheduled bump rather than on the next build. That is why the pin and the cron ship as one change: the pin decides that invalidation happens on a reviewable commit, and the schedule decides that it happens every week. The [base image update workflow guide](/guides/base-image-update-workflow-github-actions) covers the full job, including repositories that pin several bases, and [image digest](/glossary/image-digest) defines what the pinned string addresses. ### [Why did only some of my build stages rebuild?](#why-did-only-some-of-my-build-stages-rebuild)Copy Anchor Link Each stage in a multi-stage Dockerfile carries its own `FROM` and its own cache chain, per the [multi-stage build reference](https://docs.docker.com/build/building/multi-stage/). Bumping the runtime stage base rebuilds the runtime stage and leaves a cached builder stage alone. Bumping the builder stage base rebuilds that stage plus every later stage that copies from it with `COPY --from`. The [Docker cache invalidation guide](/guides/docker-cache-invalidation-github-actions) works through the ordering rules that decide which stages are affected. ### [Can I avoid the rebuild after a base image change entirely?](#can-i-avoid-the-rebuild-after-a-base-image-change-entirely)Copy Anchor Link No. New base content produces new layer content for everything above it, so the work happens once by definition, per the [Docker cache invalidation reference](https://docs.docker.com/build/cache/invalidation/). What you control is the timing and the repeat count. A digest pin plus a scheduled bump moves the rebuild onto one pull request, and a builder that holds its layer cache on local disk means one job repopulates the chain for every job behind it, per the [Docker builders documentation](/docs/ci/docker-builders). The [Docker builds on GitHub Actions hub](/solutions/docker-builds-github-actions) puts both moves in the context of a full pipeline. --- # Why Do My GitHub Actions Jobs Queue Behind Each Other? | WarpBuild URL: https://www.warpbuild.com/answers/why-do-my-github-actions-jobs-queue-behind-each-other Description: Three things serialize jobs: a concurrency group holding runs in one lane, an account-level parallel job ceiling, and a runner pool with no free capacity. # Why Do My GitHub Actions Jobs Queue Behind Each Other? Three things serialize jobs: a concurrency group holding runs in one lane, an account-level parallel job ceiling, and a runner pool with no free capacity. ## [Answer](#answer)Copy Anchor Link Three separate mechanisms serialize GitHub Actions jobs, and from the run list they look alike: a `concurrency` group that holds every matching run in one lane, an account-level ceiling on parallel jobs, and a runner pool with no free capacity behind your `runs-on` labels. Each one leaves a different visible signal, so identify the signal before editing any workflow file, because the fix for one does nothing for the other two. | Signal you can see | Cause | Where to confirm it | | ---------------------------------------------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------- | | Run header shows a Waiting banner naming a group, and no jobs have started | A concurrency group | The workflow run page | | A queued run flips to Canceled the moment a newer commit lands on the same ref | A concurrency group under the default queue: single | The workflow run page | | Running job count plateaus at 20, 40, 60, or 500 and then advances in waves of that size | Account-level ceiling on GitHub-hosted runners | [GitHub usage limits](https://docs.github.com/en/actions/reference/limits) | | macOS jobs wait while Linux jobs on the same account start immediately | Account-level ceiling, macOS sub-limit | [GitHub usage limits](https://docs.github.com/en/actions/reference/limits) | | Jobs on one runner label wait while jobs on other labels start on time | Runner pool capacity for that label | Queue Timings report | | Jobs on a label never start at all and produce no logs | Label mismatch or runner group access | [Common issues](/docs/ci/common-issues) | The last row is the one people misread most often. A job whose labels match zero online runners waits forever, which feels like a capacity shortage and is a routing problem. ## [Detail](#detail)Copy Anchor Link ### [1\. A concurrency group holding runs in one lane](#1-a-concurrency-group-holding-runs-in-one-lane)Copy Anchor Link The `concurrency` key creates a named lane that allows one workflow run or job at a time, documented in the [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency), checked on 2026-08-13\. Group names resolve to strings, they are case insensitive, and membership is scoped to the repository rather than to one workflow file. Two workflow files whose group expressions produce the same string share a single lane. A literal group name is the usual accident: Copy Text ``` name: deploy on: push: branches: ["**"] concurrency: group: deploy cancel-in-progress: false jobs: ship: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/deploy.sh ``` Every push in the repository resolves to `deploy`, so a push on a feature branch parks behind a release running on `main`. Widening the key restores parallelism while keeping the property you wanted, which is one deploy at a time per target: Copy Text ``` concurrency: group: deploy-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} ``` Under the default `queue: single` the group holds one running item and one pending item, and a third arrival cancels the pending one. Setting `queue: max` lets up to 100 arrivals wait. The [concurrency group definition](/glossary/concurrency-group) covers the settings and the states they produce. A group only holds work back or cancels it, and it always labels the run header while doing so, so a run page without a Waiting banner points you at one of the other two causes. ### [2\. An account-level parallel job ceiling](#2-an-account-level-parallel-job-ceiling)Copy Anchor Link Jobs on standard GitHub-hosted runners draw from shared pools with a fixed ceiling per account: 20 concurrent jobs on Free, 40 on Pro, 60 on Team, and 500 on Enterprise, with macOS sub-ceilings of 5 on the first three plans and 50 on Enterprise. Those figures come from [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13, and a matrix caps out at 256 jobs per workflow run from the same page. The ceiling is account wide. One repository fanning out a wide matrix pushes every other repository on the account into the same wave pattern, which is why the queue often starts in a repository nobody on your team touched. The guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) walks the full symptom to mechanism mapping and the configuration that clears each one. ### [3\. A runner pool with no free capacity](#3-a-runner-pool-with-no-free-capacity)Copy Anchor Link Jobs targeting self-hosted or managed labels sit outside the account ceiling and queue on pool capacity instead. For a static fleet the ceiling is the count of registered machines. For an autoscaled fleet such as an ARC scale set it is the maximum runner setting. Either way, once every matching machine is busy, job N+1 waits for machine 1 to finish, which is the wave pattern people describe as jobs queueing behind each other. ### [How to read wait time per runner label](#how-to-read-wait-time-per-runner-label)Copy Anchor Link Queue wait per label is the measurement that separates cause 3 from causes 1 and 2, and it is the number to watch after any change. Per the [reports documentation](/docs/ci/features/reports): * **Queue Timings** has one row per runner label and stack, carrying run count, queue time P75, and queue time P90, plus a daily bar chart of average queue time and job count. * **Jobs** has one row per repository, workflow, and job name, with duration P75 and P90 next to queue time P75 and P90, so a job that got slower and a job that started later read differently. * Both tabs export to CSV with the current filters applied, so a before and after comparison is a spreadsheet rather than a screenshot. Sort Queue Timings by P90 and filter to one label at a time. A high P90 with a normal P75 on one label is a burst overrunning capacity at specific hours. A high P75 and P90 together on one label is a steady shortage. Near-zero percentiles on every label while pull requests still feel slow means the queue is fine and job duration is the problem, which the guide to [GitHub Actions queue times](/guides/github-actions-queue-times) takes apart reading by reading. ### [What the waves cost](#what-the-waves-cost)Copy Anchor Link Serialization moves wall clock and leaves billed minutes alone, which is why it survives budget reviews. Assume a suite of 12 jobs, each taking 5 minutes, each on `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) at $0.008 per minute from the [pricing page](/pricing), checked on 2026-08-13\. Total compute is 60 job-minutes in every row below. | Free slots | Waves | Wall clock per run | Billed job-minutes | Runner cost per run | | ---------- | ----- | ------------------ | ------------------ | ------------------- | | 4 | 3 | 15 min | 60 | $0.48 | | 6 | 2 | 10 min | 60 | $0.48 | | 12 | 1 | 5 min | 60 | $0.48 | At 300 runs a month the suite bills 18,000 job-minutes for $144 in all three rows. Moving from the 4-slot row to the single-wave row returns 10 minutes of wall clock per run, or 3,000 minutes a month of engineers waiting on a pull request check, and the invoice column does not move. ### [Removing the pool as a cause](#removing-the-pool-as-a-cause)Copy Anchor Link Creates a fresh machine for each job when that job queues. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Per the [cloud runners documentation](/docs/ci/cloud-runners), that statement covers generally available features on Linux and Windows runners; macOS concurrency runs on a per-organization quota, so teams sizing a large macOS matrix arrange the quota with support at [support@warpbuild.com](mailto:support@warpbuild.com). That removes cause 3 and leaves the other two where they were. An account ceiling still counts jobs on GitHub-hosted runners, and a concurrency group you wrote still serializes runs on any runner, managed or not, so keep reading the signal. ## [Related Questions](#related-questions)Copy Anchor Link ### [How do I tell a concurrency group from a capacity shortage?](#how-do-i-tell-a-concurrency-group-from-a-capacity-shortage)Copy Anchor Link A concurrency group announces itself. The run header carries a Waiting banner naming the group, and queued runs flip to Canceled when a newer commit lands on the same ref under the default `queue: single`. A capacity shortage produces no banner: jobs sit at Waiting for a runner while queue wait on one runner label climbs in the [Queue Timings report](/docs/ci/features/reports) and other labels start on time. The [concurrency group definition](/glossary/concurrency-group) covers the group settings, and the guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) maps each symptom to the mechanism behind it. ### [Do GitHub account job ceilings apply to jobs on WarpBuild runners?](#do-github-account-job-ceilings-apply-to-jobs-on-warpbuild-runners)Copy Anchor Link The per-plan concurrent job ceilings of 20, 40, 60, and 500 count jobs on GitHub-hosted runners, per [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13\. Jobs that target `warp-` labels queue on pool capacity instead. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, covering generally available features on Linux and Windows runners per the [cloud runners documentation](/docs/ci/cloud-runners); for high macOS concurrency, contact [support@warpbuild.com](mailto:support@warpbuild.com). A concurrency group you wrote still serializes runs on any runner. ### [Where do I see how long jobs wait on each runner label?](#where-do-i-see-how-long-jobs-wait-on-each-runner-label)Copy Anchor Link The Queue Timings report has one row per runner label and stack with run count, queue time P75, and queue time P90, plus a daily average chart and CSV export, documented in the [reports documentation](/docs/ci/features/reports). Sort by P90 and filter to one label at a time to separate a burst from a steady shortage. When a label shows no jobs starting at all, check bot repository access, runner group access, and workflow path restrictions in the [common issues documentation](/docs/ci/common-issues) before adding capacity. The guide to [GitHub Actions queue times](/guides/github-actions-queue-times) matches each reading to a fix, and per-minute rates for every runner size are on the [pricing page](/pricing). --- # Why Do Python Tests Run Slower in CI? | WarpBuild URL: https://www.warpbuild.com/answers/why-do-my-python-tests-run-slower-in-ci-than-locally Description: Usually three causes stack up: the runner has fewer cores than your laptop, every job starts with a cold cache, and pytest runs the suite in one process. # Why Do Python Tests Run Slower in CI? Usually three causes stack up: the runner has fewer cores than your laptop, every job starts with a cold cache, and pytest runs the suite in one process. Last verified: 2026-08-13 Python tests run slower in GitHub Actions than on a laptop for three reasons that usually stack: the runner has fewer cores than your machine, every job starts with a cold dependency and bytecode cache, and pytest executes the suite in a single process unless you tell it otherwise. Each cause has a separate fix, and only the first one changes the per-minute rate you pay. ## [Answer](#answer)Copy Anchor Link | Cause | What it looks like in the job log | Why it happens on a runner | Fix | | ---------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Fewer cores than the laptop | Wall clock tracks test count, and one or two cores sit pinned while the suite runs | GitHub's standard hosted Linux runner for private repositories is 2 vCPU with 8GB of RAM ([GitHub runner specs](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13) | Point runs-on at a larger label, for example warp-ubuntu-latest-x64-8x at 8 vCPU and 32GB ([cloud runners documentation](/docs/ci/cloud-runners)) | | Cold dependency and bytecode cache | The install step takes minutes, and first collection is slow before any test body executes | Runners are ephemeral virtual machines, freshly allocated per job and destroyed at the end ([cloud runners documentation](/docs/ci/cloud-runners)), so site-packages, the resolver cache, and every \_\_pycache\_\_ directory start empty | Restore the resolver cache and the virtualenv with [WarpBuilds/setup-python](/docs/ci/features/caching/setup-actions) or [WarpBuilds/cache](/docs/ci/features/caching) | | Serial suite by default | One core busy on a CPU-bound suite while the rest of the machine idles | pytest runs tests in one process; parallel execution comes from the [pytest-xdist plugin](https://pytest-xdist.readthedocs.io/en/stable/distribution.html) | Add \-n with the worker count matched to the runner's vCPU count | The order matters. Caching and worker count cost nothing per minute, so fix them before buying cores. A suite that installs from scratch and then runs on one worker will look slow on any machine, and a bigger runner makes the install cheaper only in wall-clock terms while doubling the rate. Linux x64 shapes run from 2 vCPU with 8GB to 32 vCPU with 128GB, all on 150GB SSD storage, and every shape carries 4GB of memory per vCPU ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). That ratio is what decides how many pytest workers a machine can hold. ## [Detail](#detail)Copy Anchor Link ### [Cores: what the runner actually hands pytest](#cores-what-the-runner-actually-hands-pytest)Copy Anchor Link A laptop with 8 or 10 performance cores gives `pytest -n auto` 8 or 10 workers. GitHub's standard hosted Linux runner for private repositories carries 2 vCPU, so the same command gets 2 ([GitHub runner specs](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13). For a CPU-bound suite that is the whole gap: the same tests, a quarter of the parallelism. On `warp-ubuntu-latest-x64-8x` the same command gets 8 workers with 4GB each. Before changing the label, read the CPU and memory utilization the job already reports through CI observability, which groups metrics by repository, workflow, job, and instance type ([observability documentation](/docs/ci/features/observability)). A suite that idles at 20 percent CPU on 2 vCPU has a different problem than one that pins both cores for 15 minutes. ### [The caches your laptop already has](#the-caches-your-laptop-already-has)Copy Anchor Link Local runs inherit state that a fresh job does not. The virtualenv is installed, wheels sit in `~/.cache/pip` or `~/.cache/uv`, `__pycache__` holds bytecode compiled during the last run ([Python compiled files reference](https://docs.python.org/3/tutorial/modules.html#compiled-python-files)), the database container is already up, and the page cache holds the files pytest is about to read. A runner starts with none of it. Two mechanisms close most of that gap. [WarpBuilds/setup-python](/docs/ci/features/caching/setup-actions) installs the interpreter and caches pip, pipenv, or poetry dependencies through the `cache` input, and [WarpBuilds/cache](/docs/ci/features/caching) stores any directory as a drop-in replacement for `actions/cache`. For suites where environment build is the dominant cost, snapshot runners take a snapshot of the runner VM during a workflow and reuse it on later runs, which carries the installed environment forward instead of rebuilding it ([snapshot runners documentation](/docs/ci/features/snapshot-runners)). ### [Worker count matched to the runner size](#worker-count-matched-to-the-runner-size)Copy Anchor Link Copy Text ``` name: python-tests on: pull_request: jobs: pytest: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-python@v6 with: python-version: "3.12" cache: pip cache-dependency-path: requirements-dev.txt - run: pip install -r requirements-dev.txt - name: Run the suite with one worker per vCPU run: pytest -n 8 --dist loadfile -q --durations=15 ``` Three details in that file do the work. The `-n 8` matches the 8 vCPU of `warp-ubuntu-latest-x64-8x`, and `-n auto` reaches the same number by detecting the CPU count. The `--dist loadfile` mode keeps every test in a module on one worker, which stops module-scoped fixtures from being built once per worker. And `--durations=15` prints the slowest tests, which is how you find out whether the remaining time is CPU work or a fixture waiting on a socket. ### [Pricing the parallelism decision](#pricing-the-parallelism-decision)Copy Anchor Link | Runner label | Shape | Per minute | Workers at one per vCPU | Rate per worker-minute | | --------------------------- | ------------- | ---------- | ----------------------- | ---------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8GB | $0.004 | 2 | $0.002 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16GB | $0.008 | 4 | $0.002 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32GB | $0.016 | 8 | $0.002 | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64GB | $0.032 | 16 | $0.002 | | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32GB | $0.012 | 8 | $0.0015 | Rates and shapes come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13\. The last column is the row's rate divided by its vCPU count, and it holds at $0.002 across the x64 ladder. Perfect scaling would therefore hold the bill flat while cutting wall clock, and the gap between that and reality is what a measurement pass is for. Work an example. Assume a suite takes 16.0 minutes with 2 workers on `warp-ubuntu-latest-x64-2x`, which is $0.064 per run. Assume near-linear scaling to 8 workers lands it at 4.5 minutes on `warp-ubuntu-latest-x64-8x`: 4.5 minutes at $0.016 is $0.072 per run. At 500 pull request runs a month that is $32.00 against $36.00, so 11.5 minutes come off every run for $4.00 a month. Assume 16 workers reach 3.2 minutes: $0.1024 per run, or $51.20 a month, which buys 1.3 more minutes for another $15.20. `warp-ubuntu-latest-x64-8x` costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner of the same 8 vCPU, 32 GB shape: 27 percent lower list price ([GitHub pricing](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). ### [Where more cores stop helping](#where-more-cores-stop-helping)Copy Anchor Link Some Python suites keep their duration on any size. Tests that wait on one Postgres container serialize on the database rather than the interpreter, and adding workers turns that into lock contention. Import-heavy `conftest.py` files charge their cost to every worker, so 16 workers pay the import 16 times. Tests that fetch from the network run at network speed. The [sizing guide for Python test suites](/guides/sizing-runners-for-python-test-suites) works through reading those signals before changing a label, and the [pytest sharding guide](/guides/pytest-parallel-test-sharding) covers the other shape of the same decision, which is splitting the suite across several jobs instead of several workers. The full setup, from cache configuration to runner choice, is on the [Python on GitHub Actions page](/solutions/python-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link ### [How many pytest-xdist workers should I run on a GitHub Actions runner?](#how-many-pytest-xdist-workers-should-i-run-on-a-github-actions-runner)Copy Anchor Link Start at one worker per vCPU, so 8 workers on `warp-ubuntu-latest-x64-8x` and 16 on `warp-ubuntu-latest-x64-16x`. The `-n auto` setting picks that number from the detected CPU count ([pytest-xdist documentation](https://pytest-xdist.readthedocs.io/en/stable/distribution.html)). Lower it when workers contend for one database container, or when 4GB per worker runs short for a fixture set that loads large data. Past the point where one machine helps, the [pytest sharding guide](/guides/pytest-parallel-test-sharding) covers splitting the suite across jobs. ### [Why is the dependency install slower in GitHub Actions than on my machine?](#why-is-the-dependency-install-slower-in-github-actions-than-on-my-machine)Copy Anchor Link Runners are ephemeral virtual machines, freshly allocated for each job and destroyed when the job completes ([cloud runners documentation](/docs/ci/cloud-runners)), so `site-packages`, the pip or uv download cache, and every `__pycache__` directory start empty. Restoring those paths with [WarpBuilds/setup-python](/docs/ci/features/caching/setup-actions) or [WarpBuilds/cache](/docs/ci/features/caching) turns a full resolve and install into a download and unpack. ### [Does a larger runner cost more once the suite runs in parallel?](#does-a-larger-runner-cost-more-once-the-suite-runs-in-parallel)Copy Anchor Link The Linux x64 ladder prices at $0.002 per vCPU-minute at every size, so a suite that scales linearly with worker count costs about the same per run on 8 vCPU as on 2 and returns the result sooner. Cost rises where scaling is sub-linear, which is the case worth measuring. Full rates are on the [pricing page](/pricing). ### [Do I need to change my pytest configuration to move to a bigger runner?](#do-i-need-to-change-my-pytest-configuration-to-move-to-a-bigger-runner)Copy Anchor Link Change the `runs-on` label to a `warp-` label and set the worker count to match the new vCPU count. Test code, conftest files, markers, and plugins stay the same. The whole configuration, from cache setup to label choice, sits on the [Python on GitHub Actions page](/solutions/python-github-actions), and the [sizing guide](/guides/sizing-runners-for-python-test-suites) covers picking the label from measured utilization. --- # Why Does Cargo Rebuild Everything in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/why-does-cargo-rebuild-everything-in-github-actions Description: Cargo rebuilds because a fresh runner has no target directory, and a changed toolchain, feature set, or RUSTFLAGS invalidates a restored one. Keys and costs. # Why Does Cargo Rebuild Everything in GitHub Actions? Cargo rebuilds because a fresh runner has no target directory, and a changed toolchain, feature set, or RUSTFLAGS invalidates a restored one. Keys and costs. Cargo rebuilds everything because each GitHub Actions job starts on a clean machine with no `target` directory, so there are no fingerprints on disk to compare against and every crate in the graph compiles from source. When `target` does get restored from a cache and Cargo still rebuilds it, the fingerprints came back but no longer match: the toolchain, the enabled feature set, a profile setting, or `RUSTFLAGS` moved between the run that wrote the entry and the run reading it. ## [Answer](#answer)Copy Anchor Link Cargo keeps compiled artifacts in `target` and, next to each one, a fingerprint recording the inputs that produced it. The [build cache reference](https://doc.rust-lang.org/cargo/reference/build-cache.html) describes the directory, and Cargo's [contributor guide on fingerprints](https://doc.crates.io/contrib/architecture/fingerprints.html) lists what goes into the comparison. A rebuild happens when a fingerprint is absent or when any recorded input differs. That gives three distinct situations, and they need different fixes. The first is an empty `target`. [GitHub-hosted runners give each job a fresh virtual machine](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), and self-hosted runners configured as ephemeral behave the same way. Nothing survives, so the fix is a cache or a prepared machine. The second is a restored `target` that Cargo discards anyway. Something in the fingerprint changed, and Cargo cannot reuse artifacts built under different inputs. The third looks like a full rebuild and is not one: the dependency tree restores cleanly and your workspace crates recompile on every job. That is timestamps, covered below. Here is what sits in the fingerprint and what moves it inside a workflow. | Fingerprint input | What changes it in a GitHub Actions job | What rebuilds | | ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | The rustc version, down to the commit hash | rustup update, a rust-toolchain.toml bump, a runner image shipping a newer preinstalled toolchain | Every crate in the graph | | The enabled [feature set](https://doc.rust-lang.org/cargo/reference/features.html) for each unit | \--all-features in one job and default features in another, or a new dependency turning on a feature of a shared crate | The crate whose features changed and everything compiled against it | | [Profile settings](https://doc.rust-lang.org/cargo/reference/profiles.html): opt-level, debug, lto, codegen-units, panic, overflow-checks | Switching to \--release, editing a \[profile.\*\] table, adding a profile only the workflow uses | Everything built under that profile | | RUSTFLAGS and RUSTDOCFLAGS | A lint job exporting \-D warnings while the test job exports nothing | Everything for that flag set | | The target triple | \--target x86\_64-unknown-linux-musl on one job and the host triple on another | Everything for that triple | | Fingerprints of dependencies | A Cargo.lock update touching one low-level crate | That crate and every crate above it | | Source file timestamps for path and workspace crates | actions/checkout writing every file with the current timestamp | Your workspace crates, on every job | | Environment variables read through env! or option\_env! | A workflow exporting a build identifier or version string per run | The crates that read them | Two rows explain most confused reports. Registry dependencies are fingerprinted from the package checksum rather than from timestamps, so a restored entry keeps them valid while the workspace crates in the same entry rebuild. And feature sets unify across the graph, so a single new dependency can change the hash of a crate that nobody edited. ## [Detail](#detail)Copy Anchor Link ### [Put the toolchain and the feature set in the key](#put-the-toolchain-and-the-feature-set-in-the-key)Copy Anchor Link `Cargo.lock` is the obvious key input and it covers none of the top four rows in that table. A key built from the lockfile alone restores several gigabytes after a toolchain bump and then throws all of it away. Name the compiler and the flag set in the key directly. Hashing `rust-toolchain.toml` records the intent; hashing the output of `rustc -vV` records what actually ran, which matters when the file says `stable` and the runner image moved. Copy Text ``` name: rust on: push: branches: [main] pull_request: env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 RUSTFLAGS: "-D warnings" jobs: test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - name: Record the exact toolchain run: rustc -vV | tee rustc-version.txt - name: Restore cargo cache id: cargo uses: WarpBuilds/cache/restore@v1 with: path: | ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db target key: cargo-allfeatures-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('rustc-version.txt') }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | cargo-allfeatures-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('rustc-version.txt') }}- - run: cargo test --workspace --all-features --locked - name: Save cargo cache if: success() && github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: | ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db target key: ${{ steps.cargo.outputs.cache-primary-key }} ``` The `allfeatures` segment is written by hand because the feature set and `RUSTFLAGS` appear in no file the key hashes. Change either one and change that segment, or the job restores artifacts Cargo will discard. Setting `RUSTFLAGS` at workflow level rather than per job keeps every job on one flag set, which keeps them on one set of artifacts. The [WarpBuild cache documentation](/docs/ci/features/caching) has the action inputs, and [how to cache Cargo dependencies in GitHub Actions](/answers/how-do-i-cache-cargo-dependencies-in-github-actions) covers which Cargo home paths belong in `path`. ### [One feature set per job](#one-feature-set-per-job)Copy Anchor Link A job that runs `cargo build`, then `cargo clippy`, then `cargo test --all-features` compiles the dependency graph more than once inside a single run, because each distinct feature set and flag set gets its own artifacts. The job log reads as a rebuild loop and the cache entry grows to hold every variant. Pick one feature set per job and let separate jobs carry separate cache keys. `CARGO_INCREMENTAL: 0` belongs in the same decision: incremental state pays off on a developer machine that recompiles the same tree many times, and a runner sees each commit once. [Rust incremental compilation on GitHub Actions](/guides/rust-incremental-compilation-github-actions) works through what incremental state does and does not survive. ### [When the target directory is too large to move](#when-the-target-directory-is-too-large-to-move)Copy Anchor Link Past a certain workspace size the restore step costs more than the compile it removes, and no key fixes that. A snapshot runner takes the machine itself instead: [snapshot runners](/docs/ci/features/snapshot-runners) capture the runner disk after a warm build and boot later jobs from it, so `target` is already on local disk and nothing crosses the network. Copy Text ``` jobs: test: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=rust-target' }} steps: - uses: actions/checkout@v5 - run: cargo test --workspace --all-features --locked - name: Clear credentials before snapshotting if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffd -e target - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: rust-target wait-timeout-minutes: 60 ``` Four constraints are worth knowing before you commit to this shape. The documented cleanup command is `git clean -ffdx`, and `-x` deletes gitignored paths, which includes `target`, so this workflow drops `-x` and excludes the directory it is trying to keep. Snapshots are deleted after 15 days, so an alias needs a job that refreshes it. `/tmp` does not persist across the reboot. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only, with labels on other runner types silently ignored. A macOS Rust job stays on the cache path. A snapshot does not fix the timestamp row either. `actions/checkout` still rewrites your workspace sources, so your own crates recompile and the dependency tree stays warm. ### [What the rebuild costs](#what-the-rebuild-costs)Copy Anchor Link Rates come from the [pricing page](/pricing), checked on 2026-08-13\. Durations below are illustrative, and the arithmetic is what you should rerun with numbers from your own job log. Take a workspace where `cargo test --workspace --all-features` takes 12 minutes cold and 4 minutes against a valid `target`, on `warp-ubuntu-latest-x64-8x` at $0.016 per minute, across 880 runs a month. * Rebuilding every run: 12 x $0.016 = $0.192 per job, so 880 jobs cost $168.96. * Restoring a valid entry: 4 x $0.016 = $0.064 per job, so 880 jobs cost $56.32, plus a 12 GB entry at $0.20 per GB-month for $2.40 and about 1,760 cache operations at $0.0001 for $0.18. * Restoring an invalid entry: the download plus the full 12 minute compile, which is the worst of the three and the case a toolchain-aware key removes. For the snapshot path, restore bills $0.04 per job and storage $0.025 per snapshot-hour, which is $0.60 per day for one live alias. At $0.016 per minute the restore fee equals 2.5 minutes of build time, so a snapshot pays once it removes more than that, before counting a boot that takes 45 to 60 seconds. On `warp-ubuntu-latest-x64-16x` at $0.032 per minute the same fee equals 1.25 minutes. To find which steps the minutes actually go to, the Action Debugger opens an SSH session on a paused runner. Run `cargo build --timings` against the restored `target` there to see what Cargo decided was stale. [Rust builds on GitHub Actions](/solutions/rust-github-actions) covers runner sizing for the same workloads. ## [Related Questions](#related-questions)Copy Anchor Link ### [Why do my own crates rebuild even when the cache restores?](#why-do-my-own-crates-rebuild-even-when-the-cache-restores)Copy Anchor Link Cargo checks source file timestamps for path and workspace crates, and `actions/checkout` writes every file with the timestamp of the checkout, so the restored artifacts are older than the sources that produced them and every workspace crate recompiles. Registry dependencies are fingerprinted from their [package checksum](https://doc.crates.io/contrib/architecture/fingerprints.html) rather than from timestamps, so they stay warm. That is why a restored run compiles your crates and skips the dependency tree, which is the expected result rather than a broken cache. ### [Why does adding one feature flag rebuild the whole graph?](#why-does-adding-one-feature-flag-rebuild-the-whole-graph)Copy Anchor Link The enabled feature set is part of the hash Cargo computes for every unit, and [features unify across the dependency graph](https://doc.rust-lang.org/cargo/reference/features.html), so turning on a feature of a low-level crate changes the hash of that crate and of everything compiled against it. The same rule explains a job that runs `cargo build` with default features and then `cargo test --all-features`: those are two feature sets, so the graph compiles twice. Give each feature set its own job and its own cache key. ### [When should I use a snapshot runner instead of caching the target directory?](#when-should-i-use-a-snapshot-runner-instead-of-caching-the-target-directory)Copy Anchor Link When `target` has grown past the point where moving it costs more than the compile it removes. A [snapshot runner](/docs/ci/features/snapshot-runners) restores the whole runner disk in place, so nothing crosses the network, and it suits workspaces whose build cache is measured in tens of gigabytes. Snapshot restore bills $0.04 per job against $0.016 per minute on `warp-ubuntu-latest-x64-8x`, so it pays once it removes more than 2.5 minutes of build, before counting the 45 to 60 second boot. Keep the cache path for smaller workspaces and for macOS or Windows jobs, where snapshots are unavailable. --- # Why Does Docker Build Run Out of Disk? | WarpBuild URL: https://www.warpbuild.com/answers/why-does-docker-build-run-out-of-disk-in-github-actions Description: One runner volume holds the checkout, the pulled base images, every intermediate stage, and the exported cache at once. Move the build to a remote builder. # Why Does Docker Build Run Out of Disk? One runner volume holds the checkout, the pulled base images, every intermediate stage, and the exported cache at once. Move the build to a remote builder. A Docker build runs out of disk because one runner volume holds the repository checkout, every base image pulled by the Dockerfile, every intermediate stage of a multi-stage build, the final image, and the exported build cache at the same time. BuildKit keeps the intermediate stages in its cache until the build ends rather than freeing each stage as it finishes, so a build that produces a 200 MB image can still need tens of gigabytes of working space while it runs ([Docker multi-stage build documentation](https://docs.docker.com/build/building/multi-stage/)). ## [Answer](#answer)Copy Anchor Link The runner volume is a single shared working space and the build fills it from several directions at once. Here is what a single image-building job holds at its peak, with example sizes you would replace with your own measurements: | What holds disk during the build | Where it lives | Example size | | ---------------------------------------------------------- | ------------------------------------------------------- | ------------ | | Repository checkout | Runner work directory | 3 GB | | Base images pulled by the Dockerfile | Compressed blob plus extracted layer in the image store | 12 GB | | Builder stage layers: toolchain, source tree, object files | BuildKit cache | 34 GB | | Final stage layers | Image store | 4 GB | | Exported cache with mode=max | Staged on local disk before upload | 30 GB | | A second platform in a multi-platform build | Duplicates the stage and image rows | 38 GB | | Peak | 121 GB | | Every Linux x64 and Linux ARM64 label carries 150GB SSD, every Windows label carries 256GB SSD, and macOS carries 120GB on the 6 vCPU sizes and 270GB on the 12 vCPU sizes ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). The 121 GB peak above fits inside a Linux label with room to spare, and it does not fit inside a standard GitHub-hosted runner, which GitHub documents at 14 GB of SSD storage in the [hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13\. Part of the volume is already occupied by the preinstalled tooling that ships in the runner image, catalogued in the public [actions/runner-images](https://github.com/actions/runner-images) repository, so budget against measured free space rather than the headline number. Three fixes address the problem, in the order worth trying: 1. **Move the build to a remote Docker builder.** The layers, the build cache, and the intermediate stages land on a dedicated builder virtual machine instead of the runner, and with `push: true` the image goes from the builder to the registry without ever touching the runner disk ([Docker builders documentation](/docs/ci/docker-builders)). 2. **Prune between stages.** Drop the heavy stage and its cache once the artifact you need has been copied out. This buys space back within the job and gives up the layer reuse the next build would have had. 3. **Move to a label with a bigger volume.** Disk does not scale with vCPU on Linux, so this means a different platform or a BYOC instance with local NVMe rather than a bigger Linux size. Per-minute rates for every label and builder profile are on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [Why a multi-stage build holds every stage until the end](#why-a-multi-stage-build-holds-every-stage-until-the-end)Copy Anchor Link A multi-stage Dockerfile reads as though the builder stage disappears when the final stage copies its output. On disk it does not. BuildKit records the result of each stage in its cache so that a later build, or a later `--target`, can reuse it, and that cache lives on the same volume as everything else until the job ends or something prunes it ([Docker multi-stage build documentation](https://docs.docker.com/build/building/multi-stage/)). Two multipliers turn that into a failure. A base image occupies disk twice, once as the compressed blob that was pulled and once as the extracted layer in the overlay store. And `cache-to: type=gha,mode=max` exports every intermediate layer, which means the build writes a second full copy of the stage output to local disk before uploading it. A multi-platform build repeats both for every platform in the list. The command that shows the real split is `docker system df`, which breaks usage into images, containers, local volumes, and build cache. Run it after the failing step and the largest line names the fix. ### [Fix 1: run the build on a remote Docker builder](#fix-1-run-the-build-on-a-remote-docker-builder)Copy Anchor Link Remote Docker builders are one part of the WarpBuild surface alongside snapshot runners, CI observability, an MCP server, and the Action Debugger. Each builder profile is a dedicated build virtual machine with a persistent layer cache on its own local disk, and the runner keeps only the checkout and the build context it sends over ([Docker builders documentation](/docs/ci/docker-builders)). Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push from the builder uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} profile-name: "release-builder" ``` Two details decide whether the runner disk stays clear. `push: true` sends the image from the builder to the registry directly, so no layer is written on the runner. Setting `load: true` instead copies the finished image back into the runner's image store, which puts the problem back where it started. The `cache-from` and `cache-to` lines that pointed at `type=gha` can also come out, because the profile keeps its cache on the builder rather than exporting it. Builder profiles carry their own volumes, and the size is chosen with the profile ([Docker builders documentation](/docs/ci/docker-builders)): | Builder profile | Disk | Per minute | | ---------------- | ----- | ---------- | | 16 vCPU, 32 GB | 100GB | $0.06 | | 32 vCPU, 64 GB | 200GB | $0.12 | | 64 vCPU, 128 GB | 200GB | $0.24 | | 96 vCPU, 192 GB | 600GB | $0.36 | | 96 vCPU, 192 GB | 2TB | $0.52 | | 192 vCPU, 384 GB | 600GB | $0.72 | | 192 vCPU, 384 GB | 2TB | $0.88 | Both resources bill while the job runs, because the runner waits on the builder. A six minute build on the 16 vCPU, 100GB profile costs 6 x $0.06 = $0.36 in builder time plus 6 x $0.008 = $0.048 of `warp-ubuntu-latest-x64-4x` time, or $0.408 per build. Three hundred builds a month is $122.40\. The [Docker builds on GitHub Actions solution page](/solutions/docker-builds-github-actions) works through profile sizing against matrix width and how parallel jobs share one billing session. ### [Fix 2: prune between stages](#fix-2-prune-between-stages)Copy Anchor Link When the build has to stay on the runner, free the stage output as soon as its artifact has been copied out: Copy Text ``` - name: Build the toolchain stage run: docker build --target builder -t app-builder . - name: Report disk before pruning run: | df -h / docker system df - name: Drop the toolchain stage run: | docker image rm app-builder docker builder prune --force ``` `docker builder prune` clears the BuildKit cache, which is normally the largest line in `docker system df` on a multi-stage build. The trade is explicit: the next run rebuilds those layers from scratch, so the job gets its disk back and loses its cache. That trade is what the remote builder route avoids, since the cache lives on a machine with room for it. The [guide to an out-of-disk GitHub Actions runner](/guides/github-actions-runner-out-of-disk) covers the non-Docker sources of the same failure. ### [Fix 3: move to a label with a bigger volume](#fix-3-move-to-a-label-with-a-bigger-volume)Copy Anchor Link Disk is flat across Linux sizes. `warp-ubuntu-latest-x64-2x` and `warp-ubuntu-latest-x64-32x` both mount 150GB SSD, so resizing from 4 vCPU to 16 vCPU shortens the build without changing the space available to it ([cloud runners documentation](/docs/ci/cloud-runners)). The volume changes when the platform changes: 256GB on Windows labels, 270GB on `warp-macos-26-arm64-12x`. Runner storage is ephemeral and is deleted when the runner terminates, so nothing carried over between jobs is holding the space either. On BYOC, instance types that ship local NVMe SSDs have those disks detected, formatted, and mounted at the runner work directory at boot on Linux images ([local SSD documentation](/docs/ci/features/local-ssd)), which is the largest volume available without leaving your own cloud account. Disk sizes for every runner label are set out in [how much disk GitHub Actions runners have](/answers/do-github-actions-runners-have-large-disks). ## [Related Questions](#related-questions)Copy Anchor Link **Does a larger runner size give me more disk for a Docker build?** Not on Linux. Every WarpBuild Linux x64 and Linux ARM64 label carries 150GB SSD from 2 vCPU up to 32 vCPU, so a size bump buys vCPU and RAM and leaves the volume unchanged ([cloud runners documentation](/docs/ci/cloud-runners)). Windows labels carry 256GB and `warp-macos-26-arm64-12x` carries 270GB, and the [builder profile](/runners/docker-builders) is the lever that actually moves disk. **Which prune command frees the most space during a Docker build?** Usually `docker builder prune`, because the BuildKit cache holds every intermediate stage the build produced. Run `docker system df` first to see the split between images, containers, local volumes, and build cache, then prune the largest line. Pruning the build cache also discards the layers the next build would have reused. **Does a remote Docker builder remove the disk ceiling completely?** No, it moves the ceiling to the builder. Profiles run from 100GB of disk on the 16 vCPU size up to 2TB on the 96 and 192 vCPU sizes ([Docker builders documentation](/docs/ci/docker-builders)), and the runner keeps only the checkout and the build context as long as the image is pushed from the builder rather than loaded back with `load: true`. Pick a profile size from the [remote Docker builder catalog](/runners/docker-builders), price the change against your own build minutes on the [pricing page](/pricing), and read the [Docker builds on GitHub Actions solution page](/solutions/docker-builds-github-actions) for the full workflow swap. --- # Why Does My GitHub Actions Cache Miss Every Run? | WarpBuild URL: https://www.warpbuild.com/answers/why-does-my-github-actions-cache-miss-every-run Description: A GitHub Actions cache misses every run for three reasons: the key string changed, the version hash changed, or the entry was written on another branch. # Why Does My GitHub Actions Cache Miss Every Run? A GitHub Actions cache misses every run for three reasons: the key string changed, the version hash changed, or the entry was written on another branch. Last verified: 2026-08-13 A GitHub Actions cache misses on every run for one of three reasons: the key string is different on every run, the version hash is different because the compression tool or the cached paths changed, or the entry you want was written on a branch this run cannot read. Read the resolved key first, because a key built from a commit SHA, a run ID, a timestamp, or a lockfile that churns on every merge produces a permanent miss that no amount of cache storage will fix. ## [Answer](#answer)Copy Anchor Link The cache action matches on three things at once. Per the [WarpBuild caching documentation](/docs/ci/features/caching), an entry is scoped to the `key`, the version, and the branch. A run restores an entry only when all three line up. Change any one and the lookup returns nothing, and the job pays the full cost of dependency download and compilation again. Start from the symptom. Each row below points at one of the three scopes. | What you see in the job log | Cause | Where to confirm it | | ----------------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------- | | cache-hit is false and the restore step downloads nothing, on every run | The key string changes every run | Expand the restore step and read the resolved key on two consecutive runs | | The restore step downloads an entry and cache-hit is still false | A restore-keys prefix match | Documented output behavior: cache-hit is true only on an exact key match | | Hits on one runner label, misses on another with the same key | The version hash differs | Compare runner OS and architecture between the two jobs | | Hits on the default branch, misses on pull requests | Branch scoping | Check which branch wrote the entry | | Hits for a week, then goes cold with no workflow change | Expiry after 7 days of last use | Covered in [how long GitHub Actions cache entries last](/answers/how-long-do-github-actions-caches-last) | Two of these are reporting problems rather than cache problems. A `restore-keys` match is a real restore that reports `cache-hit` as `false`, and a job that reruns its install step on that signal looks like a permanent miss in the timings while the files were already on disk. Read the restore step output before changing the key. ## [Detail](#detail)Copy Anchor Link Every fact below comes from the [WarpBuild caching documentation](/docs/ci/features/caching), the [setup actions documentation](/docs/ci/features/caching/setup-actions), the public [WarpBuilds/cache repository](https://github.com/WarpBuilds/cache), or the per-minute rates on the [WarpBuild pricing page](/pricing), checked on 2026-08-13. ### [The key changes on every run](#the-key-changes-on-every-run)Copy Anchor Link A cache key is a plain string built from GitHub Actions contexts and functions. Three patterns guarantee a miss forever. A key containing `${{ github.sha }}` or `${{ github.run_id }}` is unique per run by construction. The save succeeds, the next run computes a different key, and the entry is never read again. A key containing a date segment rolls once per day. That is deliberate when you want a daily refresh, and it is a surprise when someone added it to force one cache rebuild and left it in. A key built from `hashFiles('**/package-lock.json')` behaves correctly and can still miss on every run. The hash is stable only when the lockfile is stable. A repository that regenerates its lockfile during release, writes a build identifier into it, or has a bot bumping dependencies several times a day changes the hash faster than the cache can be reused. The check takes one minute. Open two consecutive runs of the same workflow on the same branch, expand the restore step in both, and compare the resolved key line. If the strings differ and no dependency changed, the key is the cause. ### [A prefix match still reports a miss](#a-prefix-match-still-reports-a-miss)Copy Anchor Link `restore-keys` is an ordered list of prefixes. When the primary key finds nothing, the action walks the list in order and restores the most recent entry whose key starts with one of those prefixes. That restore puts real files on disk. The `cache-hit` output stays `false` for that case. It is set to `true` on an exact match against the primary key alone. Any step guarded with `if: steps..outputs.cache-hit != 'true'` therefore reruns after a prefix match, which is the recommended behavior for an install step, because a partial match usually means the lockfile moved and some packages are missing. The failure mode is guarding an expensive build step the same way. A compile that would have been incremental against the restored artifacts starts from zero on every run because the boolean says miss. ### [The version hash covers the compression tool and the paths](#the-version-hash-covers-the-compression-tool-and-the-paths)Copy Anchor Link The version is a hash over the compression tool available on the runner and the `path` list being cached. Entries with different versions are different entries even when the key strings are identical. Two consequences show up in real workflows. Cross-platform restores fail by design. An entry saved on `warp-macos-14-arm64-6x` cannot be restored on `warp-ubuntu-latest-x64-4x`. Caching is enabled by default on Linux runners and is not supported on Windows runners. Editing the `path` list invalidates everything. A pull request that adds one directory to `path` changes the version hash, so every entry written before that merge becomes unreachable. The workflow looks unchanged in review and every job goes cold for a full cycle. The same mechanism explains the container case. A container image without `zstd` makes the action fall back to gzip, the version no longer matches the entry written with `zstd`, and the log prints a `zstd version: null` warning followed by a 404. ### [Branch scoping](#branch-scoping)Copy Anchor Link The third scope is the branch. An entry written by a job on a feature branch belongs to that branch. GitHub's [cache key matching rules](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows) govern which branches can read it, and the practical shape is that pull request branches read entries written on the base branch and cannot read each other. That produces a clean signal: warm on `main`, cold on every pull request. The fix is to make sure the workflow that writes the cache runs on pushes to the default branch, so pull requests have a base entry to fall back to through `restore-keys`. ### [A corrected workflow](#a-corrected-workflow)Copy Anchor Link This version fixes all three scopes at once. The key names the tool and the lockfile hash, `runner.os` and `runner.arch` keep platforms in separate namespaces, the ladder gives pull requests a fallback, and the save step runs only when the restore came up short. Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore dependency cache id: deps uses: WarpBuilds/cache/restore@v1 with: path: | ~/.npm node_modules key: npm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/package-lock.json') }} restore-keys: | npm-${{ runner.os }}-${{ runner.arch }}- - name: Install dependencies if: steps.deps.outputs.cache-hit != 'true' run: npm ci - name: Save dependency cache if: steps.deps.outputs.cache-hit != 'true' uses: WarpBuilds/cache/save@v1 with: path: | ~/.npm node_modules key: ${{ steps.deps.outputs.cache-primary-key }} - run: npm test ``` Four properties are worth naming. `WarpBuilds/cache@v1` and its `restore` and `save` subactions are drop-in replacements for `actions/cache@v4`, so the migration is the `uses` line. The `cache-primary-key` output carries the key the restore step computed, which keeps the save key identical to the restore key without repeating the expression. The save runs only on a miss, so a warm run performs one operation instead of two. And the `path` list is now something to treat as frozen: any edit to it rolls the version hash for the whole repository. Most language toolchains need none of this. The WarpBuild forks of the common setup actions carry the cache client already, and they use WarpBuild Cache automatically on WarpBuild runners: `WarpBuilds/setup-node@v6`, `setup-python@v6`, `setup-go@v6`, `setup-java@v5`, `setup-dotnet@v4`, `setup-ruby@v1.295.0`, `setup-zig@v2`, `rust-cache@v2`, `gradle-actions/setup-gradle@v5`, and `mise-action@v2`. Full inputs are in the [setup actions documentation](/docs/ci/features/caching/setup-actions). Copy Text ``` - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm ``` The Node case has its own layout questions, covered in [how to cache node\_modules in GitHub Actions](/answers/how-do-i-cache-node-modules-in-github-actions). Docker layer caches invalidate on a different set of rules and belong to the [Docker layer cache invalidation guide](/guides/docker-cache-invalidation-github-actions). ### [What a permanent miss costs](#what-a-permanent-miss-costs)Copy Anchor Link Cache work is billed apart from runner minutes, and the rates are free on BYOC: | Item | Hosted rate | Unit | BYOC | | ----------------------------- | ----------- | ------------- | ---- | | Cache storage | $0.20 | per GB-month | Free | | Cache write, restore, or list | $0.0001 | per operation | Free | Worked monthly model, with illustrative step durations you should replace with your own. Take a repository running 3,000 jobs a month on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, where the install step takes 4 minutes cold and 30 seconds warm, and the cache entry holds 2 GB. * Permanent miss: 3,000 x 4 = 12,000 minutes at $0.008 = $96.00 per month, plus 3,000 write operations at $0.0001 = $0.30, for $96.30. * Working cache: 3,000 x 0.5 = 1,500 minutes at $0.008 = $12.00, plus 2 GB at $0.20 = $0.40 of storage, plus 3,200 operations at $0.0001 = $0.32, for $12.72. * Difference on the same job count: $83.58 per month, from one key expression. Rates come from the [WarpBuild pricing page](/pricing), checked on 2026-08-13. An entry that always misses still bills storage until it expires, which is the argument for fixing the key rather than raising the size. Sizing and eviction are covered in the [guide to the GitHub Actions cache size limit](/guides/github-actions-cache-size-limit). When the restore step reports a hit and the files are still absent from the working directory, the path list and the working directory disagree. The Action Debugger opens an SSH session on a paused runner so you can list the restored paths directly, which settles that case faster than another round of log reading. ## [Related Questions](#related-questions)Copy Anchor Link ### [Why does the cache miss when the job runs inside a container?](#why-does-the-cache-miss-when-the-job-runs-inside-a-container)Copy Anchor Link The restore step needs `wget` to download the entry and `zstd` to decompress it. A container image without `zstd` makes the action fall back to gzip, which prints a `zstd version: null` warning and then a 404, because the compression tool is part of the version hash and the gzip version never matches the zstd entry. Install `wget` and `zstd` in the container image and pass `WARPBUILD_RUNNER_VERIFICATION_TOKEN` through to the container, as shown in the [caching documentation](/docs/ci/features/caching). ### [Why does the cache step fail to commit on a Docker build?](#why-does-the-cache-step-fail-to-commit-on-a-docker-build)Copy Anchor Link `Failed to commit cache` is the documented result of large Docker layers on a small runner. A 2x runner with layers larger than about 5GB hits it reliably. Move the job to a larger label such as `warp-ubuntu-latest-x64-16x`, or take the layer cache off the runner entirely with a remote Docker builder, which holds a persistent layer cache on a dedicated build VM. Rates for both are on the [pricing page](/pricing). ### [Does a restore-keys match count as a cache hit?](#does-a-restore-keys-match-count-as-a-cache-hit)Copy Anchor Link No. The `cache-hit` output is `true` only on an exact match against the primary key. A prefix match through `restore-keys` restores an entry and still reports `false`, so a step guarded on `cache-hit` reruns even though the files are already on disk. That is the right behavior for an install step and the wrong behavior for an incremental build step. Read the restore step log to tell a partial restore from an empty one. ### [Why can a macOS cache entry not restore on a Linux runner?](#why-can-a-macos-cache-entry-not-restore-on-a-linux-runner)Copy Anchor Link The version hash covers the compression tool and the list of cached paths, so an entry saved on `warp-macos-14-arm64-6x` carries a different version from an entry saved on `warp-ubuntu-latest-x64-4x` and the two never match. Put `runner.os` and `runner.arch` in the key so the mismatch shows up as a clean miss on a separate key instead of a 404 warning inside a shared one. Entry lifetime is covered in [how long GitHub Actions cache entries last](/answers/how-long-do-github-actions-caches-last). --- # Why Does My Job Show High IO Wait? | WarpBuild URL: https://www.warpbuild.com/answers/why-does-my-job-show-high-io-wait Description: A job shows high IO wait when the processor sits idle while storage catches up. Read disk I/O against CPU for the same window, then move the work to local disk. # Why Does My Job Show High IO Wait? A job shows high IO wait when the processor sits idle while storage catches up. Read disk I/O against CPU for the same window, then move the work to local disk. ## [Answer](#answer)Copy Anchor Link A GitHub Actions job shows high IO wait when the processor sits idle while the kernel waits for a storage device to answer, so the job spends wall clock without spending cores. On a runner that pattern almost always traces back to large writes landing on a network-attached volume, or to a cache restore unpacking a big archive into the work directory. There is no chart named `iowait` in the runner metrics view. The shape that identifies the problem is disk I/O climbing toward its ceiling while CPU utilization stays low or moderate over the same window. WarpBuild records disk I/O as the maximum rolling average of read plus write throughput over the last 30 seconds and marks an instance High Disk IO once max disk I/O reaches 80 percent of supported throughput ([observability documentation](/docs/ci/features/observability), checked on 2026-08-13). | Charts across the slow window | Reading | First move | | ---------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------- | | Disk I/O at or above 80 percent of supported throughput, CPU low | Bound by bytes | Put the bytes on local disk, then write fewer of them | | Disk I/O moderate, CPU low, step still long | Bound by operations, many small files | Cut file count in caches and unpacked layers | | Disk I/O high and CPU high together | The build is doing real work | Treat it as a sizing question and read the CPU chart | | No charts at all | Job under about 1 minute, or collection paused | Sample inside the job with vmstat | Two collection rules decide whether the charts exist. Metrics and logs are collected only for jobs longer than about 1 minute, and collection can be paused for an organization, in which case the Usage view shows the telemetry agent initializing with no data behind it ([observability documentation](/docs/ci/features/observability)). The fleet-wide version of this reading, aggregated by repository, workflow, job, and instance type, is on the [GitHub Actions observability page](/solutions/github-actions-observability). The fixes run in one order: local storage first, a smaller cache second, prepared machine state third. ## [Detail](#detail)Copy Anchor Link ### [Confirm it inside the job](#confirm-it-inside-the-job)Copy Anchor Link Sample the `wa` column while the job runs, then print the worst samples at the end. This works on any Linux runner and costs one background process. Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Start sampling run: nohup vmstat 5 120 > /tmp/vmstat.log 2>&1 & - run: npm ci - run: npm run build - name: Highest IO wait samples if: always() run: | head -2 /tmp/vmstat.log tail -n +3 /tmp/vmstat.log | sort -k16 -n | tail -5 ``` The `wa` column is the share of CPU time spent waiting on storage. Line the timestamps of the worst samples up against step boundaries in the job log: if they sit inside the cache restore step or a `docker pull`, that step is the source, and no amount of extra vCPU changes it. The [guide to runner level metrics](/guides/github-actions-runner-metrics) covers the same reading from the dashboard and from the API rather than from inside the job. ### [Three sources produce almost all of it](#three-sources-produce-almost-all-of-it)Copy Anchor Link **Cache unpacking.** A restore reads a compressed archive and writes every file in it. Decompression runs on one core while the writes queue behind it, which is the textbook low CPU with pinned disk shape. Caches that outgrow their budget make this worse on both ends: GitHub caps a repository at 10 GB across all entries and evicts entries that have not been accessed for seven days ([GitHub dependency caching docs](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13), so a fat cache both takes longer to unpack and misses more often. **Container layer writes.** Every pulled layer is decompressed and written into the overlay filesystem before the container starts. A base image with a few hundred megabytes of dependencies writes those bytes on every job. **A build tool writing many small files.** A dependency install creating 200,000 files, a compiler emitting object files, or a test runner writing coverage fragments spends its time in per-file syscalls. Throughput can look unremarkable while the job still waits, which is why file count matters as much as byte count. ### [Fix one: keep the work on local disk](#fix-one-keep-the-work-on-local-disk)Copy Anchor Link The highest IO wait numbers come from work directories that are not local disk at all. On BYOC runners, pick an instance type with local NVMe and the runner does the rest: it detects the local SSDs at boot, creates a RAID-0 array when there is more than one, formats with ext4, and mounts the result at the runner work directory such as `/home/runner/work`, all before the job starts and with no configuration ([local SSD documentation](/docs/ci/features/local-ssd), checked on 2026-08-13). Candidate families are `c5d`, `m5d`, `r5d`, `i3`, `c6id`, `m6id`, and `r6id` on AWS, the `-lssd` machine types on GCP, and `Lsv3` or `Lasv3` on Azure. Two limits belong in the same breath. Local SSD auto-mount is available on Linux images only, so Windows and macOS runner types keep the regular boot disk. The mounted storage is ephemeral and dies with the instance, which is the expected contract for a runner. The [local SSD runner page](/runners/local-ssd) lists the setup end to end. On hosted runners the work directory is already local: every Linux x64 size in the catalog ships 150GB SSD, from `warp-ubuntu-latest-x64-2x` at $0.004 per minute to `warp-ubuntu-latest-x64-32x` at $0.064 per minute ([pricing page](/pricing), checked on 2026-08-13). Storage capacity does not change with the label, so sizing up to solve a storage problem buys cores that stay idle. ### [Fix two: write fewer bytes and fewer files](#fix-two-write-fewer-bytes-and-fewer-files)Copy Anchor Link Narrow the cached paths to the ones that pay back the unpack. Leave build output directories out, split one large cache into two so a job restores only what it needs, and stop archiving directories that rebuild in seconds. Work the arithmetic on a 6 GB cache restored by 400 jobs a month, using [WarpBuild list prices](/pricing) checked on 2026-08-13: | Line | Rate | Monthly | | ------------------------------ | ---------------------------------------------- | ---------------------------------- | | Cache storage | $0.20 per GB-month | 6 x $0.20 = $1.20 | | Cache write or restore | $0.0001 per operation | 420 x $0.0001 = $0.04 | | Runner minutes spent unpacking | $0.008 per minute on warp-ubuntu-latest-x64-4x | 400 x 1.5 minutes x $0.008 = $4.80 | The storage line is rounding error and the minutes line is the bill. Substitute the restore duration from your own job timing for the 1.5 minutes above; the ratio holds. Trimming that cache from 6 GB to 2 GB removes bytes from the disk queue and minutes from the invoice at the same time. The [answer on slow cache restores](/answers/why-is-my-cache-restore-slow-in-github-actions) covers key design, compression, and restore-key fallbacks in detail. ### [Fix three: prepared machine state](#fix-three-prepared-machine-state)Copy Anchor Link The fastest restore is the one that never runs. Snapshot runners capture a runner VM mid-workflow and boot later jobs from that snapshot, so dependencies are already on disk when the job starts. Add `snapshot.enabled=true` to create from the base image, or `snapshot.key=` to boot from an existing snapshot. Snapshots are supported on WarpBuild Cloud Ubuntu runners, are deleted after 15 days, and do not persist `/tmp` ([snapshot runner documentation](/docs/ci/features/snapshot-runners), checked on 2026-08-13). Price it against the setup it replaces, at [list prices](/pricing) checked on 2026-08-13\. A job whose setup steps take 8 minutes on `warp-ubuntu-latest-x64-4x` spends 8 x $0.008 = $0.064 per job, or $128.00 across 2,000 jobs a month. Snapshot restore bills $0.04 per job, or $80.00 across the same 2,000 jobs, plus snapshot storage at $0.025 per snapshot-hour, which is $18.00 for one snapshot held through a 720 hour month. That is $98.00 against $128.00, and the wall clock saved is whatever those setup steps show in your job timing today. Image layer writes have their own version of this. Remote Docker builders keep the layer cache on the builder machine's own disk, so the runner never unpacks layers to prove that a build is repeatable. ### [What acting on this costs](#what-acting-on-this-costs)Copy Anchor Link Full rates by runner type are on the [pricing page](/pricing). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does the runner metrics view have an IO wait chart?](#does-the-runner-metrics-view-have-an-io-wait-chart)Copy Anchor Link No. The five recorded metrics are CPU, memory, filesystem, disk I/O, and network utilization. Disk I/O is the maximum rolling average of read plus write throughput over the last 30 seconds, and IO wait shows up as that series climbing while CPU utilization stays low across the same window. The Recommendations view labels an instance High Disk IO once max disk I/O reaches 80 percent of supported throughput, per the [observability documentation](/docs/ci/features/observability). ### [Disk I/O looks moderate but the step still waits on storage. What is happening?](#disk-io-looks-moderate-but-the-step-still-waits-on-storage-what-is-happening)Copy Anchor Link The job is bound by operations rather than by bytes. A dependency install or an artifact unpack that writes 200,000 small files spends its time on per-file syscalls, so throughput never plateaus while the `wa` column stays high. Cut the file count first: narrower cache paths, fewer extracted layers, and build output directories left out of the archive. The [guide to runner level metrics](/guides/github-actions-runner-metrics) shows how to read those two cases apart per job. ### [Will a bigger runner label fix high IO wait?](#will-a-bigger-runner-label-fix-high-io-wait)Copy Anchor Link Only when the cores are also busy. A larger label buys vCPU and RAM, and every Linux x64 size in the WarpBuild catalog carries the same 150GB SSD, so a job that idles at 20 percent CPU while writing a 6 GB cache to disk idles the same way on a wider machine. Move the bytes to local disk with a [local SSD runner](/runners/local-ssd), then remove bytes, then remove the restore entirely with prepared machine state. --- # Why Does the Gradle Daemon Start on Every Run? | WarpBuild URL: https://www.warpbuild.com/answers/why-does-the-gradle-daemon-start-every-run Description: A GitHub Actions job gets a new virtual machine, so no Gradle daemon survives to reuse. The startup cost breakdown, the settings that cut it, and the numbers. # Why Does the Gradle Daemon Start on Every Run? A GitHub Actions job gets a new virtual machine, so no Gradle daemon survives to reuse. The startup cost breakdown, the settings that cut it, and the numbers. ## [Answer](#answer)Copy Anchor Link The Gradle daemon starts on every GitHub Actions run because there is no daemon left over to connect to: [each GitHub-hosted runner is a new virtual machine](https://docs.github.com/en/actions/concepts/runners/github-hosted-runners), and the same is true of a WarpBuild cloud runner, so the first `./gradlew` invocation of the job has to start one. That cost repeats on every run unless the machine state itself is carried between runs, because a daemon is a process on one host and no workflow setting can move a process to the next host. On a laptop the arrangement works the other way. [The daemon is a long-lived background process that hosts Gradle's execution engine](https://docs.gradle.org/current/userguide/gradle%5Fdaemon.html), it has been enabled by default since Gradle 3.0, and an idle daemon stays alive for 3 hours, so the second build of the afternoon connects to the first build's process and reuses its in-memory caches of plugin classes and task input hashes. A GitHub Actions job never gets a second build on the same machine, so the daemon is started, used once, and thrown away with the runner. Passing [\--no-daemon](https://docs.gradle.org/current/userguide/gradle%5Fdaemon.html) does not remove the cost. Gradle still runs the build in a JVM, either the client JVM when its arguments match what the build asks for or a single-use process that exits at the end, so JVM startup and the configuration phase are still paid. What the flag gives up is the in-memory caching a daemon would have held for a later build. Three separate costs hide behind the phrase "the daemon starts every run", and they respond to different fixes: | Cost | What happens | What carries it between runs | | --------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | Daemon start | JVM boot, plugin classloading, no JIT profile yet | Nothing. A process cannot be restored | | Configuration phase | Init scripts, the settings script, build scripts and plugins are evaluated to produce the task graph | The configuration cache entry in PROJECT/.gradle/configuration-cache | | Dependency resolution | Wrapper distribution and the dependency graph are downloaded into \~/.gradle | The Gradle User Home cache, or a restored runner disk | Only the first row is truly unavoidable. The other two are repeated work that the configuration cache, the build cache and a prepared machine can each remove. ## [Detail](#detail)Copy Anchor Link ### [The configuration phase is the part worth attacking](#the-configuration-phase-is-the-part-worth-attacking)Copy Anchor Link [Gradle's configuration cache](https://docs.gradle.org/current/userguide/configuration%5Fcache.html) turns on with `org.gradle.configuration-cache=true` in `gradle.properties` or `--configuration-cache` on the command line. On a hit, the entire configuration phase is skipped: init scripts, the settings script, build scripts and plugins are not re-evaluated, and Gradle restores the task graph from the entry instead. The catch on GitHub Actions is where the entry lives. It is written to `PROJECT/.gradle/configuration-cache` inside the repository checkout, and it is encrypted on disk with a machine-specific key held in the Gradle User Home. A cache action that restores `~/.gradle` therefore brings back the dependency graph and leaves the configuration cache behind, which is why a job can report a full dependency cache hit and still spend a minute configuring a multi-module build. The [build cache](https://docs.gradle.org/current/userguide/build%5Fcache.html) is the third setting and covers a different phase again. `org.gradle.caching=true` lets Gradle reuse task outputs whose inputs have not changed, with the local cache stored under the Gradle User Home. Configuration cache, build cache and dependency cache are three separate stores, and a build that enables one and expects the others to follow is the usual source of the "caching is on and nothing got faster" report. The [Gradle configuration cache guide](/guides/gradle-configuration-cache-github-actions) covers the entry-reuse rules and the build logic patterns that invalidate an entry on every run. ### [The configuration that removes the repeated work](#the-configuration-that-removes-the-repeated-work)Copy Anchor Link Copy Text ``` # gradle.properties org.gradle.configuration-cache=true org.gradle.caching=true org.gradle.parallel=true org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g ``` Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: "21" - uses: WarpBuilds/gradle-actions/setup-gradle@v5 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - run: ./gradlew build ``` `cache-encryption-key` is the input that lets configuration cache data be stored alongside the Gradle User Home; the upstream action documents it as a base64 AES key, generated once with `openssl rand -base64 16` and held as a repository secret. `cache-read-only` keeps pull request builds restoring the entry the default branch published rather than writing one entry per open branch. No cache backend appears in that file because the `WarpBuilds/*` forks bundle the cache client and use WarpBuild Cache automatically on a WarpBuild runner, as the [setup actions documentation](/docs/ci/features/caching/setup-actions) describes. The cache inputs and the per-language forks are listed on [how to cache Gradle builds in GitHub Actions](/answers/how-do-i-cache-gradle-builds-in-github-actions). ### [The prepared machine option](#the-prepared-machine-option)Copy Anchor Link When configuration time dominates and the entry keeps missing, the next lever is to stop starting from a clean disk. [Snapshot runners](/docs/ci/features/snapshot-runners) boot a job from a saved image of an earlier run's disk, so the checkout, the Gradle User Home and the `PROJECT/.gradle/configuration-cache` directory all arrive already populated: Copy Text ``` runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=gradle-main' }} ``` Four constraints shape how far this goes. The daemon process still starts from scratch after the boot, so what comes back is disk state. Snapshot boots take 45 to 60 seconds. Snapshots are deleted after 15 days, and `/tmp` is not preserved. Snapshot labels are honored only on cloud Ubuntu runners, so an Android release build pinned to macOS cannot use them. One more habit matters here: a `git clean -ffdx` step before the snapshot deletes `PROJECT/.gradle/configuration-cache` along with the credentials it was meant to remove, so prefer targeted removals of credential paths. ### [What the repeated work costs](#what-the-repeated-work-costs)Copy Anchor Link Ubuntu x64 rates from the [pricing page](/pricing), checked on 2026-08-13: | Runner label | vCPU | RAM | Price per minute | | -------------------------- | ---- | ------ | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 | Snapshot line items on the same page, checked the same day: $0.04 per restore and $0.025 per snapshot-hour. Now a worked model with the assumptions stated, so you can substitute your own. Take a repository with 800 workflow runs per month on `warp-ubuntu-latest-x64-8x`, and suppose daemon start plus configuration accounts for 40 seconds of each run. * Repeated startup time: 800 runs at 40 seconds is 8.9 hours, or 533 minutes. * At $0.016 per minute, that is $8.53 per month. * One snapshot alias held for a 30 day month is 720 snapshot-hours at $0.025, or $18.00, plus 800 restores at $0.04, or $32.00, for $50.00 per month. The $8.53 line is what the configuration cache is competing for, and it is close to free to enable. The $50.00 snapshot line has to buy back 3,125 minutes at the 8 vCPU rate, which is 3.9 minutes per run, so a snapshot earns its place on repositories where dependency resolution and a cold local build cache cost minutes rather than seconds. Measure before choosing. `./gradlew build --profile` writes an HTML report under `build/reports/profile` that separates configuration time from task execution time, which tells you whether the 40 seconds above is 5 seconds or 90 in your repository. Sizing, worker counts and daemon heap for the label you land on are covered on [Gradle build caches on GitHub Actions runners](/solutions/gradle-github-actions). ## [Related Questions](#related-questions)Copy Anchor Link ### [Does passing --no-daemon make a GitHub Actions build faster?](#does-passing---no-daemon-make-a-github-actions-build-faster)Copy Anchor Link No. Gradle still runs the build in a JVM, either the client JVM when its arguments match the build requirements or a single-use process that exits at the end, so JVM startup and the configuration phase are still paid. The flag only gives up the in-memory caching a daemon would have held for a second build that never happens on a fresh runner. [Gradle build caches on GitHub Actions runners](/solutions/gradle-github-actions) covers the daemon heap and worker settings that do change the number. ### [Can I keep a Gradle daemon alive between GitHub Actions jobs?](#can-i-keep-a-gradle-daemon-alive-between-github-actions-jobs)Copy Anchor Link No. A daemon is a process on one machine, and each GitHub-hosted runner is a new virtual machine, so nothing in a workflow file can move a running process to the next job. A [snapshot runner](/docs/ci/features/snapshot-runners) brings back the disk that the last run left behind, which covers the Gradle User Home and the configuration cache entry, while the daemon process itself still starts from scratch after the boot. ### [Does the configuration cache survive between runs on GitHub Actions?](#does-the-configuration-cache-survive-between-runs-on-github-actions)Copy Anchor Link Only if something carries the project directory. Entries live in `PROJECT/.gradle/configuration-cache`, which the Gradle User Home cache actions never touch, so the two ways to keep an entry are the `cache-encryption-key` input on `setup-gradle` or a snapshot runner that restores the whole disk. The [Gradle configuration cache guide](/guides/gradle-configuration-cache-github-actions) walks through both, and [how to cache Gradle builds in GitHub Actions](/answers/how-do-i-cache-gradle-builds-in-github-actions) covers the dependency side. --- # Why Is My Cache Restore Slow? | WarpBuild URL: https://www.warpbuild.com/answers/why-is-my-cache-restore-slow-in-github-actions Description: Cache restore time tracks archive size and the link between runner and store. Break-even arithmetic, three trims that shrink the entry, and how to measure it. # Why Is My Cache Restore Slow? Cache restore time tracks archive size and the link between runner and store. Break-even arithmetic, three trims that shrink the entry, and how to measure it. A cache restore is a network transfer followed by a decompression, so its duration tracks two things: the size of the compressed archive and the speed of the link between the runner and the cache store. Once the archive grows past the point where moving those bytes costs more runner minutes than regenerating the files would, the entry loses time on every job that restores it. ## [Answer](#answer)Copy Anchor Link Both halves of the round trip are billed as runner minutes, because the runner sits idle waiting on the transfer. A workflow that restores 8 GB at the start of the job and writes it back in the post-run save step is paying for the same bytes twice per run, whether or not the build used them. Write the rule down before touching any keys. Let `A` be the compressed archive size, `R` the effective restore rate you measure end to end (download plus decompression), and `B` the wall clock time to rebuild the same paths from scratch. On a run that only restores, the entry is worth having when `B > A / R`. On a run that restores and then saves, the same bytes move twice, so the threshold doubles to `B > 2A / R`. The table below works that arithmetic at two assumed rates. Substitute the rate from your own job log using the measurement steps in the next section; 1 GB is treated as 1000 MB throughout, and the runner cost column prices the restore on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, from the [pricing page](/pricing), checked on 2026-08-13. | Compressed archive | Restore at 100 MB/s | Restore at 250 MB/s | Rebuild time needed to break even (restore-only run, 100 MB/s) | Break-even on a run that also saves | Runner cost of one restore at 100 MB/s | | ------------------ | ------------------- | ------------------- | -------------------------------------------------------------- | ----------------------------------- | -------------------------------------- | | 200 MB | 2 s | 0.8 s | over 2 s | over 4 s | $0.0003 | | 1 GB | 10 s | 4 s | over 10 s | over 20 s | $0.0013 | | 3 GB | 30 s | 12 s | over 30 s | over 60 s | $0.0040 | | 5 GB | 50 s | 20 s | over 50 s | over 100 s | $0.0067 | | 10 GB | 100 s | 40 s | over 100 s | over 200 s | $0.0133 | | 20 GB | 200 s | 80 s | over 200 s | over 400 s | $0.0267 | Two readings follow from the shape of that table. Small entries clear the bar easily, which is why a 200 MB package manager download cache almost always pays for itself. Large entries need a rebuild step measured in minutes to justify themselves, and most repositories that restore 10 GB or more are carrying directories the build could regenerate in seconds. Hit rate multiplies the whole calculation. An entry restored on 40 percent of runs delivers 40 percent of the saving while paying its transfer cost on every run that writes it, so [measuring hit rate](/answers/how-do-i-measure-cache-hit-rate-in-github-actions) belongs in the same pass as measuring restore time. ## [Detail](#detail)Copy Anchor Link ### [Measure the restore before you change anything](#measure-the-restore-before-you-change-anything)Copy Anchor Link Three sources give you the numbers, and none of them requires a new tool. The GitHub Actions run view is the per-step timing view. Each step in a job carries its own elapsed time, including the restore step and the post-run save step that GitHub appends automatically for cache actions, which appears at the bottom of the step list named after the step that created it. Read the restore, the save, and the install or build step that regenerates the same paths off a single run, and the comparison the break-even rule needs is already on screen. For the detail inside a step, rerun with [debug logging enabled](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging). Entry sizes come from the GitHub CLI, sorted heaviest first: Copy Text ``` gh cache list --sort size_in_bytes --limit 30 ``` Job-level trends come from the WarpBuild [Reports page](/docs/ci/features/reports). The Jobs section aggregates every unique repository, workflow, and job name combination with Duration P75 and P90 and Queue Time P75 and P90, and every tab exports to CSV. That is the surface for answering whether last week's cache change moved the P90, rather than whether one run looked better. CI observability is part of the WarpBuild product surface alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger, and CPU and memory columns in Reports require observability to be enabled. ### [Trim one: stop caching what the build regenerates](#trim-one-stop-caching-what-the-build-regenerates)Copy Anchor Link The largest entries usually hold two kinds of files: downloads that came over the network once, and outputs a local command can rebuild. Cache the first kind, drop the second. For Node, that means caching `~/.npm` rather than `node_modules`. The download cache is compact and stable, and `npm ci` reconstructs the tree from it without a network round trip. The same split applies elsewhere: the Go module cache rather than the build output tree, the Gradle dependency cache rather than every `build/` directory, the pip wheel cache rather than the virtual environment. Log directories, test reports, coverage output, and editor artifacts add bytes to every restore and are never read by the next run. Exclusions are worth checking after each change, because a path pattern that once matched 400 MB can quietly match 4 GB a year later. ### [Trim two: split one entry into two](#trim-two-split-one-entry-into-two)Copy Anchor Link A single key covering dependencies and build output means one changed lockfile rewrites the whole archive. Splitting the paths into two entries with separate keys means the slow-moving half stays warm while the fast-moving half rotates, and jobs that need only one half restore only that half. Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Restore dependency downloads id: deps uses: WarpBuilds/cache@v1 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} restore-keys: | npm-${{ runner.os }}- - name: Restore build output uses: WarpBuilds/cache@v1 with: path: .next/cache key: next-${{ runner.os }}-${{ github.sha }} restore-keys: | next-${{ runner.os }}- - run: npm ci - run: npm run build ``` `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` and takes the same `path`, `key`, and `restore-keys` inputs, per the [caching documentation](/docs/ci/features/caching). Two inputs matter for restore time specifically. `lookup-only: true` checks whether an entry exists without downloading it, which suits a gate job that decides whether later jobs need to rebuild. `fail-on-cache-miss: true` turns a silent fallback into a failed step, which is the right setting on a job that should never rebuild from source. ### [Trim three: drop the stale entries](#trim-three-drop-the-stale-entries)Copy Anchor Link Keys built on a lockfile hash write a full new archive whenever the lockfile changes, and the superseded copies stay in storage until they age out. Worse, a `restore-keys` prefix fallback can restore one of those older copies into a working directory, after which the save step writes the union back under the new key. Entries grow generation over generation and restores get slower with no change to the repository. Three fixes apply. Delete entries you know are dead, using the `delete-cache` input on `WarpBuilds/cache` or the WarpBuild console. Narrow the `restore-keys` prefix so a fallback can only match a recent generation. Let time do the rest: WarpBuild cache entries expire 7 days after their last use, per the [caching documentation](/docs/ci/features/caching), so an abandoned key clears itself. The [cache size limit guide](/guides/github-actions-cache-size-limit) covers the related failure, where GitHub's default 10 GB per-repository ceiling evicts entries in least recently used order and a key that hit yesterday misses today. ### [What the archive costs per month](#what-the-archive-costs-per-month)Copy Anchor Link Storage is the smaller line. The runner minutes spent moving the archive are the larger one. Both come from the [pricing page](/pricing), checked on 2026-08-13: cache storage is $0.20 per GB-month, cache write and restore operations are $0.0001 each, and both are free on BYOC runners. | Compressed archive | Storage per month | Restore minutes per 1,000 jobs at 100 MB/s | Runner cost of those minutes on warp-ubuntu-latest-x64-4x | | ------------------ | ----------------- | ------------------------------------------ | --------------------------------------------------------- | | 1 GB | $0.20 | 167 | $1.33 | | 5 GB | $1.00 | 833 | $6.67 | | 10 GB | $2.00 | 1,667 | $13.33 | | 20 GB | $4.00 | 3,333 | $26.67 | Cache operations add $0.20 per 1,000 jobs at two operations per job, one restore and one save, and that figure does not move with archive size. At 20 GB the runner minutes cost about six times the storage, which is the reason to treat a large entry as a time problem first. ### [When a cache is the wrong mechanism](#when-a-cache-is-the-wrong-mechanism)Copy Anchor Link A cache action moves named paths. State that lives outside a cacheable path, such as installed system packages, container images in the local Docker store, or a warmed compiler directory, is not addressable by path and ends up bloating the archive when teams try to force it in. [Snapshot runners](/runners/snapshot) boot a job from a saved image of an earlier run's disk instead, which removes the transfer from the job entirely for that class of state. Snapshot runners apply to the Ubuntu part of that catalog, and WarpBuild caching is not supported on Windows runners. The [persistent caches guide](/guides/persistent-caches-github-actions) walks through choosing between the two mechanisms with the same break-even arithmetic used above. ## [Related Questions](#related-questions)Copy Anchor Link ### [How do I tell whether the restore or the rebuild is slower?](#how-do-i-tell-whether-the-restore-or-the-rebuild-is-slower)Copy Anchor Link Read the two numbers off one run. The restore step and its matching post-run save step each carry their own elapsed time in the GitHub Actions run view, and the step that regenerates the same paths carries its own. If the restore plus save total exceeds the regeneration step, the entry is costing runner minutes rather than saving them. Pair that with [cache hit rate](/answers/how-do-i-measure-cache-hit-rate-in-github-actions), because a slow restore on a low hit rate is worse than either number suggests on its own, and use the [Reports page](/docs/ci/features/reports) to confirm the change held at P90 across a week. ### [Does a larger runner make a cache restore faster?](#does-a-larger-runner-make-a-cache-restore-faster)Copy Anchor Link A larger size is the documented fix for one specific failure, the "Failed to commit cache" error that appears when Docker layers are large and the runner is small, such as layers over 5 GB on a 2x runner, per the [caching documentation](/docs/ci/features/caching). For a restore that is slow rather than failing, time the step on both sizes before committing, because `warp-ubuntu-latest-x64-4x` costs $0.008 per minute against $0.016 per minute for `warp-ubuntu-latest-x64-8x`, from the [pricing page](/pricing), checked on 2026-08-13\. Doubling the rate is worth it only when the restore step shortens by more than half. ### [Is a 10 GB cache entry too large to be worth restoring?](#is-a-10-gb-cache-entry-too-large-to-be-worth-restoring)Copy Anchor Link It depends on what the entry replaces. At an effective restore rate of 100 MB per second, a 10 GB archive takes about 100 seconds to restore and about 200 seconds on a run that also saves it, so it pays for itself only when regenerating those paths takes longer than that. Entries above that threshold usually belong in a split cache, keyed so the slow-moving half stays warm, or on a [snapshot runner](/runners/snapshot). The [cache size limit guide](/guides/github-actions-cache-size-limit) covers the separate ceiling question, where GitHub caps combined caches in a repository at 10 GB by default and evicts least recently used entries first. --- # Why Is My Docker Build Slow in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/why-is-my-docker-build-slow-in-github-actions Description: Usually three causes: no persistent layer cache, QEMU emulation for a second architecture, or an undersized runner. Move the build to a remote builder. # Why Is My Docker Build Slow in GitHub Actions? Usually three causes: no persistent layer cache, QEMU emulation for a second architecture, or an undersized runner. Move the build to a remote builder. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link A Docker build in GitHub Actions is slow for one of three reasons almost every time: the job starts on a fresh machine with an empty layer store and rebuilds everything, a second architecture is being emulated with QEMU instead of built natively, or the build sits on a runner with fewer cores than its compile steps can use. The repair for the first two is the same move, which is to run the build on a remote Docker builder that keeps a persistent layer cache on its own disk and builds each architecture on its own machine ([Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13). Read the BuildKit output of one slow run before changing anything. Three signatures separate the three causes. 1. **Cold cache.** Every `RUN` line prints real work and none print `CACHED`. A GitHub Actions runner is destroyed at the end of the job, so the layer store goes with it, and the next run starts from nothing unless layers are exported somewhere and pulled back. 2. **Emulation.** The workflow contains `docker/setup-qemu-action` and the `platforms` value names an architecture the runner is not. The non-native half of the graph runs through a user mode interpreter that translates instructions before the host CPU executes them. 3. **Undersized runner.** The steps that dominate the log are compilers, linkers, `node-gyp` rebuilds, or `pip` builds from source, and the job holds a 2 vCPU label. Those steps scale with cores, and the runner is the ceiling. The configuration change is small. Point the build at a builder profile, drop the local buildx and QEMU setup steps, and let the profile carry both architectures: Copy Text ``` jobs: image: - runs-on: warp-ubuntu-latest-x64-8x + runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - - uses: docker/setup-buildx-action@v3 - - name: Build and push - uses: docker/build-push-action@v6 + uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ghcr.io/acme/api:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max + profile-name: "api-builder" ``` The full workflow after the change: Copy Text ``` name: image on: push: branches: [main] jobs: image: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ghcr.io/acme/api:${{ github.sha }} profile-name: "api-builder" api-key: ${{ secrets.WARPBUILD_API_KEY }} timeout: 600000 ``` `Warpbuilds/build-push-action` is a drop-in replacement for the standard build and push action, so the `context`, `push`, `tags`, and `platforms` inputs keep their meaning. The `api-key` input is only needed when the driving job runs somewhere other than a WarpBuild runner. `cache-from` and `cache-to` are gone because a cached builder holds the layers locally and reuses them on the next build without an export and import round trip. Per minute rates for runners and builders are on the [pricing page](/pricing). ## [Detail](#detail)Copy Anchor Link ### [Cause one: the layer cache does not survive the job](#cause-one-the-layer-cache-does-not-survive-the-job)Copy Anchor Link A GitHub Actions job is an ephemeral machine. BuildKit writes its layer cache to that machine's disk, the machine is deleted when the job ends, and the next run of the same workflow gets a clean disk. Every dependency install, every compile, and every asset build runs again. The usual workaround exports the cache to a registry or to the Actions cache service and imports it at the start of the next run. That converts CPU time into network time. A 4GB layer set has to travel out at the end of every build and back in at the start of the next one, and the import happens before the first `RUN` step can be skipped. A remote Docker builder removes the transfer. A builder profile maps to one dedicated builder virtual machine that keeps its layer cache on local disk between builds ([Docker builders documentation](/docs/ci/docker-builders)). The second build against that profile reads cached layers from the disk they were written to. Two behaviors are worth knowing before you rely on it. The cache is shared across jobs on the profile but eventually consistent, so layers produced by one in-flight build may not be visible to another build running at the same moment, and they become available to later builds after synchronization. A profile that goes unused for 10 days has its cache reset automatically, so a repository that builds once a fortnight starts cold each time. If the cache is warm and the build still repeats work, the problem is Dockerfile ordering rather than cache storage. A `COPY . .` placed above the dependency install invalidates every layer below it on any file change. The [Docker cache invalidation guide](/guides/docker-cache-invalidation-github-actions) walks through finding the layer that keeps changing. ### [Cause two: the second architecture runs under emulation](#cause-two-the-second-architecture-runs-under-emulation)Copy Anchor Link `docker/setup-qemu-action` registers `binfmt_misc` handlers so the kernel routes foreign binaries to a user mode interpreter. With `platforms: linux/amd64,linux/arm64` on an x64 runner, the amd64 half runs natively and the arm64 half runs every instruction through that interpreter, with each syscall marshalled across two ABIs. The cost lands unevenly. `FROM` pulls, `COPY`, and `ADD` are executed by BuildKit on the host and are unaffected. `RUN` steps that burn CPU inside the target root filesystem carry the whole penalty, which is why a Go or Rust compile or a native extension build is where an emulated multi-arch build spends its time. Splitting the work removes the interpreter. Enable both architectures on the builder profile and each architecture builds on a separate builder instance on its own native hardware. The [QEMU and native ARM64 guide](/guides/qemu-vs-native-arm64-builds) covers the diagnosis in more depth, including why `exec format error` persists after the emulation is removed. Two constraints apply to that split. arm64 and multi-arch builder profiles cap at 64 vCPU, so the 96 vCPU and 192 vCPU sizes are available only on amd64-only profiles. A multi-arch build also opens one session per architecture, and each session is billed independently. ### [Cause three: the runner is smaller than the build](#cause-three-the-runner-is-smaller-than-the-build)Copy Anchor Link The Linux x64 rate doubles at each size step, so cores and cost move together ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13): | Runner label | vCPU | Memory | Per minute | | -------------------------- | ---- | ------ | ---------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | $0.064 | Once the build moves to a builder, the runner size stops mattering for build speed. The builder size is independent of the runner size, so the driving job can hold a 2 vCPU label while the build executes on a much larger machine. macOS runners are the exception to all of this, because they cannot run Docker at all and have to hand container work to a Linux job or a builder. ### [Builder profile sizes and rates](#builder-profile-sizes-and-rates)Copy Anchor Link Seven profile sizes are available, from the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13: | Profile | vCPU | Memory | Disk | Per minute | Architectures | | -------------------- | ---- | ------ | ----- | ---------- | ------------------- | | 16 vCPU | 16 | 32GB | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU | 32 | 64GB | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU | 64 | 128GB | 200GB | $0.24 | amd64, arm64, multi | | 96 vCPU | 96 | 192GB | 600GB | $0.36 | amd64 only | | 96 vCPU, large disk | 96 | 192GB | 2TB | $0.52 | amd64 only | | 192 vCPU | 192 | 384GB | 600GB | $0.72 | amd64 only | | 192 vCPU, large disk | 192 | 384GB | 2TB | $0.88 | amd64 only | Disk is the input people underestimate. A profile holds a persistent layer cache, and a repository with several large base images and a wide build matrix fills 100GB faster than expected. The recommended floor is roughly 8 vCPU and 16GB of memory per concurrent build job on a profile, so a profile serving six parallel jobs belongs in the 64 vCPU row rather than the 16 vCPU row. The full catalog with per size notes is on the [remote Docker builder page](/runners/docker-builders). ### [How a builder session is billed, and how it stacks with the runner](#how-a-builder-session-is-billed-and-how-it-stacks-with-the-runner)Copy Anchor Link Builders are billed per session. A session runs from the moment the builder action starts until the job completes, and several concurrent jobs sharing one profile share one session, measured from the first job start to the last job completion. A fan-out of eight matrix jobs against one profile produces one billable stretch of time rather than eight. The runner and the builder are two separate resources and both are billed. The builder minute costs more than the runner minute it replaces, and it earns its place by removing minutes from the build. Sessions also end when the work does, so invoke the builder action immediately before the build step. Builders have a built in timeout so idle time is not charged forever, but a builder assigned at the top of a long job and used at the bottom is paid for throughout. ### [When the builder pays for itself](#when-the-builder-pays-for-itself)Copy Anchor Link Take a driving job on `warp-ubuntu-latest-x64-2x` at $0.004 per minute and a 16 vCPU builder profile at $0.06 per minute, with the job waiting on the build. The combined rate while building is $0.064 per minute. Compare that against whatever the build costs today as a single job: | Build runs today on | Rate today | Combined rate with a 16 vCPU builder | Build time that keeps the bill flat | | -------------------------- | ---------- | ------------------------------------ | ----------------------------------- | | warp-ubuntu-latest-x64-2x | $0.004 | $0.064 | one sixteenth of today | | warp-ubuntu-latest-x64-4x | $0.008 | $0.064 | one eighth of today | | warp-ubuntu-latest-x64-8x | $0.016 | $0.064 | one quarter of today | | warp-ubuntu-latest-x64-16x | $0.032 | $0.064 | one half of today | | warp-ubuntu-latest-x64-32x | $0.064 | $0.064 | the same as today | All rates come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13. A worked month makes the shape concrete. Say the repository pushes 600 image builds a month and the build takes 9 minutes today on `warp-ubuntu-latest-x64-8x`, which is 600 x 9 x $0.016 = $86.40. | Scenario | Runner line | Builder line | Monthly total | | --------------------------------- | ------------------------- | ------------------------- | ------------- | | Today, 9 minutes on 8 vCPU | 600 x 9 x $0.016 = $86.40 | none | $86.40 | | Builder, build lands at 3 minutes | 600 x 3 x $0.004 = $7.20 | 600 x 3 x $0.06 = $108.00 | $115.20 | | Builder, build lands at 2 minutes | 600 x 2 x $0.004 = $4.80 | 600 x 2 x $0.06 = $72.00 | $76.80 | At three minutes the bill rises by $28.80 a month and the pipeline returns 6 minutes per build, which is 3,600 minutes of wall clock a month that people and merge queues were waiting on. At two minutes the bill falls by $9.60 a month as well. Which row you land on depends on how much of the Dockerfile is dependency installation that a warm cache skips, so measure a warm build before committing to a profile size. Doubling the profile to 32 vCPU at $0.12 per minute doubles the builder line, so the break-even times halve. Larger profiles suit builds with wide parallelism inside a single Dockerfile and multi-stage graphs with independent branches. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface, and the [Docker builds on GitHub Actions page](/solutions/docker-builds-github-actions) covers the full configuration end to end. ## [Related Questions](#related-questions)Copy Anchor Link **My build has a cache backend configured and it still runs cold. Why?** A cache backend only helps when the cache key still matches. A COPY of the whole repository placed above the dependency install, a lockfile that changes on every run, or an ARG that carries the commit SHA invalidates every layer below it, so the cache is present and unusable. Fix the Dockerfile ordering before changing where the cache lives. **Is a multi-arch build billed as one builder session or two?** Two. Each architecture runs on a separate builder instance, so a multi-arch profile opens one session per architecture and the two are billed independently. Both sessions stay alive until the post-action steps finish. Note also that arm64 and multi-arch builder profiles cap at 64 vCPU. **Which builder profile size should I start with?** The 16 vCPU, 32GB, 100GB disk profile at $0.06 per minute covers most single-image builds, and it supports amd64, arm64, and multi-arch. WarpBuild recommends roughly 8 vCPU and 16GB of memory per concurrent build job on a profile, so size up when several jobs share one profile. **Do I pay for the GitHub Actions runner and the Docker builder at the same time?** Yes. The runner and the builder are two separate resources on two separate lines. The builder size is chosen independently of the runner size, so a 2 vCPU runner can drive a 192 vCPU builder profile, and both minutes are billed. Price your own build against the break-even table above on the [pricing page](/pricing), then create a builder profile and change one action reference. --- # Why Is My GitHub Actions Bill So High? | WarpBuild URL: https://www.warpbuild.com/answers/why-is-my-github-actions-bill-so-high Description: A high GitHub Actions bill usually comes from four drivers: oversized runners, macOS minutes, re-run storms, and cache misses that force full rebuilds. # Why Is My GitHub Actions Bill So High? A high GitHub Actions bill usually comes from four drivers: oversized runners, macOS minutes, re-run storms, and cache misses that force full rebuilds. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link A GitHub Actions bill climbs for four reasons, and they usually arrive in this order: oversized runners that bill a higher rate for vCPU the job never uses, macOS minutes priced well above Linux minutes, re-run storms from duplicate triggers and retries, and cache misses that force full rebuilds on every run. Runner cost is billed minutes multiplied by the rate of the label the job holds, so every one of those four shows up as either more minutes or a higher rate per minute. The rate half is easy to read off a price list. On WarpBuild, the Linux x64 rate doubles at every size step. | WarpBuild runner | Shape | WarpBuild rate | Nearest GitHub-hosted runner | GitHub rate | Difference | | -------------------------- | --------------- | -------------- | ------------------------------------- | ----------- | --------------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004/min | ubuntu-latest on private repositories | $0.006/min | 33 percent lower list price | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008/min | 4-core Linux larger runner | $0.012/min | 33 percent lower list price | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016/min | 8-core Linux larger runner | $0.022/min | 27 percent lower list price | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032/min | 16-core Linux larger runner | $0.042/min | 24 percent lower list price | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064/min | 32-core Linux larger runner | $0.082/min | 22 percent lower list price | WarpBuild rates come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [WarpBuild pricing page](/pricing). GitHub list prices were checked on 2026-08-13 against the [GitHub Actions per-minute rate list](https://docs.github.com/en/billing/reference/actions-minute-multipliers). One caveat before you compare invoices: GitHub gives public repositories a 4 vCPU, 16 GB Linux shape at no charge, so every GitHub number here is the private-repository price that paying teams see. The table above compares one workload at fixed duration. Substitute measured job times for your fleet and use the [WarpBuild pricing page](/pricing) for the full rate list. The structure of the bill matters as much as the rates. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so headcount and repository count never appear as line items. Signup includes $10 free credits, which is enough to run the diagnosis below against a real workload before you commit anything. ## [Detail](#detail)Copy Anchor Link ### [Start with the reports](#start-with-the-reports)Copy Anchor Link Every fix below depends on knowing which line is large. The [reports documentation](/docs/ci/features/reports) covers three sections that answer the question directly, and each one exports to CSV. | Question | Where to look | What to read | | ------------------------------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | Which jobs cost the most? | Billing, CI tab | One row per job execution: repository, job name, runner label, stack, execution time, billed time, and the split between runner cost and snapshot cost | | Which repository or label owns the growth? | Billing, CI tab daily chart | Stacked daily cost grouped by repository or by runner label | | Is a job paying for vCPU it never uses? | Jobs | Duration P75 and P90, queue time P75 and P90, CPU P75 and P90, memory P75 and P90 per unique job | | Is cache spend storage or operations? | Billing, Cache tab | Summary cards split total cost into storage cost and operations cost, with rows filtered by storage, operation-hit, and operation-commit | | What do Docker builds cost? | Billing, Docker Builder tab | One row per builder session with profile, architecture, duration, and cost | | Are jobs waiting instead of running? | Queue Timings | Queue time P75 and P90 per runner label and stack | Two structural facts save a lot of confusion here. Docker builder sessions bill separately from runners and appear on their own tab, so a workflow whose image build moved to a remote builder shows a smaller CI line and a new Docker Builder line. Snapshot usage bills separately inside the CI row, at $0.04 per snapshot restore and $0.025 per snapshot-hour of storage, which is why each row carries a runner cost and a snapshot cost rather than one number. CPU and memory percentiles need [CI observability](/docs/ci/features/observability) enabled on the runner. Jobs without telemetry show a dash in those columns, and a dash is the reason most oversizing goes unnoticed for months. ### [Driver 1: oversized runners](#driver-1-oversized-runners)Copy Anchor Link The rate doubles at every step of the size ladder, so a job pinned to a 32 vCPU label pays eight times the per-minute rate of the same job on 4 vCPU. That trade only pays back when the extra cores actually cut wall-clock time. Here is one job converted into a monthly number. Assume an integration suite that runs on every pull request, 300 runs a month, 14 minutes per run on `warp-ubuntu-latest-x64-32x`. * Before: 300 runs times 14 minutes is 4,200 minutes, at $0.064 per minute, which is $268.80 a month. * The Jobs report shows CPU P75 at 21 percent and memory P75 at 34 percent, so the suite is bound by network and disk waits rather than cores. * After: the same suite on `warp-ubuntu-latest-x64-8x` takes 17 minutes, which is 5,100 minutes at $0.016 per minute, or $81.60 a month. * Monthly difference: $187.20, with three minutes added to each run. The same assumption set on the nearest GitHub-hosted shapes costs 4,200 minutes at $0.082 per minute, or $344.40, before the move, and 5,100 minutes at $0.022 per minute, or $112.20, after it. GitHub list prices checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Run that arithmetic per job rather than per fleet. A compile step at 90 percent CPU on 16 vCPU deserves its label; a Terraform plan at 8 percent CPU on the same label is paying quadruple for a job that waits on an API. ### [Driver 2: macOS minutes](#driver-2-macos-minutes)Copy Anchor Link macOS is the most expensive platform in any GitHub Actions fleet, and it usually dominates the invoice for iOS teams even when it is a small share of total minutes. A WarpBuild macOS runner with 6 vCPU and 14 GB costs $0.08 per minute, and the 12 vCPU, 28 GB runner costs $0.16 per minute. At those rates, 2,000 macOS minutes cost $160.00, the same as 40,000 minutes on `warp-ubuntu-latest-x64-2x` at $0.004. That ratio is the reason macOS deserves its own review pass. Group the daily cost chart by runner label and the macOS line usually stands alone. Two questions then decide the fix. First, does every job on the label need macOS at all, or are packaging, linting, and metadata steps riding along because they were written into the same job? Those steps move to Linux, where `warp-ubuntu-latest-x64-4x` bills $0.008 per minute against $0.08 for the 6 vCPU macOS runner. Second, does the Xcode build finish in less than half the time on the 12 vCPU runner? If it does not, the 6 vCPU label costs less per run at $0.08 per minute. A worked case: an Xcode build holding `warp-macos-latest-arm64-12x` for 900 minutes a month costs $144.00\. The same build on the 6 vCPU runner takes 1,150 minutes and costs $92.00, a $52.00 monthly difference for a slower but cheaper run of a job nobody waits on. ### [Driver 3: re-run storms](#driver-3-re-run-storms)Copy Anchor Link Minutes multiply faster than rates do. A matrix of 10 jobs at 6 minutes each on `warp-ubuntu-latest-x64-4x` bills 60 minutes per run, or $0.48 at $0.008 per minute. Three common patterns then multiply that number: * `push` and `pull_request` both firing on the same branch, which doubles every commit to $0.96. * No concurrency group, so five commits pushed in ten minutes leave five full matrices running instead of one. * Retries on flaky jobs, where two extra attempts of a 6 minute job add $0.096 to the affected run. At 600 runs a month the duplicated version costs $288.00 and the deduplicated version costs $144.00, on the same test code. This workflow shape removes the duplication and sizes each job separately: Copy Text ``` name: ci on: pull_request: branches: [main] concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: unit: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci - run: npm test integration: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/cache@v1 with: path: | ~/.cache/build key: build-${{ hashFiles('**/bun.lockb') }} restore-keys: build- - run: ./scripts/integration-tests.sh packaging: runs-on: warp-ubuntu-latest-arm64-4x steps: - uses: actions/checkout@v4 - run: docker build -t app:arm64 . ``` The `packaging` job on `warp-ubuntu-latest-arm64-4x` at $0.006 per minute also shows the cheapest structural move available on Linux: any job with no x86 dependency costs less on ARM64 at every size. ### [Driver 4: cache misses forcing full rebuilds](#driver-4-cache-misses-forcing-full-rebuilds)Copy Anchor Link A cold cache turns a 40 second dependency restore into a multi-minute install, on every job of every run. The cost is invisible on the cache tab because it lands on the runner line instead. Assume 8 jobs per run across 300 runs a month, so 2,400 job executions, each installing dependencies in 4.5 minutes cold against 0.6 minutes warm, on `warp-ubuntu-latest-x64-4x`. * Cold: 10,800 minutes at $0.008 per minute, which is $86.40 a month. * Warm: 1,440 minutes at $0.008 per minute, which is $11.52, plus the cache itself. * Cache line items: 60 GB of storage at $0.20 per GB-month is $12.00, and 4,800 cache operations at $0.0001 each is $0.48. * Net: $86.40 becomes $24.00, a $62.40 monthly difference. That split is why the Cache tab separates storage cost from operations cost. Storage dominates when a fleet keeps large caches alive; operations dominate when thousands of small jobs each read and write. Cache storage and cache operations are included at no charge on BYOC runners, so BYOC fleets read the cache tab as a usage report rather than a cost line. The [caching documentation](/docs/ci/features/caching) covers the key and restore-key shapes that decide the hit rate, and the [cache storage cost answer](/answers/what-does-github-actions-cache-storage-cost) covers the storage side in detail. ### [The four drivers on one fleet](#the-four-drivers-on-one-fleet)Copy Anchor Link Combining the four worked examples above into one month: | Driver | Before | After | Change | | ------------------------------------- | ----------------------------- | -------------------------------------- | --------- | | Oversized integration suite | 4,200 min at $0.064 = $268.80 | 5,100 min at $0.016 = $81.60 | \-$187.20 | | macOS build on the 12 vCPU label | 900 min at $0.16 = $144.00 | 1,150 min at $0.08 = $92.00 | \-$52.00 | | Duplicate triggers on the test matrix | 600 runs at $0.48 = $288.00 | 300 runs at $0.48 = $144.00 | \-$144.00 | | Cold dependency caches | 10,800 min at $0.008 = $86.40 | $11.52 of minutes plus $12.48 of cache | \-$62.40 | | Total | $787.20 | $341.60 | \-$445.60 | Nothing in that table required a change to application or test code. It required reading four reports and editing label names and triggers in workflow files. ### [What is not on the bill](#what-is-not-on-the-bill)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so a team of 40 engineers and a team of 400 pay the same rate for the same minute of the same runner. Adding a repository adds cost only when it queues jobs, and archiving one removes its minutes from the next invoice with no plan change. The same per-minute model covers the whole fleet rather than leaving one platform on a separate billing scheme. Signup includes $10 free credits. ## [Related Questions](#related-questions)Copy Anchor Link ### [Why did my GitHub Actions bill go up this month?](#why-did-my-github-actions-bill-go-up-this-month)Copy Anchor Link Compare the per-job billing rows across the two periods and group the daily cost chart by runner label and by repository. The usual causes are a matrix that grew, a duplicate trigger firing every commit twice, and a job pinned to a larger runner label than its CPU percentiles justify. The [cost attribution guide](/guides/github-actions-cost-attribution) covers how to split those rows by team and repository so the increase has an owner. ### [Do larger GitHub Actions runners always cost more?](#do-larger-github-actions-runners-always-cost-more)Copy Anchor Link The per-minute rate doubles at every size step, so a 32 vCPU Linux x64 runner at $0.064 per minute costs eight times as much per minute as a 4 vCPU runner at $0.008\. A larger runner lowers the monthly total only when it cuts wall-clock time by more than the rate step adds, which is a question the Jobs report answers with duration and CPU percentiles. The [larger runners cost guide](/guides/github-actions-larger-runners-cost) walks the arithmetic for each size class. ### [How do I lower a GitHub Actions bill quickly?](#how-do-i-lower-a-github-actions-bill-quickly)Copy Anchor Link Remove duplicate runs first, then right-size the jobs whose CPU percentiles sit far below the runner they hold, then warm the caches that force full rebuilds. Those three moves need no change to test code and, on the fleet modeled above, account for $445.60 a month. The [guide to reducing GitHub Actions costs](/guides/reduce-github-actions-costs) works through each lever with configuration examples, and the rate list is on the [WarpBuild pricing page](/pricing). ### [Which report shows what each GitHub Actions job costs?](#which-report-shows-what-each-github-actions-job-costs)Copy Anchor Link The CI tab of the billing report has one row per job execution with repository, job name, runner label, execution time, billed time, and the split between runner cost and snapshot cost. Cache spend and Docker builder spend sit on their own tabs, and every table exports to CSV. See the [reports documentation](/docs/ci/features/reports) for the filters and the percentile columns. --- # Why Is npm install Slow in GitHub Actions? | WarpBuild URL: https://www.warpbuild.com/answers/why-is-npm-install-slow-in-github-actions Description: Most of a cold npm install is registry fetches plus native module compilation. A dependency cache removes the fetches, and only more vCPU touches the builds. # Why Is npm install Slow in GitHub Actions? Most of a cold npm install is registry fetches plus native module compilation. A dependency cache removes the fetches, and only more vCPU touches the builds. Last verified: 2026-08-13 Most of a cold `npm install` in GitHub Actions is network work followed by native module compilation: metadata requests to the registry, tarball downloads, extraction of tens of thousands of small files, and then a `node-gyp` build for every package that ships no prebuilt binary for the runner platform. A dependency cache removes the download half of that list and leaves the compilation half untouched, which is why teams who turn caching on and still watch a slow install step are usually looking at compiler time rather than bandwidth. ## [Answer](#answer)Copy Anchor Link Four stages run inside one install step, and each one has a different lever. | Stage | What the runner is doing | Removed by a warm dependency cache | | ----------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ | | Metadata resolution | Asking the registry which versions satisfy the ranges in package.json | Skipped entirely by npm ci, which reads the lockfile instead | | Tarball downloads | Fetching one packed tarball per package over the network | Yes, the fetches become local reads from \~/.npm | | Extraction and linking | Unpacking those tarballs into node\_modules, one small file at a time | No | | Lifecycle scripts and native builds | Running install and postinstall scripts, including node-gyp compiles | No | `npm ci` covers the first row by contract. It installs a project "exactly as described" by the lockfile, deletes `node_modules` before it starts, and errors out when the lockfile and `package.json` disagree instead of resolving the difference ([npm ci](https://docs.npmjs.com/cli/v11/commands/npm-ci)). No resolver pass means no version negotiation with the registry. The second row is what a cache buys you. npm stores every packed tarball it has downloaded in the cacache directory under `~/.npm` on Linux and macOS ([npm cache](https://docs.npmjs.com/cli/v11/commands/npm-cache)), and `--prefer-offline` tells npm to skip the staleness checks and go to the network only for entries the cache does not hold ([config reference](https://docs.npmjs.com/cli/v11/using-npm/config#prefer-offline)). Restore that directory at the start of a job and the download stage becomes a local copy. Here is the whole configuration on a WarpBuild runner: Copy Text ``` name: test on: pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm cache-dependency-path: package-lock.json - run: npm ci --prefer-offline --no-audit --fund=false - run: npm test ``` `WarpBuilds/setup-node` accepts the same inputs as the upstream action and routes the cache through WarpBuild Cache when it runs on a WarpBuild runner, with no other workflow edit required ([setup actions documentation](/docs/ci/features/caching/setup-actions)). The two flags after `--prefer-offline` remove work that has nothing to do with dependencies: `--no-audit` drops the audit request npm sends to the registry after every install, and `--fund=false` drops the funding lookup ([config reference](https://docs.npmjs.com/cli/v11/using-npm/config#audit)). ## [Detail](#detail)Copy Anchor Link ### [Reading the breakdown on your own repository](#reading-the-breakdown-on-your-own-repository)Copy Anchor Link `npm ci --timing` writes a timing file into the npm logs directory alongside the debug log ([config reference](https://docs.npmjs.com/cli/v11/using-npm/config#timing)), and `--foreground-scripts` prints the output of install scripts as they run ([config reference](https://docs.npmjs.com/cli/v11/using-npm/config#foreground-scripts)) so the package that is compiling names itself in the log. Take a cold run that lands at 140 seconds, split the way a mid-sized dependency tree with two or three native addons usually splits. These durations are illustrative, and the point is the shape rather than the totals; replace them with your own timing output before acting on any of it. | Stage | Cold run | After a warm cache | | --------------------------- | -------- | --------------------------- | | Metadata resolution | 8s | 0s with npm ci on both runs | | Tarball downloads | 55s | about 3s of local reads | | Extraction and linking | 32s | 32s | | Lifecycle and native builds | 45s | 45s | | Total | 140s | about 80s | The cache removed 60 seconds and left 80\. That residual is the number worth arguing about, because no cache key change moves it. ### [Why the compilation half survives the cache](#why-the-compilation-half-survives-the-cache)Copy Anchor Link A package with native code ships either a prebuilt binary for your platform or C++ sources that `node-gyp` compiles at install time ([node-gyp](https://github.com/nodejs/node-gyp)). The registry cache holds the tarball, so the download is gone on the second run, and the compile inside that tarball runs again because its output lives in `node_modules` rather than in the store. Prebuilt coverage is per platform. A package that publishes a prebuilt for `linux-x64` and nothing for `linux-arm64` downloads on one label and compiles on the other, from the same lockfile. That difference shows up as a matrix leg that takes minutes longer than its sibling with no other explanation. The full label list and shapes are in the [cloud runners documentation](/docs/ci/cloud-runners). Caching the `node_modules` directory outright is the tempting shortcut here, and it carries its own failure mode, since the tree holds absolute paths and platform-specific binaries. [How to cache node\_modules in GitHub Actions](/answers/how-do-i-cache-node-modules-in-github-actions) walks through when the directory cache is worth the risk and how to key it. ### [When a bigger runner is the right lever](#when-a-bigger-runner-is-the-right-lever)Copy Anchor Link Compilation is CPU bound, and it is the only stage in the table that responds to more cores. npm runs lifecycle scripts for independent packages concurrently, so a tree with several compiling addons uses several cores, while a tree whose install time sits inside one large addon does not. Registry waits and single-file extraction gain nothing from a size change. Doubling the runner size doubles the per-minute rate, so the arithmetic is exact. Take a job that bills 6 minutes on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, or $0.048 per run. The same 6 minutes on `warp-ubuntu-latest-x64-8x` at $0.016 per minute is $0.096, so the move lowers the bill only if the job drops below 3 minutes. With 80 seconds of install in play, and only 45 of those seconds compiling, it cannot. Buy the bigger size for wall clock when the compile share is large, and check that share before you buy it. Rates from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), with GitHub list prices from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13: | Shape | WarpBuild label | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | | -------------- | -------------------------- | -------------------- | --------------------------- | ----------------- | | 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | $0.008 | 4-core Linux larger runner | $0.012 | | 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | $0.016 | 8-core Linux larger runner | $0.022 | | 16 vCPU, 64 GB | warp-ubuntu-latest-x64-16x | $0.032 | 16-core Linux larger runner | $0.042 | `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price, GitHub list price checked on 2026-08-13\. Every cost claim on this page carries a number, a source link, and a checked-on date, and the same numbers appear on every WarpBuild surface. [Sizing runners for Node test suites](/guides/sizing-runners-for-node-test-suites) covers the same break-even for the test step, which usually parallelizes better than the install does. ### [When compilation is unavoidable](#when-compilation-is-unavoidable)Copy Anchor Link Some trees compile on every run whatever you do, because the addon has no prebuilt for the platform and the build is the product. Snapshot runners boot a job from a runner VM captured mid workflow, so a compiled tree can already be on disk when the job starts, and the observability reports are where the install step's real duration and its share of the job come from before any of this is worth changing. [Node.js builds on WarpBuild GitHub Actions runners](/solutions/nodejs-github-actions) puts the install step back in the context of the whole pipeline. ## [Related Questions](#related-questions)Copy Anchor Link ### [Is npm ci faster than npm install in GitHub Actions?](#is-npm-ci-faster-than-npm-install-in-github-actions)Copy Anchor Link Yes, for a workflow. `npm ci` reads `package-lock.json` and installs exactly what it describes, so the resolver never runs and no registry round trip is spent working out which versions satisfy the ranges in `package.json` ([npm ci](https://docs.npmjs.com/cli/v11/commands/npm-ci)). It also deletes `node_modules` before it starts, which makes every run identical and rules out the stale-tree failures that `npm install` can produce. ### [Why is my install still slow when the cache hits?](#why-is-my-install-still-slow-when-the-cache-hits)Copy Anchor Link Because the cache only removes the download stage. A warm npm cache turns registry fetches into local reads from `~/.npm`, and the runner still extracts tens of thousands of small files and still compiles every native addon with no prebuilt binary for the platform. If the install step barely moves once the cache starts hitting, the remaining time is extraction and `node-gyp`. [How to cache node\_modules in GitHub Actions](/answers/how-do-i-cache-node-modules-in-github-actions) covers what the store cache does and does not hold. ### [Should I move to a bigger runner or change package managers?](#should-i-move-to-a-bigger-runner-or-change-package-managers)Copy Anchor Link Measure the compile share first with `npm ci --timing`. Native compilation responds to more vCPU while registry waits and extraction do not, and doubling the size doubles the per-minute rate, so the move lowers the bill only when the job finishes in under half the wall clock time it took before. [Sizing runners for Node test suites](/guides/sizing-runners-for-node-test-suites) works the same arithmetic through a full pipeline, and [Node.js builds on WarpBuild GitHub Actions runners](/solutions/nodejs-github-actions) covers the labels to try. --- # Brand Assets | WarpBuild URL: https://www.warpbuild.com/brand-assets Description: Official WarpBuild brand assets, logos, colors, and usage guidelines. # Brand Assets ## Company Name Our company name is "WarpBuild" : one word with "W" and "B" capitalized. (incorrect usage - warpbuild, Warpbuild, warp build, warpBuild) [Download WarpBuild Assets](https://drive.google.com/uc?export=download&id=1LSLafI4bEhjUdPIigUMxJ23hAxLZn823) ## Logotype The logotype consists of the WarpBuild name and the symbol in a horizontal layout. We have a couple of variations of the logotype based on the background color. ![Logotype 1](/images/logo-dark.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Logotype 2](/images/logo-light.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Logotype 3](/images/logo-white.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Logotype 4](/images/logo-black.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ## Symbol The symbol consists of 6 long streaks and 6 short streaks in an alternating fashion with one of the smaller streaks placed at 12 o'clock. ![symbol 1](/images/mark-dark.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![symbol 2](/images/mark-light.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![symbol 3](/images/mark-white.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![symbol 4](/images/mark-black.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ## Colors Our primary colors consist of green, Dark Gray and white. Green #06AA63 RGB(6, 170, 99) Dark Gray #060708 RGB(6, 7, 8) White #FFFFFF RGB(255, 255, 255) ## Usage Make sure that there is the required amount of whitespace around the logotype and the symbol as shown in the examples. ![logotype usage](/images/usage.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![symbol usage](/images/usage-1.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) [Download WarpBuild Assets](https://drive.google.com/uc?export=download&id=1LSLafI4bEhjUdPIigUMxJ23hAxLZn823) --- # Bring Your Own Cloud | WarpBuild URL: https://www.warpbuild.com/byoc Description: BYOC guides: architecture, permissions, Terraform, networking, and cost for runners in your own cloud account. # Bring Your Own Cloud Run managed GitHub Actions runners inside your own AWS, GCP, or Azure account. * [BYOC Runners on AWS for GitHub ActionsWarpBuild BYOC runs GitHub Actions runners as ephemeral EC2 instances inside your own AWS account. Architecture, IAM surface, configuration, and cost.](/byoc/aws) * [Cost Allocation Tags for BYOC RunnersA WarpBuild BYOC stack tags every AWS resource it creates. The default keys, a per-team and per-repository tag scheme, and how to reconcile both bills.](/byoc/aws/cost-allocation-tags) * [Custom AMIs for BYOC RunnersBuild your own AMI for WarpBuild BYOC runners on AWS. Linux and Windows image requirements, a packer template, the add-image step, and the rebuild cost model.](/byoc/aws/custom-amis) * [IAM Permissions for BYOC on AWSWarpBuild BYOC on AWS asks for one IAM role, grouped into five named policy contexts. What each context does, how to narrow it, and what breaks if you cut it.](/byoc/aws/iam-permissions) * [Instance Profiles for BYOC RunnersAttach an IAM instance profile to WarpBuild BYOC runners on AWS so every GitHub Actions job on a runner set gets the AWS permissions it needs, and no more.](/byoc/aws/instance-profiles) * [Networking Requirements for BYOC on AWSBYOC runners on AWS need a VPC with public and private subnets, a security group that blocks all inbound traffic, and outbound routes to four destinations.](/byoc/aws/networking) * [S3 and ECR Prerequisites for BYOC on AWSWhat the S3 bucket and ECR endpoints in an AWS BYOC stack are used for, the permission set behind each one, and why Region placement decides your transfer bill.](/byoc/aws/s3-and-ecr-prerequisites) * [A Security Checklist for BYOC on AWSSix documented controls harden a WarpBuild BYOC stack on AWS: dedicated account or VPC, no inbound, instance isolation, egress limits, IAM scope, IMDSv2.](/byoc/aws/security-checklist) * [Terraform for WarpBuild BYOC on AWSThe WarpBuild Terraform provider declares BYOC runner images and custom runner sets for GitHub Actions inside your own AWS account. Beta, AWS BYOC only.](/byoc/aws/terraform) * [Windows GitHub Actions Runners on BYOC AWSWarpBuild BYOC runs Windows Server 2022 GitHub Actions runners as EC2 instances inside your own AWS account. Architecture, documented minimums, images, cost.](/byoc/aws/windows-runners) * [BYOC Runners on Azure for GitHub ActionsWarpBuild BYOC runs GitHub Actions runners inside your own Azure subscription. WarpBuild manages the fleet, your subscription owns the VMs, disks, and cache.](/byoc/azure) * [Current Limitations of BYOC on AzureBYOC on Azure has no snapshot runners, no stack import flow, East US only, and tagging plus custom roles pending. Every documented limit with its workaround.](/byoc/azure/limitations) * [WarpBuild Control Plane Egress IPs for GitHubThe WarpBuild control plane calls GitHub from three static egress IPs. Where the published list lives, how to scope the allow list rule, and when to recheck it.](/byoc/control-plane-egress-ips) * [A Cost Model for BYOC RunnersBYOC billing has two lines: $0.002 per runner minute to WarpBuild and the compute on your own cloud bill. A worked monthly model with storage and transfer.](/byoc/cost-model) * [BYOC Runners on GCP for GitHub ActionsRun GitHub Actions runners as Compute Engine VMs in your own GCP project. Stack layout, service account roles, quotas, and the $0.002 per minute fee.](/byoc/gcp) * [Disk Types for BYOC Runners on GCPGCP BYOC runners take five disk types, from pd-standard to hyperdisk-extreme, plus automatic local SSD on -lssd machine types. Tradeoffs and sizing.](/byoc/gcp/disk-types) * [Service Accounts for BYOC on GCPBYOC on GCP uses two service accounts: one the cloud connection creates in your project, one you attach to runner VMs. Roles, setup order, and review points.](/byoc/gcp/service-account) * [Requiring IMDSv2 on BYOC RunnersRequire IMDSv2 is a per-runner setting on AWS BYOC custom runners. Where it sits in the runner definition, what it blocks, and how to enforce it fleet wide.](/byoc/imdsv2) * [Running BYOC Runners in Several RegionsRun BYOC GitHub Actions runners in more than one region with one WarpBuild stack per region, per-region runner labels, in-region caches, and transfer costs.](/byoc/multi-region) * [Restricting Outbound Traffic from RunnersRunners in your own cloud account need five outbound destinations. Narrow the rest with security group rules, subnet ACLs, and an egress proxy in front.](/byoc/outbound-traffic-controls) * [The BYOC Runner Lifecycle for GitHub ActionsA BYOC runner is one VM created in your cloud account when a GitHub Actions job is queued and terminated when it ends. Stages, ownership, and cost.](/byoc/runner-lifecycle) * [Updating and Deleting a BYOC StackApply WarpBuild BYOC stack and cloud connection updates in order, drain in-flight GitHub Actions jobs first, and verify what a delete leaves in your account.](/byoc/stack-updates) * [Standby Disks for BYOC GitHub Actions RunnersStandby disks keep BYOC GitHub Actions runners warm by holding pre-initialized VMs shut down, so a queued job starts in about 15 seconds. Setup and cost.](/byoc/standby-disks) --- # BYOC Runners on AWS for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/byoc/aws Description: WarpBuild BYOC runs GitHub Actions runners as ephemeral EC2 instances inside your own AWS account. Architecture, IAM surface, configuration, and cost. # BYOC Runners on AWS for GitHub Actions WarpBuild BYOC runs GitHub Actions runners as ephemeral EC2 instances inside your own AWS account. Architecture, IAM surface, configuration, and cost. Last verified: 2026-08-13 Self-hosted GitHub Actions runners on AWS mean EC2 instances that register against your repositories and execute workflow jobs inside your own VPC. WarpBuild BYOC gives you that placement without the controller you would otherwise operate: WarpBuild runs the control plane, your AWS account runs the instances, and a workflow moves onto them by changing one `runs-on` label. This page covers the AWS architecture, the IAM surface WarpBuild asks for, the stack and runner configuration knobs, and the split of responsibility between your team and WarpBuild. BYOC runs on AWS, GCP, and Azure; the AWS path is the one with import mode and Terraform coverage. ## [Architecture](#architecture)Copy Anchor Link BYOC on AWS splits into a control plane that WarpBuild operates and a data plane that lives entirely in your AWS account. The control plane holds the GitHub App connection, the runner configuration, the job queue watch, and the reconciliation loop that keeps runner capacity matched to queued jobs. It never holds your build data. When GitHub queues a job whose `runs-on` label matches one of your custom runners, the control plane calls the AWS API with the IAM role you granted and launches an EC2 instance in your VPC. The data plane is a CloudFormation stack in one AWS region of your account. Creating it is documented in the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config), and the resource graph is drawn out in the [BYOC AWS architecture reference](/docs/ci/byoc/aws/architecture). The stack owns: * A VPC with public and private subnets across availability zones, and route tables carrying internet-bound traffic through the Internet Gateway. * A security group attached to every runner instance the stack launches. In create mode CloudFormation provisions it. In import mode you select an existing one. * An S3 bucket in the same region as the stack, used for the artifact cache, container layer cache, runner telemetry, and other workflow artifacts. * EC2 instances, EBS volumes, and launch templates created per job and destroyed after it. Three stack properties are immutable after creation: the stack name, the S3 bucket, and the region. Pick them deliberately. A naming convention that carries the product and the region reads well six months later, for example `payments-use1` and `payments-use2`. The S3 bucket has two documented key layouts, which matters when you write a lifecycle policy: Copy Text ``` //artifact_cache///// /runner/logs/all/. ``` Both prefixes grow without bound unless you expire them. The documented recommendation is a 7 day retention lifecycle rule, adjusted to your own retention policy. ### [Instance lifecycle](#instance-lifecycle)Copy Anchor Link Each GitHub Actions job runs on a distinct, freshly provisioned EC2 instance that is terminated when the job completes. There is no shared state between jobs and no runner that survives to serve a second job. That property is what makes autoscaling correct rather than merely convenient: the queue depth sets the instance count, and a finished job leaves nothing behind for the next one to inherit. Cold boot time on BYOC is handled by standby disks. A standby disk is a VM of the same instance type as the runner, booted once so that networking and machine setup complete, then shut down. When GitHub requests a runner, WarpBuild boots a standby disk if the pool has one, which puts job start at roughly 15 seconds. If the pool is empty, a fresh VM is created instead. The pool size is configured per custom runner. WarpBuild does not charge for standby disks; the shut-down VM stops billing compute while its EBS volume keeps billing storage in your account. Spot instances are not supported for standby disks, because a Spot instance can be reclaimed at any time and the pool would stop being a guarantee. ### [Resource naming and tagging](#resource-naming-and-tagging)Copy Anchor Link Everything the stack creates is discoverable by name and tag, which is what makes cost attribution work in AWS Cost Explorer. | Resource type | Naming pattern | | ---------------- | -------------- | | S3 buckets | warpbuild-\* | | EC2 instances | warp-\* | | EBS volumes | warp-\* | | Launch templates | tmpl-warp-\* | WarpBuild adds a `managed-by: warpbuild` tag to provisioned resources, plus a documented tag set on each runner instance: `warpbuild-github-org`, `warpbuild-runner-labels`, `warpbuild-runner-id`, `warpbuild-stack-id`, and `warpbuild-stack-name`. Custom tags you define on the stack are applied to everything it creates. Filtering a cost report by `warpbuild-runner-labels` gives you spend per runner class without any extra instrumentation. ## [Permissions and Access](#permissions-and-access)Copy Anchor Link Connecting an AWS account creates one IAM role, named `warpbuild-`, through a CloudFormation template you apply yourself. The role is the entire access surface. Its permissions are grouped into named policy contexts so that you can read, edit, or reject them one block at a time. | Policy context | What it is used for | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | FineGrainedEC2Permissions | Launch just-in-time runners, describe instance types and offerings per region and availability zone, manage launch templates, create and deregister images, tag instances | | SpotServiceLinkedRolePermissions | Create and manage the EC2 Spot service-linked role, and pass roles to runner instances | | NetworkPermissions | Describe regions, VPCs, subnets, route tables, security groups, and internet gateways; create and delete the endpoints the stack needs in import mode | | StoragePermissions | EBS snapshot actions, plus the S3 actions behind the WarpBuild cache action and runner system logs | | CloudFormationPermissions | Submit changeset requests when a connection or a stack needs an upgrade | The EC2 block is the one worth reading closely, because it is where launch authority lives: Copy Text ``` Policies: - PolicyName: "FineGrainedEC2Permissions" PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - "ec2:DescribeInstanceTypes" - "ec2:DescribeInstanceTypeOfferings" - "ec2:DescribeInstances" - "ec2:RunInstances" - "ec2:CreateFleet" - "ec2:RequestSpotInstances" - "ec2:CancelSpotInstanceRequests" - "ec2:DescribeSpotInstanceRequests" - "ec2:DescribeSpotPriceHistory" - "ec2:CreateLaunchTemplate" - "ec2:DeleteLaunchTemplate" - "ec2:ModifyLaunchTemplate" - "ec2:TerminateInstances" - "ec2:CreateImage" - "ec2:DeregisterImage" - "ec2:DescribeImages" - "ec2:CreateTags" - "ec2:DeleteTags" Resource: "*" - PolicyName: "SpotServiceLinkedRolePermissions" PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - "iam:CreateServiceLinkedRole" - "iam:PassRole" Resource: "arn:aws:iam::*:role/aws-service-role/spot.amazonaws.com/AWSServiceRoleForEC2Spot" ``` Two customization paths are supported. You can edit the CloudFormation template on the AWS redirect page before you apply the connection stack, or you can edit the role after it exists. Teams that want a hard boundary use [IAM tag-based access control](https://docs.aws.amazon.com/IAM/latest/UserGuide/access%5Ftags.html) keyed on `managed-by: warpbuild`, which scopes the role to resources WarpBuild created. The full policy document is expanded in the [configuration docs](/docs/ci/byoc/aws/config). ### [What the runner itself can reach](#what-the-runner-itself-can-reach)Copy Anchor Link The connection role governs what WarpBuild can do to your account. A separate control governs what your build steps can do: the instance profile. WarpBuild injects no permissions into runner workloads. The permissions a job has are exactly the ones carried by the instance profile you attach, configured per runner under Custom Runner configuration and the Instance Profile ARN field. Because the setting is per runner, a deploy runner and a unit-test runner can carry different AWS authority under the same stack. Attaching a profile takes one grant on the WarpBuild connection role: Copy Text ``` aws iam put-role-policy \ --role-name warpbuild- \ --policy-name PassRolePolicy \ --policy-document '{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "iam:PassRole", "Resource": "", "Condition": { "StringEquals": { "iam:PassedToService": "ec2.amazonaws.com" } } } ] }' ``` Verify it with `aws iam simulate-principal-policy` before you wire a workflow to it. The full walkthrough is in the [instance profile guide](/docs/ci/byoc/aws/instance-profile). Runner instances support both IMDSv1 and IMDSv2 by default. Setting Require IMDSv2 on the runner, either at creation time or on the Update Runner page, forces token-authenticated metadata requests and closes the classic path where a request-forgery bug in a build step reads instance credentials. If your build steps read instance metadata directly, confirm the client library sends the IMDSv2 token before you flip it. Network posture rounds it out. The runner agent polls outbound, so runner instances accept inbound connections from nowhere, including from inside the VPC. The [security hardening guide](/docs/ci/byoc/aws/security) recommends a security group with an empty inbound rule set and outbound access to `api.warpbuild.com`, `github.com`, `api.github.com`, and your package registries. Network ACLs handle the second question, which is whether one runner can reach another; runner instances never need to talk to each other, so denying intra-subnet traffic is safe. ## [Configuration](#configuration)Copy Anchor Link Bring the AWS side up first, then create the stack, then create runners. **Prerequisites.** You need permission to run a CloudFormation stack, a VPC with at least one public and one private subnet with internet connectivity, and an S3 bucket in the stack region. The documented recommendation is three public and three private subnets in different availability zones, which widens instance type availability, and at least 250 usable IP addresses per subnet so that concurrency is not capped by address exhaustion. **Quotas.** The runner resources are created in your account and consume your quotas. For a target of `$CON` concurrent jobs, per stack: | Resource | Quota to request | | ------------- | ------------------------------------------------------- | | EC2 instances | $CON x vCPU per job | | EBS volumes | $CON x disk TB per job | | Elastic IPs | 3 plus $CON, when static IPs are enabled | | S3 buckets | 1, shared by artifact cache, layer cache, and telemetry | | VPCs | 1 | Quota increases apply per region, so raise them in the region where the stack lives. The [BYOC AWS docs](/docs/ci/byoc/aws/config) carry the full list and the console links. **Stack modes.** Easy create builds a new VPC and its resources to the documented best practices. Import mode takes an existing VPC, subnets, and security group as inputs and is supported on AWS only. **Custom runners.** A custom runner is a named instance configuration: one or more instance types in priority order, disk size and performance, IP configuration, standby disk count, and an optional instance profile. Listing several instance types matters more than it looks. The workflow references a single label, and WarpBuild picks an available instance type from your ordered list, so a capacity gap in one type does not park jobs in the queue. Choose types that are close in price and performance, for example `m7a.xlarge` and `m7i.xlarge`, so job duration stays predictable. Disk configuration is the most common cause of slow BYOC jobs. The documented minimum is 100 GB, 125 MBps, and 3000 IOPS. The documented recommendation is 150 GB, 400 MBps, and 4000 IOPS. Windows Server 2022 x86-64 runners on AWS want at least 8 vCPU, the m7a series, 6000 IOPS, and 500 MBps. Spot instances suit short jobs that tolerate interruption, and AWS bills them at its published Spot rates in your account, where AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices ([AWS EC2 Spot Instances](https://aws.amazon.com/ec2/spot/), checked on 2026-08-13). Pair Spot with a longer fallback instance type list, since availability is what strands a queued job. Two behaviors need no configuration. If the instance types you select expose local NVMe instance store volumes, such as the `i3`, `c5d`, `m5d`, and `r5d` families, the runner detects them at boot and mounts them at the work directory; multiple devices are combined into a RAID-0 array first. This is Linux images only. Nested virtualization is enabled automatically when every instance type in the runner set supports it. ### [Referencing runners in a workflow](#referencing-runners-in-a-workflow)Copy Anchor Link A BYOC runner is addressed by its Runner ID, which is the runner name with the `warp-custom-` prefix. Nothing else in the workflow changes. .github/workflows/ci.yml Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-custom-payments-use1-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test integration-tests: needs: unit-tests runs-on: warp-custom-payments-use1-16x steps: - uses: actions/checkout@v4 - run: ./scripts/integration.sh ``` Mixed fleets are common while a migration is in flight. WarpBuild-hosted labels and BYOC labels coexist in one workflow, so a matrix can send the heavy leg into your account and leave the rest on hosted capacity: .github/workflows/build.yml Copy Text ``` name: build on: [push] jobs: lint: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run lint package: strategy: matrix: include: - runner: warp-ubuntu-latest-x64-8x target: linux-amd64 - runner: warp-ubuntu-latest-arm64-8x target: linux-arm64 - runner: warp-windows-latest-x64-8x target: windows-amd64 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - run: ./scripts/package.sh ${{ matrix.target }} ``` Those hosted labels and their rates come from the runner catalog: | Label | OS | vCPU | RAM | Storage | Hosted rate per minute | | --------------------------- | ------------------- | ---- | ------ | --------- | ---------------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.064 | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.012 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64 GB | 256GB SSD | $0.064 | On AWS BYOC the supported guest operating systems are Linux and Windows Server 2022 x86-64; the AWS hypervisor does not expose the Hyper-V features their Azure equivalents do, and macOS runners stay on WarpBuild-hosted capacity. ### [Custom VM images](#custom-vm-images)Copy Anchor Link BYOC runners can boot your own AMI instead of a WarpBuild image, which is how teams that must ship a hardened base or a preloaded toolchain keep BYOC compliant with their image policy. Linux and Windows AMIs are supported. A Linux image has to use `systemd`, because the WarpBuild agent runs as a unit, and it has to carry `curl`, `wget`, `bash`, `jq`, and `libicu`, the last of which the GitHub Actions runner needs for its .NET runtime. Ubuntu and Amazon Linux 2023 both qualify. Keep `tar`, `gzip`, `coreutils`, and `shadow-utils` in place. The [custom VM images guide](/docs/ci/byoc/custom-vm-images) includes a working Packer template. Owning the image also means owning its patch cycle. WarpBuild-managed images are updated by WarpBuild; a custom AMI is rebuilt on your schedule. ### [The Terraform path](#the-terraform-path)Copy Anchor Link Terraform support exists for BYOC on AWS. The [WarpBuild Terraform provider](https://registry.terraform.io/providers/WarpBuilds/warpbuild/latest) is published on the Terraform Registry and manages BYOC runner images and custom runner sets, the same surface as the [automation API](/docs/ci/api-keys/automation). It authenticates with a WarpBuild API key carrying the `ci` scope, supplied as `WARPBUILD_API_KEY`. The provider is in beta, so pin the version exactly: Copy Text ``` terraform { required_providers { warpbuild = { source = "WarpBuilds/warpbuild" version = "0.2.0-beta" } } } ``` Terraform coverage is scoped to AWS BYOC. Resource reference, data sources, and a full example live on the [AWS BYOC Terraform page](/byoc/aws/terraform). ## [Who Manages What](#who-manages-what)Copy Anchor Link The reason to read a BYOC page carefully is to find out which team gets paged at 2am. Here is the split. | Responsibility | Your AWS account | WarpBuild | | ------------------------------------------------------------ | ----------------------------------------------- | ---------------------------------------------------- | | Provisioning the VPC, subnets, security group, and S3 bucket | Yes, through the CloudFormation stack you apply | Authors the template and the create and import flows | | Launching and terminating runner instances | Instances run here and consume your quotas | Watches the job queue and drives the AWS API | | Runner image patching | Only for a custom AMI you supply | Patches WarpBuild-managed runner images | | Cloud bill for EC2, EBS, and data transfer | Yes, billed by AWS at your rates | Not billed | | WarpBuild fee | $0.002 per runner minute | Metered and invoiced | | Job data, caches, logs, and artifacts | Held in your S3 bucket and on your instances | Control plane metadata only | | IAM authority granted to build steps | Instance profile you attach per runner | Injects no permissions into workloads | | Quota increases and capacity planning | Yes, per region | Documents the quota model | | Runner lifecycle bugs and control plane incidents | Escalate | Owns | Two consequences follow. Build data and cache contents stay inside your account and your region, which is the usual reason a regulated team picks BYOC over hosted capacity. And capacity limits become AWS quota questions rather than vendor questions, which is why the quota table above is worth filing before the first migration rather than after it. If your requirement is regional placement rather than account placement, WarpBuild-hosted runners cover it too: US and EU are the regions WarpBuild names publicly, and the [US data residency page](/enterprise/data-residency-us) covers that path. ### [A worked cost model](#a-worked-cost-model)Copy Anchor Link Three ways to run the same 8 vCPU Linux job, priced against GitHub's published list prices. GitHub rates come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the plan page at [github.com/pricing](https://github.com/pricing), checked on 2026-08-13\. WarpBuild rates come from the [WarpBuild pricing page](/pricing). | Shape | GitHub-hosted list price per minute | WarpBuild-hosted rate per minute | WarpBuild BYOC fee per minute | | ---------------------- | -------------------------------------------- | -------------------------------- | ----------------------------- | | Linux 2 vCPU, 8 GB | $0.006 (ubuntu-latest, private repositories) | $0.004 | $0.002 | | Linux 4 vCPU, 16 GB | $0.012 (4-core larger runner) | $0.008 | $0.002 | | Linux 8 vCPU, 32 GB | $0.022 (8-core larger runner) | $0.016 | $0.002 | | Linux 16 vCPU, 64 GB | $0.042 (16-core larger runner) | $0.032 | $0.002 | | Linux 32 vCPU, 128 GB | $0.082 (32-core larger runner) | $0.064 | $0.002 | | Windows 8 vCPU, 32 GB | $0.042 (8-core larger runner) | $0.032 | $0.002 | | Windows 16 vCPU, 64 GB | $0.082 (16-core larger runner) | $0.064 | $0.002 | Take a team burning 200,000 Linux minutes a month on an 8 vCPU shape. * GitHub-hosted 8-core larger runner: 200,000 x $0.022 = $4,400 per month. * WarpBuild-hosted `warp-ubuntu-latest-x64-8x`: 200,000 x $0.016 = $3,200 per month. Arithmetic on the two list prices: (0.022 - 0.016) / 0.022 = 27 percent lower list price, GitHub list price checked on 2026-08-13. * WarpBuild BYOC on AWS: 200,000 x $0.002 = $400 per month in WarpBuild fees, plus whatever AWS bills your account for the EC2 instances, EBS volumes, and data transfer those jobs consume, at your own On-Demand, Spot, or Savings Plan rates. Add 50,000 Windows minutes on an 8 vCPU shape and the same three lines read $2,100, $1,600, and $100 plus your AWS bill. The BYOC line covers the WarpBuild fee alone, so the honest comparison is $400 plus your EC2 bill against $4,400, and whether it lands depends on your instance mix, your Spot ratio, and your committed-use discounts. Model it with a real month of workflow minutes before you migrate, and price the storage the cache and telemetry prefixes will hold. Use the table above with your measured job duration and AWS instance rates. WarpBuild's per-size arithmetic is also on the [pricing page](/pricing). BYOC add-ons are included in the per-minute fee. Egress costs during deploy-heavy jobs are handled on the enterprise tier; see [zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress). ### [Operating it after the migration](#operating-it-after-the-migration)Copy Anchor Link CI observability streams OpenTelemetry system metrics from the runner agent and correlates them with GitHub Actions job logs, which is how you tell an undersized disk from a slow test suite. The Action Debugger pauses a workflow and opens an SSH session on the runner, which is the fastest way to reproduce a failure that only happens on the fleet. Snapshot runners are the one product surface BYOC does not carry; they do not support BYOC, Windows, or macOS runners, and standby disks fill the cold-start role instead. SSO is available for a flat $250 per month, whatever the user count, listed with the rest of the rates on the [pricing page](/pricing). The other two clouds are covered on the [GCP BYOC page](/byoc/gcp) and the [Azure BYOC page](/byoc/azure). ## [FAQ](#faq)Copy Anchor Link **Does WarpBuild need inbound network access to my AWS account?** No. The GitHub Actions runner agent holds an outbound long poll, so the recommended security group blocks all inbound traffic and allows outbound traffic to api.warpbuild.com, github.com, api.github.com, and your package registries. WarpBuild reaches your account through the IAM role created by the CloudFormation connection stack, and the instances expose no inbound path. **What does WarpBuild charge for BYOC runners on AWS?** $0.002 per runner minute for Linux runners and $0.002 per runner minute for Windows runners. AWS bills your own account for EC2, EBS, and data transfer at your rates. **Can I manage AWS BYOC runners with Terraform?** Yes. Terraform support exists for BYOC on AWS. The WarpBuild provider is published on the Terraform Registry, covers BYOC runner images and custom runner sets, and authenticates with a WarpBuild API key carrying the `ci` scope. Terraform coverage is scoped to AWS BYOC. **Are snapshot runners available on BYOC?** No. Snapshot runners do not support BYOC runners, Windows runners, or macOS runners. On BYOC the equivalent start-time lever is a standby disk pool, which pre-initializes VMs of the runner instance type so a queued job boots in about 15 seconds. --- # Cost Allocation Tags for BYOC Runners | WarpBuild URL: https://www.warpbuild.com/byoc/aws/cost-allocation-tags Description: A WarpBuild BYOC stack tags every AWS resource it creates. The default keys, a per-team and per-repository tag scheme, and how to reconcile both bills. # Cost Allocation Tags for BYOC Runners A WarpBuild BYOC stack tags every AWS resource it creates. The default keys, a per-team and per-repository tag scheme, and how to reconcile both bills. Tag GitHub Actions runner instances at the stack: a WarpBuild BYOC stack applies a documented tag set to every AWS resource it creates and adds any custom tags you define on the stack, so EC2 and EBS spend separates by runner set, by stack, and by GitHub organization in Cost Explorer once you activate those keys. Repository-level allocation comes from the WarpBuild CI billing report, which carries the repository on every job row, and the two views join on runner label plus stack plus date. ## [Overview](#overview)Copy Anchor Link BYOC splits the invoice in two. WarpBuild bills a per-minute fee for the runners it schedules, and your own AWS account bills the EC2 instances, the EBS volumes, and the network and storage those instances touch. Allocation work on BYOC means making both sides separable by the same dimension and then joining them. BYOC runs on AWS, GCP, and Azure. Custom resource tags are available on AWS today, so everything below is AWS-specific; [BYOC runners on AWS for GitHub Actions](/byoc/aws) covers the surrounding setup. Start from what each side can separate, because the honest answer is that neither side does the whole job alone. | Dimension | In the AWS bill | In the WarpBuild reports | | --------------------- | ------------------------------------------------------------ | ------------------------------ | | GitHub organization | warpbuild-github-org tag | Account scope | | Stack and region | warpbuild-stack-name, warpbuild-stack-id tags | Stack column on every job row | | Runner set | Name, warpbuild-runner-id tags | Runner label column | | Runner labels | warpbuild-runner-labels tag | Runner label column and filter | | Team | Custom stack tag, or the team encoded in the runner set name | Runner label and job name | | Repository | No tag carries it | Repository column and filter | | Workflow and job name | No tag carries it | Job name column and filter | Machine-level dollars live in the AWS bill and are grouped by tag. Repository and workflow detail lives in the [Reports page](/docs/ci/features/reports) and is grouped by the columns on each job row. A chargeback table that has to answer both questions reads from both. ## [Architecture](#architecture)Copy Anchor Link Tags are applied where the resources are created. Runner instances are launched just in time when a job is queued and terminated when it ends, so tagging happens on the launch path rather than as a later sweep. These keys are added by default to every resource a stack creates, per the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config): | Tag key | Value | | ----------------------- | ------------------------------------- | | warpbuild-managed-by | warpbuild | | Name | {runner-id} | | warpbuild-github-org | {github-org} | | warpbuild-runner-labels | {runner-label1}, {runner-label2}, ... | | warpbuild-runner-id | {runner-id} | | warpbuild-stack-id | {stack-id} | | warpbuild-stack-name | {stack-name} | WarpBuild also adds a `managed-by: warpbuild` tag to provisioned resources, which doubles as a key for [AWS IAM tag-based access control](https://docs.aws.amazon.com/IAM/latest/UserGuide/access%5Ftags.html). On stack resources that tagging starts at CloudFormation template version 1.3 in create mode, and runner instances are tagged in all cases. Upgrade the stack template from the WarpBuild dashboard if your stack predates 1.3 and the stack resources come back untagged. Naming conventions are the backstop for anything a tag filter misses, and they matter for cleanup queries as much as for cost. | Resource type | Naming pattern | | ---------------- | -------------- | | S3 buckets | warpbuild-\* | | EC2 instances | warp-\* | | EBS volumes | warp-\* | | Launch templates | tmpl-warp-\* | Two properties of this tag set drive the scheme in the next section. First, no default key carries the repository, the workflow, or the job name, because the instance is provisioned against a runner set rather than against a repository. Second, custom tags are declared on the stack, so their values are fixed for every resource that stack creates. One step happens outside WarpBuild. AWS requires user-defined tag keys to be activated as cost allocation tags in the Billing console before they appear in cost reports, and states that activation can take up to 24 hours ([AWS cost allocation tags](https://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/activating-tags.html), checked on 2026-08-13). Activated keys appear in the Cost and Usage Report with a `user:` prefix. ## [Configuration](#configuration)Copy Anchor Link For a shared AWS account, encode the allocation dimension that varies per team in the runner set name and reserve custom stack tags for values that hold across the whole stack. | What you want to allocate by | Where it is set | Key it lands in | | ---------------------------- | -------------------------------------------------- | -------------------------------------------------- | | Team or squad | Custom runner set name | Name, warpbuild-runner-id, warpbuild-runner-labels | | Cost center or business unit | Custom tag on the stack | Your own key, for example cost-center | | Environment | Custom tag on the stack, one stack per environment | Your own key, for example env | | Region | Fixed at stack creation | warpbuild-stack-name | | Repository | Reports only | Repository column, no AWS tag | A runner set named `payments-linux-x64-8` registers the GitHub Actions label `warp-custom-payments-linux-x64-8`, and that same string reaches the AWS bill as the `Name` tag and the `warpbuild-runner-id` value. The workflow side is one line per job. Copy Text ``` name: build on: [push] jobs: api-tests: runs-on: warp-custom-payments-linux-x64-8 steps: - uses: actions/checkout@v4 - run: ./scripts/test-api.sh web-tests: runs-on: warp-custom-web-linux-x64-4 steps: - uses: actions/checkout@v4 - run: npm ci && npm test docs: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/build-docs.sh ``` The first two jobs land on BYOC instances in your account and appear in the AWS bill under their runner set tags. The `docs` job runs on a WarpBuild hosted runner, so it appears in the WarpBuild reports and never touches your AWS bill. On AWS BYOC the Linux and Windows sets are the ones your tags cover. Once the keys are active, group by them in Cost Explorer, or query the Cost and Usage Report directly. Tag column naming follows your report version, so check the schema before pasting this in. Copy Text ``` SELECT resource_tags_user_warpbuild_runner_id AS runner_set, line_item_product_code AS service, SUM(line_item_unblended_cost) AS cost FROM cur.aws_costs WHERE line_item_usage_start_date >= DATE '2026-08-01' AND resource_tags_user_warpbuild_managed_by = 'warpbuild' GROUP BY 1, 2 ORDER BY cost DESC; ``` Filtering on `warpbuild-managed-by` first keeps the query scoped to runner infrastructure, and grouping by service separates EC2 instance hours from EBS storage before anyone asks why the disk line moved. ## [Operations](#operations)Copy Anchor Link Reconciliation is a monthly three-step pass, and it works because both sides carry the runner set. 1. Export the CI billing table from the [Reports page](/docs/ci/features/reports) filtered to the stack. Every report tab exports CSV containing all rows matching the current filters rather than the visible page, and each row carries repository, job name, runner label, stack, execution time, billed time, and cost. 2. Run the tag grouping above for the same date range, or group by `warpbuild-runner-id` in Cost Explorer. 3. Join on runner label and date. The WarpBuild side gives you the platform fee and the repository split; the AWS side gives you the compute dollars for the same runner sets. The WarpBuild half of the join is arithmetic you can do before the export. BYOC runners bill at $0.002 per minute, from the [pricing page](/pricing), checked on 2026-08-13. | Runner set | Job minutes | WarpBuild fee | Compute billed by | | -------------------------------- | ----------- | ------------- | ----------------- | | warp-custom-payments-linux-x64-8 | 60,000 | $120.00 | Your AWS account | | warp-custom-web-linux-x64-4 | 35,000 | $70.00 | Your AWS account | | warp-custom-data-linux-x64-16 | 25,000 | $50.00 | Your AWS account | | Total | 120,000 | $240.00 | | For comparison against a line with no cloud bill behind it, the hosted `warp-ubuntu-latest-x64-4x` runner (4 vCPU, 16 GB) bills $0.008 per minute with compute included, from the same pricing page checked on the same date. Four AWS line types have no matching WarpBuild billed minute, and they are what makes the two totals differ: * Instance wall clock time, which includes boot and teardown around the job that WarpBuild bills. * EBS volumes for standby disks, which keep billing storage while the VM sits shut down. WarpBuild does not charge for standby disks. * Network data processing charges for runners in private subnets, shared across every job in the stack. * The stack S3 bucket holding artifact cache, container layer cache, and runner telemetry. The documented recommendation is a 7 day retention lifecycle rule, adjusted to your own policy. Set a cadence around three events. Re-run tag activation when a new stack is created, since custom stack tags are declared per stack. Re-check the grouping after adding a runner set, since a set named outside your convention lands in an untagged bucket in the report. Re-check after a stack template upgrade, since tagging on stack resources depends on the template version. Every cost number on this page carries a source link and the date it was checked, and the same rates appear on the pricing page and in the evidence data behind it. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so a per-team runner set costs nothing to create beyond the minutes it runs, and splitting one shared set into five tagged sets changes the report without changing the bill. Signup includes $10 free credits, which is enough to create a stack, run a tagged workload, and see the keys arrive in your own cost report. For the wider picture, [a cost model for BYOC GitHub Actions runners](/byoc/cost-model) works through the two-line model in full, [attributing GitHub Actions cost per repository and team](/guides/github-actions-cost-attribution) covers the hosted-runner side of the same question, and [how do I tag runner resources for cost allocation](/answers/how-do-i-tag-byoc-resources-for-cost-allocation) is the short version of this page. ## [FAQ](#faq)Copy Anchor Link **Do the WarpBuild tag keys work in Cost Explorer as soon as the stack is created?** No. AWS requires user-defined tag keys to be activated as cost allocation tags in the Billing console of the payer account, and AWS states that activation can take up to 24 hours. Activated keys appear in the Cost and Usage Report with a `user:` prefix, so `warpbuild-runner-labels` reads as `user:warpbuild-runner-labels` there. Activate the keys before the month you plan to bill against them. **How do I split runner spend by team when every team shares one AWS account and one stack?** Put the team in the runner set name. The runner id becomes the `Name` tag and lands in `warpbuild-runner-id`, and the label list lands in `warpbuild-runner-labels`, so Cost Explorer groups EC2 and EBS spend per runner set once those keys are activated. Custom tags are set on the stack and apply to everything that stack creates, so a custom key that must carry a different value per team needs one stack per team instead. **Why do the AWS costs not match the WarpBuild billed minutes exactly?** The two bills meter different things. WarpBuild bills the job at $0.002 per minute for BYOC runners, while AWS bills instance wall clock time, which includes boot and teardown around the job, plus EBS volumes for standby disks, network data processing charges, and the stack S3 bucket that all runner sets share. Reconcile on the share each runner set holds rather than to the cent. --- # Custom AMIs for BYOC Runners | WarpBuild URL: https://www.warpbuild.com/byoc/aws/custom-amis Description: Build your own AMI for WarpBuild BYOC runners on AWS. Linux and Windows image requirements, a packer template, the add-image step, and the rebuild cost model. # Custom AMIs for BYOC Runners Build your own AMI for WarpBuild BYOC runners on AWS. Linux and Windows image requirements, a packer template, the add-image step, and the rebuild cost model. A custom AMI lets a WarpBuild BYOC runner boot with your compilers, SDKs, and internal certificates already installed, so a GitHub Actions job starts working instead of installing. On AWS the path is: build the AMI in your own account, register it on the custom images page, attach it to a custom runner set, then point `runs-on` at the resulting `warp-custom-` label. This page covers the documented image requirements for Linux and Windows AMIs, a packer template and the workflow that builds it, the registration step, and the maintenance model with the rebuild cadence priced against per-job install time. ## [Overview](#overview)Copy Anchor Link BYOC runs on AWS, GCP, and Azure, and each cloud takes its own image format. On AWS the unit is an AMI, and the [custom VM images guide](/docs/ci/byoc/custom-vm-images) supports Linux and Windows based AMIs. The custom AMI path applies to the Linux and Windows runner sets you declare inside your own AWS account, and starts from a connected cloud account and stack as described on [BYOC runners on AWS](/byoc/aws). Three properties are worth knowing before you build anything. **Images are scoped to the stack region.** The add-image dialog lists the images in the region your stack was created in. A stack region cannot be changed after creation, so an AMI built in `us-east-1` is only selectable by a stack in `us-east-1`. Multi-region fleets copy the AMI per region and register each copy. **A custom image carries no image fee.** The custom VM images guide states there is no additional cost for using custom VM images. A BYOC runner set on your own AMI bills $0.002 per runner minute in WarpBuild fees for Linux and $0.002 per runner minute for Windows, the same as a stock image, with EC2 and EBS on your own AWS bill ([WarpBuild pricing](/pricing), checked 2026-08-13). **The base image is yours to keep valid.** WarpBuild launches the instance and registers a just-in-time runner against your GitHub organization; everything above the agent is what you baked in. The term itself is defined on [runner image](/glossary/runner-image), and [custom runner images for GitHub Actions](/guides/custom-runner-images-github-actions) covers the container image path for hosted runners. ## [Architecture](#architecture)Copy Anchor Link The WarpBuild agent is what turns your AMI into a GitHub Actions runner, and its requirements are the whole contract. | Requirement | Linux | Windows | Why it exists | | ------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Init system | systemd required, so Ubuntu and Amazon Linux 2023 based AMIs work | Not applicable | The [WarpBuild agent](https://github.com/WarpBuilds/warpbuild-agent/) runs as a systemd unit | | Must be installed | curl, wget, bash, jq, libicu | aria2, reachable through the system PATH | libicu is a .NET runtime dependency of the GitHub Actions runner; aria2 replaces the slow default Windows download method | | Must not be removed | tar, gzip, coreutils (id, chpasswd, chown, tr, sed), shadow-utils (useradd, usermod), systemd | Default system tooling | Present on supported distros by default and used during runner setup | | Generalization | Not required | EC2 instance must be [sysprepped](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ami-create-win-sysprep.html#sysprep-gui-procedure-ec2launchv2) before the image is created | An unsysprepped Windows AMI produces instances that share machine identity | | Job user | runner user with passwordless sudo | Jobs run as runneradmin, the same user GitHub's Windows runners use | Added if absent, so user-scoped environment variables set under another account are not visible | Two Windows details catch teams during the first boot test. User-scoped environment variables set while you were building the image belong to the account you were logged in as, and jobs run as `runneradmin`, so move them to machine level. RDP port 3389 is not opened by a new stack, so debugging a Windows AMI interactively means adding an inbound rule for your own CIDR block first. The [Windows BYOC runners on AWS](/byoc/aws/windows-runners) page covers the instance sizing and disk throughput those runners want. ## [Configuration](#configuration)Copy Anchor Link Build the AMI with packer against a supported base, install the required packages, and create the `runner` user and the tool cache directories the GitHub Actions runner expects. Copy Text ``` variable "aws_region" { type = string default = "us-east-1" } locals { version = "1.0.0" } source "amazon-ebs" "my-custom-ci" { region = var.aws_region instance_type = "t3.micro" ami_name = "my-custom-ci-v${local.version}" tags = { Name = "my-custom-ci-v${local.version}" team = "platform" build_date = "{{timestamp}}" version = local.version provider = "packer" product = "github-actions" } source_ami_filter { filters = { name = "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.*-amd64-server-*" root-device-type = "ebs" virtualization-type = "hvm" } owners = ["099720109477"] most_recent = true } ssh_username = "ubuntu" launch_block_device_mappings { device_name = "/dev/sda1" volume_type = "gp3" volume_size = 8 delete_on_termination = true } } build { sources = ["source.amazon-ebs.my-custom-ci"] provisioner "shell" { inline = [ "sudo groupadd runner || echo 'Group runner already exists'", "sudo useradd -m -g runner -s /bin/bash runner || echo 'User runner already exists'", "echo 'runner ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/runner", "sudo chmod 440 /etc/sudoers.d/runner", "sudo apt-get update", "sudo apt-get install -y curl wget unzip git jq libicu-dev", "sudo mkdir -p /opt/hostedtoolcache/Python /opt/hostedtoolcache/Node /opt/hostedtoolcache/go", "sudo chown -R runner:runner /opt/hostedtoolcache", "sudo mkdir -p /home/runner/_work /home/runner/_tool /home/runner/_temp", "sudo chown -R runner:runner /home/runner/_work /home/runner/_tool /home/runner/_temp", ] } } ``` Build it from GitHub Actions so the image has the same review path as the rest of the repository. Copy Text ``` name: build-runner-ami on: workflow_dispatch: push: branches: [main] paths: - "packer/github-actions-ami/**" permissions: contents: read id-token: write jobs: build-ami: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/PackerBuildRole aws-region: us-east-1 - uses: hashicorp/setup-packer@v3 - run: packer plugins install github.com/hashicorp/amazon - run: packer validate packer/github-actions-ami/my-custom-ci.pkr.hcl - run: packer build packer/github-actions-ami/my-custom-ci.pkr.hcl ``` Registration is four steps, and only the first is a one-time setup. 1. Create a WarpBuild stack in the region the AMI lives in. 2. Open the [Custom images tab](https://app.warpbuild.com/ci/byoc/custom-images) on the BYOC page, press **Add VM image**, choose the stack, and enter the AMI ID. 3. Create a custom runner set that uses the image, along with the instance type priority list and the disk configuration. 4. Reference the runner set in `runs-on` using the full runner ID, which is the runner name with the `warp-custom-` prefix. Copy Text ``` jobs: build: runs-on: warp-custom-ci-linux-large steps: - uses: actions/checkout@v4 - run: make build ``` If you run per-job setup or teardown scripts, bake the paths into the image rather than into every workflow. GitHub's `ACTIONS_RUNNER_HOOK_JOB_STARTED` and `ACTIONS_RUNNER_HOOK_JOB_COMPLETED` variables are already used by WarpBuild to orchestrate runners, so set the `WARPBUILD_` prefixed alternatives system wide instead, in `/etc/environment` on Linux or as machine level variables on Windows. Scripts must end in `.sh` or `.ps1`, take an absolute path, and a non-zero exit fails the job. ## [Operations](#operations)Copy Anchor Link The maintenance question is how often to rebuild the AMI rather than install per job, and it prices out cleanly. Take a fleet of 20,000 Linux jobs per month, each spending 90 seconds installing the same toolchain. That is 30,000 runner minutes per month, or 500 EC2 instance hours, spent on work an image could have done once. | Line item | Volume | WarpBuild fee | Billed by your AWS account | | ----------------------------------------------- | ---------------------- | ---------------------------------------------- | --------------------------------------- | | Per-job install, 90 s across 20,000 jobs | 30,000 runner minutes | $60 per month at $0.002 per BYOC runner minute | 500 instance hours plus attached EBS | | Weekly AMI rebuild on warp-ubuntu-latest-x64-4x | 4 builds of 15 minutes | $0.48 per month at $0.008 per minute | None, the build runs on a hosted runner | Rates are from the [WarpBuild pricing page](/pricing), checked 2026-08-13\. The breakeven is low: a weekly rebuild costs $0.48 in runner minutes, which is 240 BYOC runner minutes at $0.002, so the image pays for itself once the pipeline spends more than four hours a month on install steps the image could carry. Below that threshold, per-job installation is the cheaper operating model and the AMI stays a thin base. Three operational habits keep the rebuild loop boring. **Version the AMI name and never mutate one in place.** The template above stamps `my-custom-ci-v1.0.0` and tags `build_date` and `version`. Registering the new AMI and repointing the runner set makes rollback a two-minute change back to the previous image ID. **Rebuild on a schedule and on dependency change.** The workflow above triggers on pushes under `packer/`. Adding a weekly `schedule` trigger picks up base image security updates without anyone filing a ticket for it. **Automate the Windows sysprep.** The GUI path works for a first image and does not scale. HashiCorp documents [sysprep commands for Windows AMIs](https://developer.hashicorp.com/packer/integrations/hashicorp/amazon/latest/components/builder/ebs#windows-2022-sysprep-commands-for-amazon-windows-amis-only) that run from packer or from a plain PowerShell session. For the scope question in one paragraph, see [does BYOC support custom VM images](/answers/does-byoc-support-custom-vm-images). ## [FAQ](#faq)Copy Anchor Link **What does a custom AMI cost on WarpBuild BYOC?** Nothing extra. The custom VM images documentation states there is no additional cost for using custom VM images, so a runner set on your own AMI bills the same $0.002 per runner minute in WarpBuild fees as one on a stock image, plus the EC2 and EBS charges on your own AWS account ([WarpBuild pricing](/pricing), checked 2026-08-13). **Which packages must be present in a Linux AMI?** `curl`, `wget`, `bash`, `jq`, and `libicu`, which the GitHub Actions runner needs as a .NET runtime dependency. The distro also has to use systemd, because the WarpBuild agent runs as a systemd unit, and `tar`, `gzip`, `coreutils`, `shadow-utils`, and `systemd` must not be removed from the base image. **Why does a Windows AMI need aria2 and a sysprep pass?** WarpBuild uses aria2 to download runner artifacts on Windows because the default Windows method is slow, so aria2 has to be present and reachable through the system `PATH`. On AWS the EC2 instance must also be sysprepped before you create the image, otherwise the AMI is not generalized and instances launched from it collide on machine identity. --- # IAM Permissions for BYOC on AWS | WarpBuild URL: https://www.warpbuild.com/byoc/aws/iam-permissions Description: WarpBuild BYOC on AWS asks for one IAM role, grouped into five named policy contexts. What each context does, how to narrow it, and what breaks if you cut it. # IAM Permissions for BYOC on AWS WarpBuild BYOC on AWS asks for one IAM role, grouped into five named policy contexts. What each context does, how to narrow it, and what breaks if you cut it. WarpBuild BYOC on AWS asks for one IAM role in your account, created by a CloudFormation template that you apply yourself, with its permissions grouped into five named policy contexts so a security reviewer can read them one block at a time. That role governs what WarpBuild may do to your account; a separate instance profile, which you create and attach per runner, governs what your GitHub Actions job steps may do once an instance is running. This page walks the five contexts by purpose, maps each one to the moment in the job lifecycle where it is exercised, states what stops working when a context is removed, and separates the two identities so a review can approve them independently. The full policy document is expanded in the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config), and the account-level view of the setup is on [BYOC runners on AWS for GitHub Actions](/byoc/aws). ## [Overview](#overview)Copy Anchor Link Connecting an AWS account creates a single IAM role named `warpbuild-`. That role is the entire access surface WarpBuild holds in your account. BYOC runs on AWS, GCP, and Azure, and the AWS path is the one described here. | Policy context | Purpose | Representative actions | | -------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | | FineGrainedEC2Permissions | Runner lifecycle: launch a just-in-time instance for a queued job, then terminate it | ec2:RunInstances, ec2:CreateFleet, ec2:CreateLaunchTemplate, ec2:TerminateInstances, ec2:CreateImage, ec2:CreateTags | | SpotServiceLinkedRolePermissions | Spot capacity: create and maintain the EC2 Spot service-linked role | iam:CreateServiceLinkedRole, iam:PassRole scoped to AWSServiceRoleForEC2Spot | | NetworkPermissions | Discovery: read the network objects a stack needs, and manage the endpoints the stack creates | ec2:DescribeRegions, ec2:DescribeVpcs, ec2:DescribeSubnets, ec2:CreateVpcEndpoint | | StoragePermissions | Cache and telemetry: the S3 objects behind the cache action and runner system logs, plus EBS snapshots | s3:PutObject, s3:GetObject, s3:PutLifecycleConfiguration, ec2:CreateSnapshot | | CloudFormationPermissions | Upgrades: raise change set requests when a connection or stack template moves to a new version | CloudFormation change set actions | Read the expanded document in the configuration guide before the review meeting. It lists the statements for the EC2, spot, network, and storage contexts and describes the CloudFormation context alongside them, so the reviewer sees the literal template rather than a summary of it. ## [Architecture](#architecture)Copy Anchor Link The permissions look broad on paper because they are exercised across one short lifecycle. Following a single job makes the grouping easier to defend. GitHub queues a job whose `runs-on` label matches one of your custom runner sets. WarpBuild reads which instance types are available in your region and availability zones, launches an instance from a launch template in your stack, tags it, registers a just-in-time runner against your GitHub organization, and terminates the instance when the job ends. During the job, the cache action and the runner telemetry writer read and write objects in the stack S3 bucket. Nothing in that sequence holds state between jobs, and every instance is discarded at the end. Each context therefore fails in a specific way rather than degrading the whole system. | Context removed | What stops working | What still works | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | | FineGrainedEC2Permissions | Instances no longer launch, so jobs sit in the queue; running instances are no longer terminated by WarpBuild, so capacity has to be reclaimed by hand | Existing stack, cache bucket, dashboard configuration | | SpotServiceLinkedRolePermissions | Runner sets configured for spot capacity fail to acquire instances | On-demand capacity types, if the service-linked role already exists in the account | | NetworkPermissions | Import mode stack creation cannot enumerate VPCs, subnets, route tables, and security groups; endpoint management for the stack breaks | Runners on a stack that is already created, until an endpoint changes | | StoragePermissions | Cache reads and writes fail, runner system logs stop being pushed, bucket lifecycle configuration cannot be applied | Job execution itself, at cold-cache speed | | CloudFormationPermissions | Connection and stack template upgrades cannot be raised as change sets, so every template move becomes a manual apply | Everything on the current template version | The last row is the one teams underestimate. Template versions carry fixes: stacks created before CloudFormation template v1.4 can hit ECR authentication timeouts for runners in public subnets, and the fix is a stack upgrade through the WarpBuild dashboard. Cutting the CloudFormation context does not block that upgrade forever, though it does move the work to your team. ## [Configuration](#configuration)Copy Anchor Link Two customization paths are supported, and both are documented in the configuration guide. **Edit before you apply.** Modify the CloudFormation template on the AWS redirect page before applying the connection role stack. This is the path for teams whose policy forbids applying a vendor template as published. **Edit after creation.** Modify the role once it exists. It follows the `warpbuild-` format, so it is straightforward to target from your own IAM tooling or from a drift detector. Resource scoping is the other lever. WarpBuild tags every resource it provisions with `managed-by: warpbuild`, which lets you write [AWS IAM tag-based access control](https://docs.aws.amazon.com/IAM/latest/UserGuide/access%5Ftags.html) conditions that keep the role inside resources WarpBuild created. Resource naming follows fixed patterns as well, which the same conditions can key on: S3 buckets are `warpbuild-*`, EC2 instances and EBS volumes are `warp-*`, and launch templates are `tmpl-warp-*`. Attaching an instance profile to a runner takes one explicit grant. The `iam:PassRole` statement already in the connection template points at the EC2 Spot service-linked role ARN, so it does not cover your runner role. Copy Text ``` aws iam put-role-policy \ --role-name warpbuild- \ --policy-name PassRolePolicy \ --policy-document '{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "iam:PassRole", "Resource": "arn:aws:iam:::role/ci-runner-role", "Condition": { "StringEquals": { "iam:PassedToService": "ec2.amazonaws.com" } } } ] }' ``` Confirm the grant landed before you point a workflow at the runner: Copy Text ``` aws iam simulate-principal-policy \ --policy-source-arn arn:aws:iam:::role/warpbuild- \ --action-names iam:PassRole \ --resource-arns arn:aws:iam:::role/ci-runner-role \ --context-entries ContextKeyName=iam:PassedToService,ContextKeyType=string,ContextKeyValues=ec2.amazonaws.com ``` The full walkthrough, from creating the role through selecting it on a runner, is on [attach an IAM instance profile to AWS BYOC runners](/byoc/aws/instance-profiles). Terraform support exists for BYOC on AWS, and it starts one layer above this. The provider reads an existing stack and manages runner images and custom runner sets; the connection role stays a CloudFormation object that you apply and own. ## [Operations](#operations)Copy Anchor Link Keeping the two identities separate is what makes an ongoing review cheap. One is reviewed when the connection is created or upgraded. The other is reviewed whenever a workload changes. | Question | Connection role (warpbuild-) | Runner instance profile | | ------------------------------------------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Who uses it | The WarpBuild control plane, to launch and terminate instances | The EC2 runner instance, and therefore your workflow steps | | Who creates it | The CloudFormation connection stack that you apply | You, with aws iam create-role and aws iam create-instance-profile | | Where it is configured | The cloud connection in the WarpBuild dashboard | Per runner, under Custom Runner configuration and the Instance Profile ARN field | | How many exist | One per cloud connection | As many as you want, scoped per workload | | What a compromised build step reaches through it | Nothing; the role is held by the control plane | Exactly the policy you attached, and nothing more | | Effect of removing it | Runners stop launching | Jobs lose AWS access while runners keep launching | WarpBuild injects no permissions into runner workloads. A job holds exactly the permissions carried by the instance profile you attached, so a deploy runner and a pull request test runner can hold different AWS authority under one stack. The [security hardening guide](/docs/ci/byoc/aws/security) carries least-privilege policy examples for the two common cases, ECR push and S3 artifact upload, plus the IMDSv2 requirement that closes the request-forgery path to instance credentials. For a shorter version of this page to paste into a review ticket, see [what permissions does BYOC need in my AWS account](/answers/what-permissions-does-byoc-need-in-my-aws-account). For the rest of the review surface, network posture and metadata settings included, work through the [security checklist for BYOC runners on AWS](/byoc/aws/security-checklist). Two operational habits are worth setting up on day one. First, treat the connection role as a reviewable artifact: diff it after every stack or connection upgrade, because permissions are updated when new features need them. Second, alert on `iam:PassRole` policy changes for the connection role, since that single statement is what decides which runner roles can be attached. Costs split along the same line as permissions. WarpBuild charges $0.002 per runner minute for BYOC Linux runners and $0.002 per runner minute for BYOC Windows runners, and your own AWS account is billed directly for EC2, EBS, and data transfer at your account rates, including any discounts you already hold ([WarpBuild pricing](/pricing), checked 2026-08-13). For the compliance question that follows this one in most reviews: SOC 2 Type 2, with [trust.warpbuild.com](https://trust.warpbuild.com/) as the linked evidence. On AWS BYOC the Linux and Windows sets are the ones this role launches. ## [FAQ](#faq)Copy Anchor Link **How many IAM roles does WarpBuild BYOC on AWS need?** One role in your account per cloud connection, named `warpbuild-` and created by the CloudFormation connection stack. Runner instance profiles are separate roles that you create and attach per runner, and WarpBuild never creates them for you. **Can I remove permissions from the WarpBuild connection role?** Yes. Edit the CloudFormation template on the AWS redirect page before you apply it, or edit the role after it is created. Each policy context maps to a function, so removing one disables that function rather than degrading it: cutting `StoragePermissions`, for example, stops cache reads and writes and runner system logs. **Why does the connection role include `iam:PassRole`?** The PassRole statement in the connection template is scoped to the EC2 Spot service-linked role ARN. Attaching an instance profile to your runners needs a second PassRole grant, scoped to your own runner role and conditioned on `iam:PassedToService` equals `ec2.amazonaws.com`. --- # Instance Profiles for BYOC Runners | WarpBuild URL: https://www.warpbuild.com/byoc/aws/instance-profiles Description: Attach an IAM instance profile to WarpBuild BYOC runners on AWS so every GitHub Actions job on a runner set gets the AWS permissions it needs, and no more. # Instance Profiles for BYOC Runners Attach an IAM instance profile to WarpBuild BYOC runners on AWS so every GitHub Actions job on a runner set gets the AWS permissions it needs, and no more. Attach an IAM instance profile to WarpBuild BYOC runners on AWS in three moves: create an IAM role that EC2 can assume, grant `iam:PassRole` on that role to the WarpBuild connection role, then set the instance profile ARN on the custom runner. Every GitHub Actions job that lands on that runner set reads short-lived AWS credentials from the instance metadata service, so no AWS access keys go into GitHub secrets. This page covers the documented prerequisites, the setup commands, a least-privilege policy scoped to one bucket and one registry, and where an instance profile beats an OIDC exchange run from inside the workflow. ## [Overview](#overview)Copy Anchor Link Runner instances in a BYOC AWS stack are EC2 instances in your own account, so they take AWS identity the way any other EC2 instance does: through an instance profile that wraps an IAM role. WarpBuild adds no permissions to runner workloads, so what a job can reach in AWS is exactly what the attached profile grants, as stated in the [BYOC security hardening guide](/docs/ci/byoc/aws/security). Scoping is per runner set. The Instance Profile ARN field sits on the Custom Runner configuration rather than on the stack, so permissions follow the `runs-on` label instead of the whole fleet. | Prerequisite | Who creates it | Detail | | -------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | IAM role trusted by EC2 | You, in AWS | Trust policy names ec2.amazonaws.com as the principal ([AWS IAM roles for EC2](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html)) | | Instance profile holding that role | You, in AWS | The console creates one implicitly with the role; the CLI needs create-instance-profile plus add-role-to-instance-profile ([AWS instance profiles](https://docs.aws.amazon.com/IAM/latest/UserGuide/id%5Froles%5Fuse%5Fswitch-role-ec2%5Finstance-profiles.html)) | | WarpBuild connection role name | WarpBuild CloudFormation connection stack | Read it from the [BYOC page](https://app.warpbuild.com/ci/byoc); the name follows the pattern warpbuild- | | iam:PassRole on the runner role | You, added to the connection role | Conditioned on iam:PassedToService equal to ec2.amazonaws.com | | Instance Profile ARN on the runner set | You, in the WarpBuild dashboard | Custom Runner configuration, one ARN per runner set | BYOC runs on AWS, GCP, and Azure. Instance profiles are the AWS mechanism, and everything below is AWS only. ## [Architecture](#architecture)Copy Anchor Link Two IAM roles are in play and they belong to different parties. The connection role, `warpbuild-`, is what the WarpBuild control plane assumes to call `RunInstances` in your account. The runner role is yours, and its credentials are what your build steps use. Launching an instance that carries a role means passing an IAM role to the EC2 service, and AWS gates that action behind `iam:PassRole` on the passed role. That grant is the one step that surprises teams, because every other runner setting is picked from a dropdown. | Object | Owner | Lifetime | | -------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | | Connection role warpbuild- | Created by the WarpBuild CloudFormation connection stack, held in your account | Lives as long as the cloud connection | | Runner IAM role and its policy | You | Lives until you delete it | | Instance profile | You | Lives until you delete it | | Credentials inside the job | EC2 instance metadata service | Rotate automatically, and disappear when the instance is terminated after the job | Because each job runs on a freshly provisioned instance that is terminated when the job ends, the credential material never outlives the job, and a policy edit applies to the next job that starts. There is no long-running fleet to roll and no secret in GitHub to rotate. The other route to AWS credentials in GitHub Actions is an OIDC exchange inside the workflow: GitHub mints a token for the job and `aws-actions/configure-aws-credentials` trades it through `sts:AssumeRoleWithWebIdentity` for role credentials, with the trust policy conditioned on repository, branch, or environment claims ([GitHub docs on OpenID Connect in AWS](https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services)). | Dimension | Instance profile on the runner set | OIDC exchange in the workflow | | ---------------------------------- | --------------------------------------------------- | ------------------------------------------------------ | | Where the trust decision lives | Runner set configuration plus a role trusted by EC2 | Role trust policy with conditions on the GitHub claims | | Granularity | Every job that targets the label | The job that runs the exchange | | Workflow edit required | None | An extra step and id-token: write permission | | Available on GitHub-hosted runners | No, BYOC EC2 instances only | Yes | | Who can obtain the credentials | Anyone who can queue a job on that label | Anyone whose workflow satisfies the trust conditions | The case for the instance profile is standing access that belongs to the machine: cache buckets, base image pulls, internal package mirrors, and tools that read the default AWS credential chain with no configuration. The case for the OIDC exchange is access that belongs to a workflow rather than a runner set, and the branch and environment conditions it can carry. They compose: run the deploy job on a runner set with a narrow profile, and still exchange for the production role inside that job. ## [Configuration](#configuration)Copy Anchor Link Create the role with a trust policy naming EC2. Copy Text ``` aws iam create-role \ --role-name ci-runner-role \ --assume-role-policy-document '{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": {"Service": "ec2.amazonaws.com"}, "Action": "sts:AssumeRole" } ] }' ``` Attach a policy scoped to the resources the jobs on this label actually touch. The policy below reaches one artifact bucket and one ECR repository, and nothing else. `ecr:GetAuthorizationToken` is the one action that takes `"Resource": "*"`, because the token is account level. Copy Text ``` { "Version": "2012-10-17", "Statement": [ { "Sid": "ArtifactBucket", "Effect": "Allow", "Action": ["s3:PutObject", "s3:GetObject", "s3:ListBucket"], "Resource": [ "arn:aws:s3:::acme-ci-artifacts", "arn:aws:s3:::acme-ci-artifacts/*" ] }, { "Sid": "RegistryAuth", "Effect": "Allow", "Action": "ecr:GetAuthorizationToken", "Resource": "*" }, { "Sid": "OneRepository", "Effect": "Allow", "Action": [ "ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:PutImage", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart", "ecr:CompleteLayerUpload" ], "Resource": "arn:aws:ecr:us-east-1:123456789012:repository/acme-api" } ] } ``` Copy Text ``` aws iam put-role-policy \ --role-name ci-runner-role \ --policy-name ci-runner-policy \ --policy-document file://policy.json aws iam create-instance-profile \ --instance-profile-name ci-runner-profile aws iam add-role-to-instance-profile \ --instance-profile-name ci-runner-profile \ --role-name ci-runner-role ``` Grant `iam:PassRole` on that role to the WarpBuild connection role, exactly as the [instance profile setup guide](/docs/ci/byoc/aws/instance-profile) documents. Copy Text ``` aws iam put-role-policy \ --role-name warpbuild- \ --policy-name PassRolePolicy \ --policy-document '{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "iam:PassRole", "Resource": "arn:aws:iam::123456789012:role/ci-runner-role", "Condition": { "StringEquals": { "iam:PassedToService": "ec2.amazonaws.com" } } } ] }' ``` Paste the instance profile ARN, `arn:aws:iam::123456789012:instance-profile/ci-runner-profile`, into Custom Runner configuration in the WarpBuild dashboard. The runner set registers a label formed as `warp-custom-` plus the runner name, and workflows use it with no credential step at all. Copy Text ``` name: publish on: push: branches: [main] jobs: image: runs-on: warp-custom-ci-linux-large steps: - uses: actions/checkout@v4 - name: Confirm the runner identity run: aws sts get-caller-identity - name: Log in to the registry run: | aws ecr get-login-password --region us-east-1 \ | docker login --username AWS --password-stdin \ 123456789012.dkr.ecr.us-east-1.amazonaws.com - run: docker build -t 123456789012.dkr.ecr.us-east-1.amazonaws.com/acme-api:${{ github.sha }} . - run: docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/acme-api:${{ github.sha }} - run: aws s3 cp ./dist s3://acme-ci-artifacts/${{ github.sha }}/ --recursive ``` ## [Operations](#operations)Copy Anchor Link Verify the `iam:PassRole` grant before you wait on a queued job. A simulation answers in one call. Copy Text ``` aws iam simulate-principal-policy \ --policy-source-arn arn:aws:iam::123456789012:role/warpbuild- \ --action-names iam:PassRole \ --resource-arns arn:aws:iam::123456789012:role/ci-runner-role \ --context-entries ContextKeyName=iam:PassedToService,ContextKeyType=string,ContextKeyValues=ec2.amazonaws.com ``` If jobs sit queued after a profile change, the pass-role grant and a typo in the ARN are the first two things to check. `aws sts get-caller-identity` as an early step tells you which role a job actually received. Require IMDSv2 on runner sets that carry a profile. The toggle is under Runner Specs in the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config), and it blocks the request shape that server-side request forgery in a build step would use to read instance credentials. Split labels by permission rather than by team. One runner set with a read-only profile for pull request builds and a second with write access for deploys gives you a boundary that shows up in code review, since promoting a job to the wider permission set means editing `runs-on` in a workflow file. On AWS BYOC the Linux and Windows sets are the ones that take an instance profile. Attaching a profile changes no line on your WarpBuild invoice. BYOC runners are billed at $0.002 per runner minute for Linux and $0.002 per runner minute for Windows, with EC2 and EBS charged to your own AWS account at your rates, checked on 2026-08-13 against the [WarpBuild pricing page](/pricing). For the audit question that follows this one in most reviews: SOC 2 Type 2, with [trust.warpbuild.com](https://trust.warpbuild.com/) as the linked evidence. Where to go next depends on which role you are shaping. The permissions on the WarpBuild connection role itself are covered in [IAM permissions for WarpBuild BYOC on AWS](/byoc/aws/iam-permissions). The vendor-neutral definition of the object sits in [IAM instance profile, defined](/glossary/iam-instance-profile). The short version of this page is [Can I attach an IAM role to a BYOC runner?](/answers/can-i-attach-an-iam-role-to-a-byoc-runner). The full AWS setup, from cloud connection to first label, is on [BYOC runners on AWS for GitHub Actions](/byoc/aws). ## [FAQ](#faq)Copy Anchor Link **Why does the WarpBuild connection role need `iam:PassRole`?** AWS gates handing an IAM role to a service. The WarpBuild connection role calls `RunInstances` in your account, so to launch an instance carrying your runner role it needs `iam:PassRole` on that role ARN, scoped with a condition on `iam:PassedToService` equal to `ec2.amazonaws.com`. Without the grant the launch fails and jobs stay queued. **Can two runner sets carry different instance profiles?** Yes. The Instance Profile ARN is set on the Custom Runner configuration, so each runner set has its own. A deploy runner set can hold a role that a runner set running pull request tests never sees, and the split shows up in the workflow as two different `runs-on` labels. **Should I use an instance profile or an OIDC exchange in the workflow?** Use an instance profile for permissions every job on a runner set needs, such as reading a cache bucket or pulling a base image. Use an OIDC exchange when the permission belongs to one workflow rather than one runner set, when the same workflow also runs on GitHub-hosted runners, or when you want trust conditions on branch or environment. The two compose in the same job. --- # Networking Requirements for BYOC on AWS | WarpBuild URL: https://www.warpbuild.com/byoc/aws/networking Description: BYOC runners on AWS need a VPC with public and private subnets, a security group that blocks all inbound traffic, and outbound routes to four destinations. # Networking Requirements for BYOC on AWS BYOC runners on AWS need a VPC with public and private subnets, a security group that blocks all inbound traffic, and outbound routes to four destinations. A WarpBuild BYOC stack on AWS runs GitHub Actions jobs on EC2 instances inside your own VPC, so the network has to be right before the first runner launches: at least one public and one private subnet with internet connectivity, a security group attached to every runner instance that blocks all inbound traffic, and route tables that send internet-bound traffic through the Internet Gateway. Private subnets route outbound through NAT gateways, and every runner needs an outbound path to the WarpBuild control plane, GitHub, your package registries, and WarpBuild S3 storage for telemetry. This page lists the prerequisites as a checklist, the outbound destinations with the failure each one produces when it is blocked, and the gateway charges that private placement adds to your own AWS bill. ## [Overview](#overview)Copy Anchor Link Every row below comes from the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config), checked on 2026-08-13\. Work through it before you create the stack, because three stack properties are fixed at creation time: the stack name, the S3 bucket, and the region. | Prerequisite | Documented shape | Who provides it | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | CloudFormation permissions | Ability to apply the connection stack that provisions the IAM role WarpBuild assumes | You, in AWS | | VPC | One VPC in the stack region | Create mode: the template. Import mode: you | | Public subnets | At least one, with internet connectivity. Three in different availability zones recommended | Create mode: the template. Import mode: you | | Private subnets | At least one, with internet connectivity, routed through NAT gateways | Create mode: the template. Import mode: you | | Subnet sizing | At least 250 usable addresses per subnet, sized to peak concurrent runners | You | | Security group | All inbound blocked, outbound allowed to the destinations listed below. Attached to every instance the stack launches | Create mode: the template. Import mode: you select an existing group | | Route tables | Internet-bound traffic through the Internet Gateway, plus outbound routes for the NAT gateways serving private subnets | You | | S3 endpoint | An endpoint for the VPC so runners reach the stack bucket without data transfer charges | You, alongside the bucket | | ECR endpoints | Configured by WarpBuild for runners in public and private subnets, template version v1.4 or later | WarpBuild | | Quotas | vCPU, EBS volume, and address headroom in the stack region | You | Two stack modes consume that list differently. Easy create builds the VPC, the subnets, and the security group to the documented layout. Import mode takes an existing VPC, subnets, and security group as inputs, and import mode is supported on AWS only. BYOC runs on AWS, GCP, and Azure, so treat the numbers here as the AWS shape and price the other two clouds from their own published rates. ## [Architecture](#architecture)Copy Anchor Link The split is short. WarpBuild operates a control plane that watches the GitHub Actions job queue and calls the AWS API with the IAM role your connection stack created. Your account holds the VPC, the subnets, the security group, the S3 bucket, and the EC2 instances themselves. Nothing in that path opens a door into your network: the runner agent holds an outbound long poll, so the instances accept connections from nowhere, including from inside the VPC. Four outbound destinations carry the job. Each one fails in a different place, which is what makes the failure signature useful when a route table or a security group rule is wrong. | Destination | Purpose | Failure signature when blocked | | ------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------- | | api.warpbuild.com | Runner registration and lifecycle | The instance boots and the job stays queued, because the runner never registers against your organization | | github.com and api.github.com | Git operations and the Actions API | The job starts, then checkout or an Actions API call fails | | Package registries | Dependency downloads in your build steps | Install steps fail on network timeouts partway into the job | | WarpBuild S3 storage over HTTPS | Runner telemetry and system logs | The job runs to completion and runner logs stop arriving, so the next failure is harder to read | Two traffic classes stay inside the AWS network and never reach those routes. Cache reads and writes go to the stack bucket through the S3 endpoint, which the configuration guide documents as connecting without incurring data transfer charges. Registry authentication and image pulls go through the ECR endpoints WarpBuild configures for runners in both public and private subnets. Stacks created on CloudFormation template versions before v1.4 can hit ECR authentication timeouts from public subnet runners, and the fix is a stack upgrade to v1.4 in the dashboard. Guest coverage follows the same boundary. On AWS BYOC the supported guests are Linux and Windows Server 2022 x86-64\. For WarpBuild-hosted capacity rather than your own account, US and EU are the regions WarpBuild names publicly. ## [Configuration](#configuration)Copy Anchor Link Subnet placement is a runner setting rather than a workflow setting. Enabling static IPs on a custom runner creates its instances in private subnets behind NAT gateways, and the external addresses those runners present are the addresses of the gateways, which is what an allowlist on a third-party service needs. Leave static IPs off and instances are created in public subnets with no gateway in the path. Size the quotas per stack against a target of `$CON` concurrent jobs, and raise them in the region the stack lives in, since quota increases apply per region. | Resource | Quota to request | | ----------- | ------------------------------------------------------- | | EC2 vCPU | $CON x vCPU per job | | EBS volumes | $CON x disk size per job | | Elastic IPs | 3 plus $CON, when static IPs are enabled | | VPCs | 1 | | S3 buckets | 1, shared by artifact cache, layer cache, and telemetry | The workflow file carries none of this. A BYOC runner is addressed by its Runner ID, which is the runner name with the `warp-custom-` prefix, and a job moves between a public subnet runner set and a private subnet runner set by pointing `runs-on` at a different runner ID. .github/workflows/ci.yml Copy Text ``` name: ci on: [push] jobs: unit-tests: runs-on: warp-custom-payments-use1-4x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test deploy: needs: unit-tests runs-on: warp-custom-payments-use1-4x;network.name=production-net steps: - uses: actions/checkout@v4 - run: ./scripts/deploy.sh ``` The `deploy` job adds the [networking addon](/docs/ci/features/networking), which joins the runner to your private network for the length of the job so a step can reach an internal database or an internal API. The addon gives the job reach into your services and leaves the outbound path alone: registration with WarpBuild and the GitHub API calls still travel the route in the table above. ## [Operations](#operations)Copy Anchor Link Private placement adds a meter that public placement does not have. Traffic leaving a private subnet through a managed gateway carries a per-gigabyte processing charge in both directions on top of the hourly charge for the gateway itself. In US East (N. Virginia), AWS charges $0.045 per gateway-hour and $0.045 per GB processed ([Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13). Those charges land on your own AWS bill and never appear on the WarpBuild invoice. Per job, on an 8 minute Linux job that pulls 2 GB from destinations outside AWS: | Line item | Rate | Cost | | ----------------------------------- | ----------------------- | ----------------------------------------------------- | | Gateway data processing, 2 GB | $0.045 per GB | $0.090 per job | | WarpBuild BYOC Linux fee, 8 minutes | $0.002 per minute | $0.016 per job | | Gateway hourly charge | $0.045 per gateway-hour | $32.85 per gateway per month, whether jobs run or not | The network line is larger than the platform fee on that profile, which is the reason to split the fleet by requirement rather than routing everything through private subnets. Jobs that need a stable outbound address get the private runner set. Everything else runs in public subnets, where inbound data transfer from the internet is $0.00 per GB ([Amazon EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/), checked on 2026-08-13). Keeping the S3 endpoint and the ECR endpoints in place holds cache traffic and image pulls off the meter entirely, and smaller images move the dominant line directly. The rule set for tightening egress once the layout is settled is on [restricting outbound traffic from runners](/byoc/outbound-traffic-controls). Three more operational habits are worth setting on day one. Put a lifecycle policy on the S3 bucket, since the artifact cache prefix and the telemetry prefix both grow without bound and 7 days is the documented recommendation. Deny intra-subnet traffic with Network ACLs, because runner instances never need to reach each other. Give every custom runner at least two instance types that are close in price and performance, so a capacity gap in one type does not park jobs in the queue. Billing splits along the account boundary. WarpBuild charges $0.002 per runner minute for BYOC Linux and Windows runners and AWS bills your account for EC2, EBS, gateway hours, and data processing at your own rates. If the network setup is more than the workload justifies, a hosted `warp-ubuntu-latest-x64-4x` runs at $0.008 per minute with no VPC work at all. One enterprise note for deploy-heavy pipelines: egress costs are handled on the enterprise tier; see [zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress). Next steps depend on the open question. For the terminology behind the layout, start with [VPC explained](/glossary/vpc) and [NAT gateways explained](/glossary/nat-gateway). For the placement decision itself, [can BYOC runners run in a private subnet](/answers/can-i-run-byoc-runners-in-a-private-subnet) walks the setting and its cost. For the stack, the IAM surface, and the runner configuration around this network, see the [BYOC on AWS overview](/byoc/aws). ## [FAQ](#faq)Copy Anchor Link **What VPC layout does a WarpBuild AWS stack require?** At least one public and one private subnet, both with internet connectivity, with NAT gateways serving the private subnets. The documented recommendation is three public and three private subnets in different availability zones, each with at least 250 usable addresses, so instance type availability stays wide and concurrency is not capped by address exhaustion. **Which outbound destinations must the security group allow?** `api.warpbuild.com` for runner registration and lifecycle, `github.com` and `api.github.com` for Git operations and the Actions API, your package registries for dependency installs, and outbound HTTPS to WarpBuild S3 storage for runner telemetry. Inbound can be blocked entirely, because the runner agent polls outbound. **Do cache reads and image pulls cross the NAT gateways?** No. The stack uses an S3 endpoint for the cache and telemetry bucket, and WarpBuild configures ECR endpoints for runners in both public and private subnets, so that traffic stays inside the AWS network. Stacks on CloudFormation template versions before v1.4 can hit ECR authentication timeouts from public subnets. --- # S3 and ECR Prerequisites for BYOC on AWS | WarpBuild URL: https://www.warpbuild.com/byoc/aws/s3-and-ecr-prerequisites Description: What the S3 bucket and ECR endpoints in an AWS BYOC stack are used for, the permission set behind each one, and why Region placement decides your transfer bill. # S3 and ECR Prerequisites for BYOC on AWS What the S3 bucket and ECR endpoints in an AWS BYOC stack are used for, the permission set behind each one, and why Region placement decides your transfer bill. An AWS BYOC stack needs one S3 bucket in the same Region as the stack, which holds artifact cache objects and runner system logs, and it needs a network path to ECR, which the stack provisions as VPC endpoints rather than as a repository you name up front. The authority to actually pull an image comes from a separate place: the instance profile you attach to a runner set, which the WarpBuild connection role never touches. This page covers what each object stores, the permission set behind it, the failure you see when a piece is missing, and why the Region you pick at stack creation decides the transfer line on your AWS bill. The full prerequisite checklist lives in the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config), and the account-level view of the setup is on [BYOC runners on AWS for GitHub Actions](/byoc/aws). ## [Overview](#overview)Copy Anchor Link BYOC runs on AWS, GCP, and Azure. On AWS, storage and registry access break into four objects, and only two of them are things you decide. | Prerequisite | What it holds or carries | Who creates it | Changeable after stack creation | | ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | Stack S3 bucket | Artifact cache objects and runner system logs | The CloudFormation template in create mode, or you supply an existing bucket in import mode | No. The stack name, the bucket, and the Region are fixed at creation | | S3 gateway VPC endpoint | Runner traffic to that bucket, kept on the AWS network | The stack | Yes, endpoint management is part of the stack | | ECR API and ECR Docker interface endpoints | Image pulls from runners in public and private subnets | The stack, automatically | Yes | | Runner instance profile | The authority a job step holds to log in to ECR and read or write your own buckets | You, per runner set | Yes | The bucket is the one irreversible choice on the list. Because the bucket, the stack name, and the Region are locked together at creation, teams that want runners in two AWS Regions create two stacks with two buckets rather than repointing one. Inside the bucket, WarpBuild writes two prefixes with documented shapes, which is what makes lifecycle rules and cost queries straightforward to write: * Cache: `//artifact_cache/////` * Telemetry: `/runner/logs/all/.` ECR has no equivalent line item, because the stack does not store images. It provisions the path, and your registry stays where it already is. ## [Architecture](#architecture)Copy Anchor Link Both prerequisites exist to keep bytes off metered paths. The stack provisions an S3 gateway endpoint for the bucket and ECR API and ECR Docker interface endpoints for image pulls, so cache traffic and registry traffic stay on the AWS network instead of leaving through a NAT gateway or the public internet. Those endpoints keep working when the runner security group has outbound-only rules, because the runner always initiates the connection. | Traffic | Endpoint type | Used by | | ------------------------------------------ | ----------------------------- | -------------------------------------------------- | | Cache reads and writes to the stack bucket | S3 gateway endpoint | The WarpBuild cache action during a job | | Runner system logs | S3, outbound HTTPS | The runner telemetry writer | | docker login and image manifest calls | ECR API interface endpoint | Job steps on runners in public and private subnets | | Layer downloads and uploads | ECR Docker interface endpoint | The same job steps | One version detail matters here. Stacks created with CloudFormation template versions before v1.4 can hit ECR authentication failures, usually surfacing as an `ecr login` that times out, for runners placed in public subnets. The documented fix is a stack upgrade to template v1.4 through the WarpBuild dashboard. If a public subnet runner cannot log in while a private subnet runner in the same stack can, check the template version before you rewrite any IAM policy. AWS documents the endpoint behavior itself in the [ECR VPC endpoints guide](https://docs.aws.amazon.com/AmazonECR/latest/userguide/vpc-endpoints.html). ## [Configuration](#configuration)Copy Anchor Link Two identities carry the permissions, and mixing them up is the most common setup mistake on this surface. The **connection role**, named `warpbuild-` and created by the CloudFormation connection stack, holds the `StoragePermissions` context. That context covers the stack bucket only: `s3:CreateBucket`, `s3:ListBucket`, `s3:GetBucketLocation`, `s3:GetObject`, `s3:PutObject`, `s3:DeleteObject`, the multipart actions, the bucket and object ACL actions, the bucket policy actions, and `s3:PutLifecycleConfiguration` with its matching read. It also carries EBS snapshot actions. It holds no ECR actions at all. The `NetworkPermissions` context is what lets the stack create and delete the VPC endpoints described above, through `ec2:CreateVpcEndpoint` and `ec2:DeleteVpcEndpoints`. The full policy document is expanded in the [configuration guide](/docs/ci/byoc/aws/config), and the context-by-context walkthrough is on [IAM permissions for BYOC on AWS](/byoc/aws/iam-permissions). The **runner instance profile** is what your job steps run as. It is set per runner set, under Custom Runner configuration and the Instance Profile ARN field, and WarpBuild injects nothing into it. A pull-only profile for a deploy pipeline is short: Copy Text ``` { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["ecr:GetAuthorizationToken"], "Resource": "*" }, { "Effect": "Allow", "Action": [ "ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage" ], "Resource": "arn:aws:ecr:::repository/" } ] } ``` `ecr:GetAuthorizationToken` has to sit on `Resource: "*"` because the token is issued at account level rather than per repository. Everything else is scoped to the repository ARN. Push pipelines add `ecr:PutImage` and the three layer upload actions on the same resource, and jobs that write artifacts to a bucket of your own add `s3:PutObject`, `s3:GetObject`, and `s3:ListBucket` on that bucket ARN. Both examples are written out in the [security hardening guide](/docs/ci/byoc/aws/security). With the profile attached, a workflow needs no credential handling: Copy Text ``` name: deploy on: push: branches: [main] jobs: ship: runs-on: warp-custom-deploy-linux permissions: contents: read steps: - uses: actions/checkout@v4 - uses: aws-actions/amazon-ecr-login@v2 - run: docker pull .dkr.ecr..amazonaws.com/:latest - run: ./scripts/deploy.sh ``` Each prerequisite fails in its own way, which is what makes this list worth reading before a security review rather than during an incident. | Missing piece | What breaks | What keeps working | | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | StoragePermissions on the connection role | Cache reads and writes fail, runner system logs stop being pushed, lifecycle configuration cannot be applied | Jobs still run, at cold-cache speed | | ec2:CreateVpcEndpoint in NetworkPermissions | The stack cannot create or replace the S3 and ECR endpoints, so that traffic falls back to whichever metered path the subnet has | An already-created stack, until an endpoint changes | | ecr:GetAuthorizationToken on the instance profile | docker login to ECR fails outright, before any repository policy is consulted | Every step that does not touch the registry | | Repository-scoped ECR read actions | Login succeeds and the pull fails on the first layer | Login, and any repository the profile does list | | Stack template older than v1.4 | ECR authentication can time out for runners in public subnets | Runners in private subnets on the same stack | | A bucket lifecycle policy | Nothing fails; cache objects and logs accumulate and the storage line grows every month | All job behavior | ## [Operations](#operations)Copy Anchor Link Region placement is the decision that shows up on the bill rather than in a log. The bucket is required to sit in the stack's Region, and the stack is where the runners launch, so the cache side is handled by following the prerequisite. The registry side is yours to place, and four AWS prices decide what that placement costs. All four were checked on 2026-08-13. | Path | AWS price | When you pay it | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | | ECR to other services in the same Region | $0.00 per GB ([Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/)) | Registry and runners in one Region | | Across Availability Zones in the same Region | $0.01 per GB in each direction ([Amazon EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/)) | Subnets spread for instance availability while the resources they read sit in one zone | | Managed NAT data processing | $0.045 per GB in US East (N. Virginia), plus $0.045 per hour ([Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/)) | Runners in private subnets, on everything leaving through that path | | ECR across Regions | Internet data transfer rates on both sides; the AWS worked example uses $0.09 per GB ([Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/)) | Registry in one Region, runners in another | Same-Region placement plus the endpoints the stack provisions is what turns the first row into the row you are on. A registry one Region away from the runners moves the same gigabytes onto the last row, and the workflow file looks identical either way. The per-pull arithmetic, including the storage charge of $0.10 per GB-month per retained image version, is worked through on [what pulling images from ECR costs your pipeline](/guides/ecr-pull-costs-github-actions). Two habits keep this surface quiet after setup. Set an S3 lifecycle policy on the stack bucket, with seven days of retention as the documented recommendation, because cache objects and telemetry logs are both written on every job and neither expires on its own. And scope your VPC endpoint policies to the repositories and buckets the runners actually need, which narrows the endpoints without touching the security group. Costs split cleanly along the same line as the permissions. WarpBuild charges $0.002 per runner minute for BYOC Linux runners and $0.002 per runner minute for BYOC Windows runners, while your own AWS account is billed directly for EC2, EBS, S3 storage, and data transfer at your account rates, including discounts you already hold ([WarpBuild pricing](/pricing), checked 2026-08-13). The Linux and Windows sets are the ones an AWS stack launches. One enterprise note for pull-heavy pipelines: egress costs are handled on the enterprise tier; see [zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress). For the full inventory of objects a stack creates in your account, including the buckets, the endpoints, and the tags they carry, see [what WarpBuild creates in your cloud account](/answers/what-does-warpbuild-create-in-my-cloud-account). ## [FAQ](#faq)Copy Anchor Link **Does the S3 bucket have to be in the same Region as the runners?** Yes. The bucket must sit in the same Region as the stack, and the stack name, bucket, and Region are all fixed at creation time. Runners launch inside that stack, so a bucket in another Region would put every cache read and write on a cross-Region path. **Do I have to create an ECR repository before the stack works?** No. WarpBuild configures the ECR API and ECR Docker VPC endpoints automatically for runners in public and private subnets. What you supply is the authority: an instance profile attached to the runner set that allows `ecr:GetAuthorizationToken` plus the read actions on the repositories your jobs pull. **Why does `docker login` to ECR time out on public subnet runners?** Stacks created with CloudFormation template versions before v1.4 can hit ECR authentication failures for runners in public subnets. Upgrading the stack to template v1.4 through the WarpBuild dashboard is the documented fix. --- # A Security Checklist for BYOC on AWS | WarpBuild URL: https://www.warpbuild.com/byoc/aws/security-checklist Description: Six documented controls harden a WarpBuild BYOC stack on AWS: dedicated account or VPC, no inbound, instance isolation, egress limits, IAM scope, IMDSv2. # A Security Checklist for BYOC on AWS Six documented controls harden a WarpBuild BYOC stack on AWS: dedicated account or VPC, no inbound, instance isolation, egress limits, IAM scope, IMDSv2. A WarpBuild BYOC stack on AWS hardens to six documented controls, in this order: a dedicated AWS account or VPC, blocked inbound connections, disallowed inter-instance communication, restricted outbound traffic, least-privilege instance profiles, and required IMDSv2\. Every one of them is set in your own AWS account or in the runner configuration, which means the whole checklist is yours to apply without opening a ticket. This page takes the six items from the [AWS BYOC security hardening guide](/docs/ci/byoc/aws/security) in the documented order, states what each one defends against, and shows where the setting lives. Start from the [BYOC on AWS overview](/byoc/aws) if the stack does not exist yet. ## [Overview](#overview)Copy Anchor Link | # | Control | What it defends against | Where it is set | | - | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | 1 | Dedicated VPC or AWS account | Blast radius. A compromised job lands in a network with no production neighbors, and the audit boundary is the account boundary | AWS Organizations, plus the region and VPC chosen at stack creation | | 2 | Block all inbound connections | Any inbound path to a running job, including from other workloads that share the VPC | Runner security group, provisioned in create mode or selected in import mode | | 3 | Disallow inter-instance communication | Lateral movement from one runner instance to another while both jobs are live | Subnet network ACLs, or a security group that admits only endpoint traffic | | 4 | Restrict outbound traffic | Exfiltration destinations and untrusted download sources reachable from a job | Security group egress rules and network ACLs | | 5 | Least-privilege instance profile | The AWS resources a job can read or write using instance credentials | Instance Profile ARN, set per custom runner in the WarpBuild dashboard | | 6 | Require IMDSv2 | SSRF attacks that try to read instance credentials out of the metadata service | Runner Specs, Require IMDSv2 toggle on the runner | Two properties hold before you change any of the six. Each GitHub Actions job runs on a distinct, freshly provisioned EC2 instance that is terminated when the job completes, so no state and no credentials survive into the next job. And the permissions available to runner instances are exactly what you provide through security groups and IAM roles or instance profiles, because WarpBuild does not inject additional permissions into runner workloads. For the vendor half of a review, the attestation is SOC 2 Type 2, with [trust.warpbuild.com](https://trust.warpbuild.com/) as the linked evidence path. Reviewers request the report and the supporting security documentation there. The platform-level answers on isolation, storage lifetime, and secret handling live in [the security documentation](/docs/ci/security) and are collected for review packets on [the security review checklist](/enterprise/ci-security-review). ## [Architecture](#architecture)Copy Anchor Link The WarpBuild stack is a CloudFormation stack that provisions networking and an S3 bucket. It does not provision IAM permissions for your workloads, which is why item 5 exists as a separate step. | Resource the stack creates | Default configuration | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Security group | All egress allowed to 0.0.0.0/0; ingress allowed from VPC subnet CIDRs only | | S3 and ECR endpoints | S3 as a gateway-type endpoint, ECR API and ECR Docker as interface endpoints, so image pulls and bucket traffic stay on the AWS network | | S3 bucket | Holds WarpBuild cache data for the stack | | Subnets | Public subnets, with optional private subnets behind the managed NAT service for static egress IPs | | Network ACLs | Default VPC ACLs, which allow all traffic until you customize them | Rows come from the [AWS BYOC security hardening guide](/docs/ci/byoc/aws/security), checked on 2026-08-13\. The security group is a stack-level object. In create mode the template provisions it. In import mode you select an existing security group when the stack is created, which is the mode to use when your network team owns group definitions. BYOC runs on AWS, GCP, and Azure, and this checklist is the AWS one; the GCP and Azure stacks expose their own equivalents. The runner sets launched inside an AWS account are the Linux and Windows ones. ## [Configuration](#configuration)Copy Anchor Link **1\. Dedicated VPC or AWS account.** Deploy the stack in a separate VPC in the same account, or in an account dedicated to GitHub Actions workloads. Cross-account access is managed with [AWS Organizations](https://docs.aws.amazon.com/organizations/latest/userguide/orgs%5Fintroduction.html). **2\. Block all inbound connections.** Runner agents open outbound connections and hold them, so no inbound rule is required for a job to run. Remove every inbound rule from the runner security group and avoid attaching groups that allow `0.0.0.0/0` or the VPC CIDR inbound. In import mode, create the group with an empty inbound list. Copy Text ``` { "InboundRules": [], "OutboundRules": [ { "IpProtocol": "-1", "Destination": "0.0.0.0/0", "Description": "Allow all outbound" } ] } ``` The S3 and ECR endpoints keep working under outbound-only rules, because the runner initiates those connections. **3\. Disallow inter-instance communication.** Runner instances have no reason to talk to each other. Use network ACLs on the subnets to deny intra-subnet traffic between instances while leaving the paths to the endpoints and the gateways open, or attach a tighter security group that allows egress to `0.0.0.0/0` and admits ingress only from the endpoint network interfaces rather than the whole subnet CIDR. The [AWS network ACL documentation](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html) covers the rule syntax. **4\. Restrict outbound traffic.** Runners need outbound access, and the required set is short. | Destination | Why the runner needs it | | ----------------------------------- | --------------------------------------------------------------------------- | | api.warpbuild.com | Control plane calls for runner registration and lifecycle | | api.github.com and github.com | GitHub API calls and git operations | | Outbound HTTPS for telemetry | Runner system logs shipped to WarpBuild storage | | Your package registries and mirrors | Dependency downloads for the build itself | | Stack S3 bucket | Cache reads and writes, routed through the S3 endpoint the stack provisions | Allowlist those, add the external services your builds call, and drop the rest. The per-destination detail and the failure modes when a destination is missing are on [outbound traffic controls for BYOC runners](/byoc/outbound-traffic-controls). **5\. Least-privilege instance profiles.** An instance profile is set per runner in the WarpBuild dashboard under Custom Runner configuration, in the Instance Profile ARN field, so a runner that deploys to production can hold a role that a pull request runner never sees. Scope the policy to the exact resources the job touches. Copy Text ``` { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["ecr:GetAuthorizationToken"], "Resource": "*" }, { "Effect": "Allow", "Action": ["ecr:BatchCheckLayerAvailability", "ecr:PutImage", "ecr:UploadLayerPart"], "Resource": "arn:aws:ecr:::repository/" } ] } ``` The role also needs `iam:PassRole` granted to the WarpBuild connection role, conditioned on `iam:PassedToService` being `ec2.amazonaws.com`. The full command sequence is on [the IAM permissions page](/byoc/aws/iam-permissions) and in the [instance profile guide](/docs/ci/byoc/aws/instance-profile). **6\. Require IMDSv2.** Both IMDS v1 and v2 are supported by default. Set Require IMDSv2 on the Update Runner page, in the Runner Specs section, to close the v1 path that SSRF attacks target. Per-runner behavior and the rollout order are on [IMDSv2 on BYOC runners](/byoc/imdsv2). ## [Operations](#operations)Copy Anchor Link A checklist passes once and then drifts, so tie re-verification to three events: a stack template upgrade, an import-mode security group edit by the network team, and the creation of any new custom runner. New runners start with no instance profile attached and the default metadata setting, so items 5 and 6 are per-runner work rather than per-stack work. Four operational items sit alongside the six controls. Configure S3 bucket lifecycle policies so cache objects expire on a schedule you chose. Scope the endpoint policies to the resources runners actually reach. Keep the `managed-by: warpbuild` tag in place, since it is the hook for tag-based access control policies and for cost attribution. And watch the resource naming patterns when you write cleanup or audit queries: EC2 instances and EBS volumes are named `warp-*`, launch templates `tmpl-warp-*`, and stack buckets `warpbuild-*`. The cost side of the decision is small and worth stating plainly. BYOC Linux and Windows runners are $0.002 per runner minute in WarpBuild fees, with EC2, EBS, and data transfer billed by your own AWS account at your own rates ([pricing page](/pricing), checked on 2026-08-13). ## [FAQ](#faq)Copy Anchor Link **What does the default BYOC stack allow before I harden it?** The CloudFormation stack creates a security group that allows all egress to `0.0.0.0/0` and ingress from VPC subnet CIDRs only, default VPC network ACLs that allow all traffic, S3 and ECR endpoints for the VPC, an S3 bucket for cache data, and public subnets with optional private subnets. The six checklist items tighten that starting posture, and the defaults are listed in the [AWS BYOC security hardening guide](/docs/ci/byoc/aws/security). **Does WarpBuild add IAM permissions to my runner instances?** No. The permissions available to runner instances are exactly what you provide through security groups and IAM roles or instance profiles, and WarpBuild does not inject additional permissions into runner workloads. All IAM permissions for runner workloads are managed by you through instance profiles set per custom runner. **Which outbound destinations have to stay open?** `api.warpbuild.com` for runner registration and lifecycle, `api.github.com` and `github.com` for the GitHub API and git operations, outbound HTTPS for runner telemetry, and any package registries your builds depend on. Stack bucket traffic is routed through the S3 endpoint the stack provisions, so it does not need a separate internet path. --- # Terraform for WarpBuild BYOC on AWS | WarpBuild URL: https://www.warpbuild.com/byoc/aws/terraform Description: The WarpBuild Terraform provider declares BYOC runner images and custom runner sets for GitHub Actions inside your own AWS account. Beta, AWS BYOC only. # Terraform for WarpBuild BYOC on AWS The WarpBuild Terraform provider declares BYOC runner images and custom runner sets for GitHub Actions inside your own AWS account. Beta, AWS BYOC only. Last verified: 2026-08-13 You can declare the GitHub Actions runners that run inside your own AWS account as Terraform code with the WarpBuild provider, published at [registry.terraform.io/providers/WarpBuilds/warpbuild/latest](https://registry.terraform.io/providers/WarpBuilds/warpbuild/latest). Terraform support exists for BYOC on AWS: the provider is in beta, it supports AWS BYOC only, and it manages BYOC (AWS) runner images and custom runner sets, the same surface as the automation API. This page covers the three objects a BYOC AWS setup is built from, which of them Terraform owns today, the two credentials involved, a working configuration with the labels it produces, and who pays for what once the runners are live. ## [Architecture](#architecture)Copy Anchor Link A BYOC AWS setup on WarpBuild stacks up in three layers, and they are created in that order. **Cloud connection.** A CloudFormation stack in your AWS account provisions an IAM role that WarpBuild assumes to launch and terminate runner instances. The role follows the naming pattern `warpbuild-`. **WarpBuild stack.** A stack is the group of infrastructure components in one region of your account that runners need: the VPC, the subnets, the security group, and an S3 bucket used for artifact cache, container layer cache, and runner telemetry. The stack name, the S3 bucket, and the region are fixed at creation time and cannot be changed afterward, so pick the region before you script anything. **Custom runner set.** A runner set carries the instance types in priority order, the capacity type, the disk configuration, the runner image, and the pool size. Each set registers a GitHub Actions label formed as `warp-custom-` plus the runner name. Terraform enters at the second and third layers. The provider reads an existing stack through a data source and writes the runner image and the runner set as managed resources. | Object | Created where | Terraform coverage today | | ------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------- | | Cloud connection IAM role | AWS console, CloudFormation stack launched from the WarpBuild dashboard | Not managed | | WarpBuild stack (region, VPC, subnets, S3 bucket) | WarpBuild dashboard | Read only, through the warpbuild\_stack data source | | BYOC runner image (AMI reference) | Automation API or Terraform | Managed, warpbuild\_runner\_image | | Custom runner set (instance types, disk, pool) | Automation API or Terraform | Managed, warpbuild\_runner | | Runner instances at job time | WarpBuild control plane, in your AWS account | Not managed, and correctly so | The last row is the important one. Terraform declares the shape of a runner set. It does not hold the individual EC2 instances in state, because those are launched just in time when a job is queued and terminated when the job ends. Putting per job instances in Terraform state would fight the scheduler. At job time the flow is short. GitHub queues a job whose `runs-on` label matches a runner set. WarpBuild launches an instance from a launch template in your stack, registers a just-in-time runner against your GitHub organization, runs the job, then terminates the instance. Everything it creates follows a fixed naming pattern, which makes cost attribution and cleanup queries easy to write. | Resource type | Naming pattern | | ---------------- | -------------- | | S3 buckets | warpbuild-\* | | EC2 instances | warp-\* | | EBS volumes | warp-\* | | Launch templates | tmpl-warp-\* | Every provisioned resource also carries the `managed-by: warpbuild` tag, plus per job tags including `warpbuild-runner-id`, `warpbuild-runner-labels`, `warpbuild-stack-id`, and `warpbuild-stack-name`. Custom tags declared on the stack are added to everything the stack creates, which is the hook most teams use for chargeback. Start with the provider block. Beta releases are prereleases, so a range like `~> 0.1` never matches one and the version has to be pinned exactly. Copy Text ``` terraform { required_providers { warpbuild = { source = "WarpBuilds/warpbuild" # Prerelease version: pin it exactly version = "0.2.0-beta" } } } # Reads WARPBUILD_API_KEY from the environment provider "warpbuild" {} ``` BYOC runs on AWS, GCP, and Azure. Terraform covers one of the three today. | Cloud | BYOC setup guide | Terraform provider support | | ----- | ------------------------------------------- | ------------------------------------------- | | AWS | [BYOC on AWS](/docs/ci/byoc/aws/config) | Yes, in beta | | GCP | [BYOC on GCP](/docs/ci/byoc/gcp/config) | No, use the dashboard or the automation API | | Azure | [BYOC on Azure](/docs/ci/byoc/azure/config) | No, use the dashboard or the automation API | ## [Permissions and Access](#permissions-and-access)Copy Anchor Link Two separate credentials do two separate jobs, and mixing them up is the most common setup mistake. **The WarpBuild API key** authorizes Terraform to talk to the WarpBuild API. Create it at [app.warpbuild.com/settings/api-keys](https://app.warpbuild.com/settings/api-keys) with the `ci` scope and export it as `WARPBUILD_API_KEY`. The empty `provider "warpbuild" {}` block reads that variable, so the key stays out of the repository and out of the state file. The [automation API guide](/docs/ci/api-keys/automation) grants the same key the `cache` scope as well when the key also drives cache operations. **The AWS IAM role** authorizes WarpBuild to act inside your account. It is created once by the cloud connection CloudFormation stack, and Terraform never handles your AWS keys for runner operations. The role's permissions are grouped into policy contexts, each with a stated purpose. | Policy context | What it is used for | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | FineGrainedEC2Permissions | Launch just-in-time runners, read instance types and offerings per region and availability zone, manage launch templates, tag and terminate instances | | SpotServiceLinkedRolePermissions | Create and manage the EC2 Spot service linked role used for spot capacity | | NetworkPermissions | Read regions, VPCs, subnets, route tables, and security groups for stack creation in import mode | | StoragePermissions | Runner cache reads and writes plus runner system logs in the stack's S3 bucket | | CloudFormationPermissions | Raise change set requests when a connection or a stack needs an upgrade | If your security policy needs a narrower role, there are two supported paths documented in the [AWS configuration guide](/docs/ci/byoc/aws/config): edit the CloudFormation template on the AWS redirect page before you apply it, or edit the role after it is created. The `managed-by: warpbuild` tag exists so you can write AWS IAM tag-based access control policies around WarpBuild-managed resources. Runner instances get their own identity separately. An instance profile ARN is set per runner set, so a runner that deploys to production can hold a role that a runner running pull request tests never sees. Declare it on the runner resource and the scoping lives in code review with everything else. Network posture for the stack is straightforward: the security group attached to runner instances blocks all inbound traffic, and outbound traffic is open so runners can reach GitHub, the WarpBuild control plane, and package registries. In create mode the CloudFormation template provisions that security group. In import mode you select an existing one when creating the stack. For the compliance question that follows this one in most reviews: SOC 2 Type 2, with [trust.warpbuild.com](https://trust.warpbuild.com/) as the linked evidence. Running Terraform itself from GitHub Actions keeps the key in one place. This workflow applies runner changes on merge and uses a WarpBuild hosted Linux runner for the Terraform job. Copy Text ``` name: warpbuild-runners on: push: branches: [main] paths: - "infra/warpbuild/**" pull_request: paths: - "infra/warpbuild/**" jobs: terraform: runs-on: warp-ubuntu-latest-x64-4x permissions: contents: read defaults: run: working-directory: infra/warpbuild env: WARPBUILD_API_KEY: ${{ secrets.WARPBUILD_API_KEY }} steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 with: terraform_wrapper: false - run: terraform init - run: terraform plan -out=tfplan - if: github.ref == 'refs/heads/main' run: terraform apply -auto-approve tfplan ``` ## [Configuration](#configuration)Copy Anchor Link A complete runner set is three blocks: a data source that looks up the stack, an image resource that points at an AMI, and a runner resource that describes the machines. Copy Text ``` data "warpbuild_stack" "primary" { alias = "ci-primary" } resource "warpbuild_runner_image" "linux_x64" { alias = "ci-linux-x64" stack_id = data.warpbuild_stack.primary.id ami_id = "ami-0123456789abcdef0" } resource "warpbuild_runner" "ci_linux_large" { name = "ci-linux-large" provider_id = data.warpbuild_stack.primary.id pool_size = 2 configuration = { image = warpbuild_runner_image.linux_x64.id byoc_sku = { arch = "x64" instance_types = ["m7a.xlarge", "m7i.xlarge"] role_arn = "arn:aws:iam::123456789012:role/WarpBuildRunnerRole" } } } ``` Four things to know about that configuration. `instance_types` is a priority list, not a single choice. The workflow asks for one label and WarpBuild picks an available instance type from the list. Keep the types close in price and performance so job behavior stays predictable, and give every set at least two types so jobs do not sit in the queue when one type is short on capacity in a region. Spot capacity makes a longer list more valuable, because spot availability moves. `role_arn` is the instance profile discussed above. Leave it empty for runner sets that need no AWS access. `pool_size` keeps warm capacity ready for the label. Raise it for the busiest labels and leave it low for long tail ones. Disk configuration matters more than most teams expect. The minimum supported configuration is 100GB, 125MBps, and 3000 IOPS. The recommended configuration is 150GB, 400MBps, and 4000 IOPS. Windows Server 2022 x86-64 runners on AWS want more: at least an 8 vCPU instance type, with the m7a series recommended, 6000 IOPS, and 500 MBps of throughput. On AWS BYOC the Linux and Windows sets are the ones you declare here. Settings that the provider schema does not expose yet stay in the WarpBuild dashboard: the IMDSv2 requirement toggle under Runner Specs, standby disks, static IP selection, and stack template upgrades. The resource schema tracks the automation API request bodies and the provider's API client is generated from the same specification, so check the resource documentation for the exact version you pinned before assuming a field exists. Once `terraform apply` finishes, the label is live. The runner ID is the runner name with the `warp-custom-` prefix, and that string goes straight into `runs-on`. Copy Text ``` name: build on: [push] jobs: unit-tests: runs-on: warp-custom-ci-linux-large steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test package: needs: unit-tests runs-on: warp-custom-ci-linux-large steps: - uses: actions/checkout@v4 - run: ./scripts/package.sh docs: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/build-docs.sh ``` The `docs` job shows the mixed pattern that most teams land on. Workloads that need your VPC, your data, or your instance profile run on the BYOC label. Everything else runs on a WarpBuild hosted label with no cloud account setup at all. Both kinds of label live in the same workflow file and both are one line to change. Regions work differently across the two. WarpBuild hosted runners run in US and EU regions. A BYOC stack runs in the AWS region you choose when you create the stack, and since that choice is permanent, teams with two regions create two stacks and two sets of Terraform resources. ## [Who Manages What](#who-manages-what)Copy Anchor Link Infrastructure as code moves a boundary rather than erasing one. This is where each object sits after the provider is in place. | Object | Terraform | WarpBuild | You, in AWS | | --------------------------------------- | ------------------------------------ | ---------------------------------------------------------------- | -------------------------------------------------------------------- | | Cloud connection IAM role | Not managed | Defines the required permissions and the CloudFormation template | Applies the template, approves any permission edits | | Stack (region, VPC, subnets, S3 bucket) | Read only | Creates and upgrades the stack template | Supplies network inputs in import mode, owns the S3 lifecycle policy | | Runner image (AMI) | Declared as warpbuild\_runner\_image | Tracks image versions and purges old ones per settings | Builds and publishes the AMI when using a custom base image | | Custom runner set | Declared as warpbuild\_runner | Enforces the configuration when launching instances | Reviews the plan before it is applied | | Runner instances per job | Not managed | Launches, registers with GitHub, terminates | Pays the EC2 and EBS charges | | Cache and telemetry objects | Not managed | Writes cache entries and runner logs to the bucket | Sets the retention policy, 7 days is the recommendation | | Service quotas | Not managed | Reports capacity failures | Raises EC2 vCPU, EBS volume, and Elastic IP quotas ahead of peak | | GitHub Actions workflow labels | Produces the label string | Registers runners under that label | Edits runs-on in the workflow files | Quotas are the one row that reliably bites teams during a first load test. Size EC2 vCPU quota as the peak number of concurrent jobs times the vCPUs per job, size EBS volume quota the same way against your disk configuration, and add headroom on Elastic IPs when static IPs are enabled, because one is attached per concurrently running job. Billing splits along the same line. WarpBuild charges a per minute fee for BYOC runners and your cloud provider bills the compute directly. | What you run | WarpBuild fee per minute | Compute billed by | | -------------------------------------------------- | ------------------------ | ----------------- | | BYOC Linux runners on AWS | $0.002 | Your AWS account | | BYOC Windows runners on AWS | $0.002 | Your AWS account | | Hosted warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) | $0.008 | Included | | Hosted warp-ubuntu-latest-arm64-4x (4 vCPU, 16 GB) | $0.006 | Included | A worked model for a team running 200,000 Linux job minutes per month on a 4 vCPU shape, using GitHub public list prices from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing), both checked on 2026-08-13: * GitHub-hosted 4-core Linux larger runner: 200,000 minutes at $0.012 per minute is $2,400 per month. * WarpBuild hosted `warp-ubuntu-latest-x64-4x`: 200,000 minutes at $0.008 per minute is $1,600 per month, a 33 percent lower list price against the GitHub 4-core larger runner. * WarpBuild BYOC on AWS: 200,000 minutes at $0.002 per minute is $400 per month in WarpBuild fees, plus the EC2 instance hours and EBS volumes on your own AWS bill at your account's rates, including any spot or committed use discounts you already hold. Use the worked examples above with your measured job duration and AWS instance rates. WarpBuild's per-size rates are on the [pricing page](/pricing). The BYOC line covers the WarpBuild per minute fee alone, so compare it against your own EC2 rate card before you move a fleet. Teams that already hold committed use discounts, or that run large instance types where the hourly EC2 rate dominates the per minute WarpBuild fee, are the ones that come out ahead on BYOC. Teams without that pricing usually start on hosted runners and move later, which is a label change in `runs-on` and a Terraform apply. One enterprise note for deploy-heavy pipelines: egress costs are handled on the enterprise tier; see [zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress). Every cost number on this page carries its source and the date it was checked, and the same numbers appear on the pricing page and in the evidence data behind it. Next steps depend on where you are. If the AWS account is not connected yet, start with [WarpBuild BYOC on AWS](/byoc/aws) and the [AWS configuration guide](/docs/ci/byoc/aws/config), then come back and put the runner sets in code. If you are on another cloud, see [WarpBuild BYOC on GCP](/byoc/gcp) or [WarpBuild BYOC on Azure](/byoc/azure), where runner sets are managed through the dashboard or the automation API. If the driver is where the machines sit rather than how they are declared, [US data residency for GitHub Actions runners](/enterprise/data-residency-us) covers that question. The provider reference for every resource and data source lives on the [Terraform provider docs page](/docs/ci/api-keys/terraform) and in the [provider repository](https://github.com/WarpBuilds/terraform-provider-warpbuild). ## [FAQ](#faq)Copy Anchor Link **Does the WarpBuild Terraform provider work for GCP or Azure BYOC?** No. Terraform support exists for BYOC on AWS, and the provider is in beta with AWS BYOC only. BYOC runs on AWS, GCP, and Azure, so GCP and Azure runner sets are configured in the WarpBuild dashboard or through the automation API. **Which provider version should I pin?** Pin an exact prerelease version, such as 0.2.0-beta. Beta releases are prereleases, so a range like `~> 0.1` never matches them, and the resource schema may change between beta releases. **What credentials does the provider need?** One WarpBuild API key with the `ci` scope, created at [app.warpbuild.com/settings/api-keys](https://app.warpbuild.com/settings/api-keys) and exported as `WARPBUILD_API_KEY`. AWS access is handled by the IAM role that the WarpBuild cloud connection creates in your account. **Can Terraform create the cloud connection and the WarpBuild stack?** No. The cloud connection and the stack are created in the WarpBuild dashboard through a CloudFormation stack. Terraform reads an existing stack through the `warpbuild_stack` data source and manages runner images and custom runner sets against it. --- # Windows GitHub Actions Runners on BYOC AWS | WarpBuild URL: https://www.warpbuild.com/byoc/aws/windows-runners Description: WarpBuild BYOC runs Windows Server 2022 GitHub Actions runners as EC2 instances inside your own AWS account. Architecture, documented minimums, images, cost. # Windows GitHub Actions Runners on BYOC AWS WarpBuild BYOC runs Windows Server 2022 GitHub Actions runners as EC2 instances inside your own AWS account. Architecture, documented minimums, images, cost. Last verified: 2026-08-13 ## [Overview](#overview)Copy Anchor Link WarpBuild BYOC runs Windows Server 2022 x86-64 GitHub Actions runners as EC2 instances inside your own AWS account, launched per job and terminated when the job finishes. You apply a CloudFormation stack, define a Windows custom runner on top of it, and point `runs-on` at that runner's label; WarpBuild watches the job queue and calls the AWS API with the IAM role the stack created. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. BYOC runs on AWS, GCP, and Azure, and the [feature matrix](/docs/ci/feature-matrix) marks Windows x86-64 as supported on BYOC AWS and BYOC Azure and pending on BYOC GCP, checked on 2026-08-13\. The [AWS BYOC page](/byoc/aws) covers the stack, the IAM surface, and the Linux path; this page covers what changes when the guest is Windows. Three things change: * The [AWS BYOC configuration guide](/docs/ci/byoc/aws/config) publishes a separate set of minimum infrastructure requirements for Windows Server 2022 x86-64 runners, and they sit above the general custom runner minimums. * A custom Windows AMI has requirements a Linux AMI does not, starting with `aria2` on the system PATH and a sysprepped source instance. * WarpBuild caches are not supported for Windows based runners ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13), so the workflow keeps `actions/cache`. ## [Architecture](#architecture)Copy Anchor Link The split is the same one Linux BYOC uses. WarpBuild operates the control plane that holds the GitHub App connection, the runner configuration, and the queue watch. Your AWS account holds the data plane: a VPC with public and private subnets, a security group, an S3 bucket for the artifact cache and runner logs, and the EC2 instances and EBS volumes created per job. Each Windows job gets a fresh instance. Nothing carries over between jobs, which is why a Windows runner definition has to be sized for a cold machine rather than for a long-lived agent that keeps a warm package cache on local disk. Three runner behaviors read differently on Windows, and the first two push work back onto the disk configuration: | Behavior | Linux BYOC runner | Windows BYOC runner | | ------------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------- | | Local NVMe instance store auto-mount | Detected at boot and mounted at the work directory, RAID-0 across multiple devices | Not supported; the work directory lives on EBS | | WarpBuild cache action | Enabled | Not supported; use actions/cache | | Snapshot runners | Not supported on BYOC | Not supported on BYOC or on Windows | Because the work directory sits on EBS on every Windows job, the volume settings in the runner definition are what a slow `dotnet restore` or a slow MSBuild link step usually traces back to. Resource tagging is worth setting up before the first Windows runner, since it is the only way to separate Windows spend from Linux spend in AWS Cost Explorer. The stack tags every resource it creates with `warpbuild-managed-by`, `warpbuild-github-org`, `warpbuild-runner-labels`, `warpbuild-runner-id`, `warpbuild-stack-id`, and `warpbuild-stack-name`. Filtering a cost report on `warpbuild-runner-labels` gives per runner class spend, so a Windows label and a Linux label on the same stack report separately with no extra instrumentation. ## [Configuration](#configuration)Copy Anchor Link Bring up the stack first, following the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config), then create the Windows custom runner. These are the documented numbers, checked on 2026-08-13. | Setting | General custom runner minimum | General best practice | Windows Server 2022 x86-64 on AWS | | --------------- | ------------------------------------- | -------------------------------------- | --------------------------------------- | | Instance size | Your instance types in priority order | Types of similar price and performance | At least 8 vCPU, m7a series recommended | | Disk size | 100 GB | 150 GB | Follows the general guidance | | Disk throughput | 125 MBps | 400 MBps | 500 MBps | | Disk IOPS | 3000 | 4000 | 6000 | The instance row catches teams sizing from the hosted catalog. Hosted Windows runners start at 4 vCPU with 16 GB, since the 2 vCPU Windows shapes were removed on June 8, 2026, and the BYOC guidance starts a step above that at 8 vCPU. List several instance types in priority order. The workflow names one label and WarpBuild picks an available type from your list, so a capacity gap in one m7a size does not park a queued Windows job. Two more runner-level settings apply the same way they do on Linux. Require IMDSv2 forces token-authenticated metadata requests, set on the Update Runner page or at creation time. The Instance Profile ARN field attaches AWS authority to the job itself, configured per runner, so a Windows deploy runner and a Windows test runner carry different permissions under one stack. Spot instances are supported on BYOC AWS and suit short jobs that tolerate interruption; AWS bills spot capacity in your own account at its published rates. ### [Custom Windows AMIs](#custom-windows-amis)Copy Anchor Link BYOC runners can boot your own AMI, and the [custom VM images guide](/docs/ci/byoc/custom-vm-images) lists what a Windows image has to carry. Linux and Windows AMIs are both supported. Four notes are specific to Windows on AWS: 1. `aria2` has to be installed and reachable through the system `PATH`. WarpBuild downloads job artifacts with it because the default Windows method is slow. The guide includes a PowerShell block that unpacks the release into `C:\Tools\aria2` and appends that directory to the machine `Path` variable. 2. The EC2 instance has to be sysprepped before you create the AMI from it, through the Amazon EC2Launch settings dialog or through the Packer sysprep commands the guide links. 3. Jobs run under `runneradmin`, the same user GitHub's Windows runners use, and WarpBuild creates the account if the image lacks it. User-scoped environment variables baked into another account will not reach the job, so move them to machine scope. 4. Pre-job and post-job hooks use the `WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_STARTED` and `WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_COMPLETED` variables, set at machine level from PowerShell rather than in a user profile. One operational detail catches people the first time they debug an image: the stack does not open RDP port 3389 by default, so reaching a Windows instance interactively means adding an inbound rule for your own CIDR block. More image detail is on the [custom AMIs page](/byoc/aws/custom-amis). ### [The workflow](#the-workflow)Copy Anchor Link A BYOC runner is addressed by its Runner ID, which is the runner name with the `warp-custom-` prefix. Hosted labels and BYOC labels coexist in one workflow file. .github/workflows/build.yml Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: windows-build: runs-on: warp-custom-win2022-use1-8x steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 with: path: ~\AppData\Local\NuGet\v3-cache key: nuget-${{ hashFiles('**/packages.lock.json') }} - run: dotnet restore - run: dotnet build --configuration Release --no-restore - run: dotnet test --configuration Release --no-build linux-package: needs: windows-build runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - run: ./scripts/package.sh ``` ## [Operations](#operations)Copy Anchor Link Price the three routes for the same 8 vCPU, 32 GB Windows job before you migrate. WarpBuild rates come from the [pricing page](/pricing); the GitHub rate comes from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Route | Rate per minute | Who bills the compute | | ------------------------------------------- | -------------------- | --------------------- | | GitHub-hosted 8-core Windows larger runner | $0.042 | GitHub | | WarpBuild hosted warp-windows-latest-x64-8x | $0.032 | WarpBuild | | WarpBuild BYOC on AWS | $0.002 WarpBuild fee | Your AWS account | On the hosted route the arithmetic is (0.042 - 0.032) / 0.042 = 24 percent lower list price, GitHub list price checked on 2026-08-13\. The BYOC row is a partial bill by design: add what AWS charges your account for the m7a instances, for the EBS volumes, and for data transfer. AWS bills provisioned IOPS and throughput above the gp3 baseline separately ([AWS EBS pricing](https://aws.amazon.com/ebs/pricing/), checked on 2026-08-13), so the 6000 IOPS and 500 MBps the Windows guidance calls for cost more per volume than the 3000 IOPS floor a Linux runner can sit on. Model a real month of Windows minutes with your own instance mix. Ownership of the image patch cycle moves with the image. WarpBuild patches WarpBuild-managed runner images. A custom Windows AMI is rebuilt on your schedule, which for most Windows fleets means a monthly rebuild that folds in the Patch Tuesday updates plus whatever Visual Studio workload versions your build pins. Automate the Packer build in a workflow and keep the AMI ID in the runner definition under change control. CI observability streams OpenTelemetry system metrics from the runner agent and correlates them with GitHub Actions job logs, which is how you separate an undersized EBS volume from a slow test suite on a Windows job. Watch disk queue depth and throughput first, since that is where the documented Windows minimums bite. SonarQube runs tens of thousands of GitHub Actions jobs per day on WarpBuild BYOC runners in its own AWS account, across Windows and Linux. That is the shape this page is written for: one stack, several custom runners, Windows and Linux labels sharing the same VPC, S3 bucket, and tagging scheme. Sizes and rates for those hosted labels are on the [Windows runners page](/runners/windows), and the shorter platform answer is at [does BYOC support Windows runners](/answers/does-byoc-support-windows-runners). ## [FAQ](#faq)Copy Anchor Link **What are the documented minimums for a Windows BYOC runner on AWS?** The AWS BYOC configuration guide recommends at least 8 vCPU with the m7a series, 6000 IOPS, and 500 MBps of disk throughput for Windows Server 2022 x86-64 runners, checked on 2026-08-13\. The general custom runner minimum is 100 GB, 125 MBps, and 3000 IOPS, so a Windows runner cloned from a Linux definition normally needs its disk settings raised before the first job. **Does the WarpBuild cache work on Windows BYOC runners?** No. WarpBuild caches are not supported for Windows based runners, on hosted capacity and on BYOC. Use `actions/cache` in the workflow for NuGet packages, MSBuild output, and other Windows build state. The S3 bucket in your stack still holds runner system logs and telemetry. **What does a Windows BYOC runner cost?** $0.002 per runner minute in WarpBuild fees. AWS bills your own account for the EC2 instance, the EBS volume, and data transfer at your rates, and the higher IOPS and throughput the Windows guidance calls for are billed by AWS above the gp3 baseline. --- # BYOC Runners on Azure for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/byoc/azure Description: WarpBuild BYOC runs GitHub Actions runners inside your own Azure subscription. WarpBuild manages the fleet, your subscription owns the VMs, disks, and cache. # BYOC Runners on Azure for GitHub Actions WarpBuild BYOC runs GitHub Actions runners inside your own Azure subscription. WarpBuild manages the fleet, your subscription owns the VMs, disks, and cache. Last verified: 2026-08-13 WarpBuild BYOC runs GitHub Actions runners as ephemeral VMs inside an Azure subscription you own, while WarpBuild manages the fleet: it creates each runner, registers it with GitHub, and terminates it when the job ends. Your subscription holds the virtual network, the VMs, the disks, and the storage account and container used for cache and telemetry, so Azure bills that compute to you directly and WarpBuild charges $0.002 per minute for BYOC runners. BYOC runs on AWS, GCP, and Azure. This page covers the Azure path: what lives where, which consent and quotas the setup needs, what the dashboard configures, and who is responsible for each piece once runners are live. One constraint decides most of the planning, so it belongs up front: BYOC Azure is currently enabled for East US only, and additional regions go through [support@warpbuild.com](mailto:support@warpbuild.com). ## [Architecture](#architecture)Copy Anchor Link BYOC on Azure splits into two planes. **The WarpBuild control plane** decides when a runner is needed, creates it in your subscription, registers it with GitHub against the repositories you granted through the WarpBuild GitHub bot, and terminates the VM when the job finishes. It holds the runner images, the queue logic, and the dashboard where stacks and runner configurations are defined. **Your Azure subscription** holds everything that costs Azure money. The VMs that execute jobs, their disks and IP addresses, the virtual network and subnets they sit in, and the storage account and container that holds artifact cache, container layer caches, and telemetry data are all resources in your subscription, under your own role assignments and your own bill. Three objects connect the two planes, and they are created in this order. 1. **A cloud connection.** Creating a cloud connection sets up a consent for the WarpBuild CI Enterprise application with the permissions required by WarpBuild to manage runners. You provide the tenant ID and subscription ID to verify the connection after the consent and permission configuration (ARM deployment) is complete. 2. **A stack.** A stack is the group of infrastructure in one region of your subscription that runners need: the virtual network, the public and private subnetworks, and the storage account and container used for cache and telemetry. 3. **A custom runner.** A custom runner is the machine shape: one or more VM sizes in priority order, the disk configuration, and the IP configuration. Each custom runner gets a Runner ID, and that ID, prefixed with `warp-custom-`, is the `runs-on` label your workflow uses. Three properties of a stack are fixed at creation. The stack name, the storage account and container, and the region cannot be changed after creation. Treat a stack as immutable infrastructure: to move regions, to rename, or to relocate cache storage, create a new stack, point the custom runners at it, and delete the old one. Deletion runs in reverse and the platform enforces the order. A cloud connection cannot be deleted until the stacks that depend on it are deleted, and a stack cannot be deleted until the custom runner configurations that depend on it are deleted. WarpBuild shows the dependent list on each delete confirmation, so a cleanup pass starts at the runner configurations. Every job gets a fresh VM. There is no persistent runner pool to patch, no controller to keep alive in your cluster, and no state carried from one job to the next inside the VM. Cache is the deliberate exception, and it lives in the storage account and container in your subscription rather than on the machine. Egress costs during deploy-heavy jobs are handled on the enterprise tier; see [zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress). Region placement for regulated workloads is covered on the [US data residency page](/enterprise/data-residency-us) and the [EU data residency page](/enterprise/data-residency-eu); US and EU are the regions WarpBuild names publicly. ## [Permissions and Access](#permissions-and-access)Copy Anchor Link Setup needs three grants: one in Microsoft Entra, one in the Azure subscription, and one in GitHub. **Entra consent.** The person running setup must hold [Privileged Role Administrator](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent?pivots=portal#prerequisites) to grant admin consent for the WarpBuild CI Enterprise application. That consent is what allows WarpBuild to manage runner infrastructure. If your organization separates directory administration from subscription ownership, book both people for the same window, because the connection is only verified once the consent and the ARM deployment have both landed. **Subscription scope.** The connection is verified against the tenant ID and subscription ID you supply, so its blast radius is that subscription. A second subscription needs a second cloud connection. Teams that want a hard audit boundary between GitHub Actions and production usually connect a subscription dedicated to GitHub Actions, which also makes the compute line on the Azure bill self-explanatory. **GitHub access.** Install the WarpBuild GitHub bot and grant it the repositories whose workflows should be allowed to queue jobs on these runners. Runners register against those repositories, and a workflow reaches the fleet by carrying the matching `warp-custom-` label in `runs-on`. The outbound list for a runner is short. A runner needs outbound access to `api.warpbuild.com` for registration and lifecycle, and to `github.com` and `api.github.com` for the job itself, plus whatever package registries and container registries your build pulls from. The [AWS security hardening guide](/docs/ci/byoc/aws/security) documents that outbound list in full, and the same destinations apply to runners on Azure. ### [Quotas in the stack region](#quotas-in-the-stack-region)Copy Anchor Link Runner resources are created in the BYOC Azure subscription, so Azure quotas set your practical concurrency ceiling. The [Azure BYOC docs](/docs/ci/byoc/azure) publish per-stack guidance where `CON` is the number of concurrently running jobs you want to support. | Resource | Additional quota per stack | Notes | | --------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------ | | CPU | CON x vCPU per job | Adjust for machine type, preemptible, and on-demand instances | | Persistent disks | CON x DISK\_TB | Adjust provisioned IOPS if needed | | In-use regional external IPv4 addresses | 3 + CON | One static IP is required for NAT in a stack, and one static IP is attached to each concurrently running job | | Object storage | 1 | The same storage container is used for artifact cache, container layer caches, and telemetry data | | NAT | 3 | One per stack | | Networks | 1 | One virtual network is needed | | Subnetworks | 2 | One public and one private subnetwork is needed per stack | Two details trip up first-time setups. The quotas apply to the region where the stack is created, so change the region selector in the Azure portal before editing quota values. And the list is a starting point rather than an exhaustive one; for a fleet sized past a few hundred concurrent jobs, send the intended concurrency to [support@warpbuild.com](mailto:support@warpbuild.com) and get the numbers checked before the first heavy Monday morning. A new stack that throttles early is usually short on quota in its region. Azure approves increases asynchronously, so file the requests during setup rather than after the first queue backs up. ## [Configuration](#configuration)Copy Anchor Link Setup is three screens in the dashboard, in the order the architecture describes: connect the subscription, create the stack, create one or more custom runners. Two limits shape the choices. **No import flow.** BYOC Azure does not support the import flow for stack creation, so the stack creates its own network resources rather than adopting a virtual network you already run. Plan the address space for a new network instead of budgeting time to reuse an existing one. **No snapshot runners.** Snapshot-based runners are not available for BYOC Azure. Keep snapshot-dependent jobs on WarpBuild-hosted Linux runners and run the rest of the pipeline in your subscription. ### [Region availability](#region-availability)Copy Anchor Link BYOC Azure is currently enabled for East US. For additional regions, reach out to [support@warpbuild.com](mailto:support@warpbuild.com). Because the region is fixed at stack creation, decide the region before you create anything, and remember that a second region later means a second stack, a second storage container, and a second set of custom runner definitions pointing at it. ### [Machine and disk configuration](#machine-and-disk-configuration)Copy Anchor Link Pick one or more instance types in priority order. The GitHub workflow uses a single runner label, and the runner picks the instance type based on availability, so a priority list absorbs capacity gaps in the region without a workflow edit. Choose sizes with similar performance so job durations stay predictable across the fallbacks. Spot instances are useful for short jobs that can be interrupted, and they pair well with a priority list of two or three sizes. Azure bills that capacity to your subscription at its own published rates. Disk configuration has a documented floor. The minimum disk size is 256GB, and the recommended configuration is P20; throughput and IOPS are managed automatically by Azure, described on the [Azure managed disk types page](https://learn.microsoft.com/en-us/azure/virtual-machines/disks-types#premium-ssd-size). Two behaviors need no configuration at all. If the selected VM sizes include local NVMe temporary storage, the disks are detected and mounted to the runner's work directory at boot; a single local SSD is formatted and mounted directly, and multiple local SSDs are combined into a RAID-0 array before mounting. This applies to Linux-based images only. Nested virtualization also works automatically on Azure VM sizes that support it with the default `Standard` security type, which matters for Android emulator jobs and similar workloads. Windows Server 2022 x86-64 runners are supported on Azure. The [preinstalled software page](/docs/ci/preinstalled-software#windows-server-2022-x86-64) lists the tooling on that image. Resource tagging is listed as coming soon in the [Azure configuration docs](/docs/ci/byoc/azure/config), so plan cost attribution on the subscription or resource group boundary for now. ### [Workflow YAML](#workflow-yaml)Copy Anchor Link A BYOC runner is referenced by its full Runner ID. A single workflow can mix BYOC labels with hosted ones, which is how most teams handle macOS while their Linux and Windows fleets run in Azure. .github/workflows/ci.yml Copy Text ``` name: ci on: push: branches: [main] jobs: build-linux: # The full Runner ID, including the warp-custom- prefix, is the label runs-on: warp-custom-azure-eastus-linux-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run build test-windows: runs-on: warp-custom-azure-eastus-win-4x steps: - uses: actions/checkout@v4 - run: dotnet test --configuration Release package-ios: runs-on: warp-macos-latest-arm64-6x steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build ``` For a staged cutover, put the label behind a repository variable. The workflow keeps running on a WarpBuild-hosted runner until the Azure stack is ready, and the switch is one variable edit with no pull request. .github/workflows/test.yml Copy Text ``` jobs: test: runs-on: ${{ vars.LINUX_RUNNER || 'warp-ubuntu-latest-x64-4x' }} steps: - uses: actions/checkout@v4 - run: make test ``` Set `LINUX_RUNNER` to `warp-custom-azure-eastus-linux-8x` when the stack is verified, and clear the variable to roll back. ### [Automation](#automation)Copy Anchor Link Terraform support exists for BYOC on AWS. Azure stacks are created through the dashboard flow described above. ## [Who Manages What](#who-manages-what)Copy Anchor Link The split below is the operating agreement for a BYOC Azure fleet. | Area | WarpBuild | Your Azure subscription | | ------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------- | | Runner lifecycle: create, register with GitHub, terminate | Managed by WarpBuild | Nothing to run | | Runner images and preinstalled software | Built and updated by WarpBuild | Nothing to build | | Entra consent for the WarpBuild CI Enterprise application | Requests the consent | Privileged Role Administrator approves it | | Stack resources: virtual network, subnets, storage account and container | Provisioned through the connection | Owned by you, billed to you | | Region choice and the immutable stack name, storage container, and region | Enforces immutability after creation | Chosen by you at creation | | Quota increases in the stack region | Advises the numbers | Requested and approved in Azure | | Runner minutes and compute charges | $0.002 per BYOC runner minute | VM, disk, IP, and storage billed directly by Azure | | Instance type priority list and spot selection | Configuration surface in the dashboard | Sizes and spot policy chosen by you | | Cache, container layer cache, and telemetry data | Written by the runner | Stored in your storage container | | Deletion order: runners, then stack, then connection | Blocks deletes with dependents | Executed by you | | Support and incident response | [support@warpbuild.com](mailto:support@warpbuild.com) | Azure support for subscription-level issues | ### [Cost model](#cost-model)Copy Anchor Link BYOC changes the shape of the bill: WarpBuild fees drop to a flat per-minute rate and the compute moves onto your Azure invoice, where your own reserved capacity and spot pricing apply. WarpBuild rates come from the [pricing page](/pricing). Your BYOC total depends on the Azure machine types, regions, and purchasing model you choose. Work the arithmetic below against your own Azure prices and measured job duration. | Line item | Rate | Clouds | | -------------------- | ----------------- | --------------- | | BYOC Linux runners | $0.002 per minute | AWS, GCP, Azure | | BYOC Windows runners | $0.002 per minute | AWS, Azure | | Add-ons on BYOC | Included | AWS, GCP, Azure | Work a concrete month. Take 100,000 GitHub Actions minutes on a 4 vCPU, 16 GB Linux job. GitHub lists its 4-core Linux larger runner at $0.012 per minute and its 4-core Windows larger runner at $0.022 per minute on the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) (see also [GitHub pricing](https://github.com/pricing)), both checked on 2026-08-13. | Option | Rate billed by the runner vendor | 100,000 minutes | Compute billed by Azure | | ------------------------------------------- | -------------------------------- | --------------- | ----------------------------------------- | | GitHub-hosted 4-core Linux larger runner | $0.012 per minute | $1,200 | None; GitHub bills the compute | | warp-ubuntu-latest-x64-4x, WarpBuild hosted | $0.008 per minute | $800 | None; WarpBuild bills the compute | | BYOC Linux runner on Azure | $0.002 per minute | $200 | VM, disk, IP, and storage on your invoice | The same month on Windows, at 20,000 minutes of a 4 vCPU, 16 GB job: | Option | Rate billed by the runner vendor | 20,000 minutes | Compute billed by Azure | | -------------------------------------------- | -------------------------------- | -------------- | ----------------------------------------- | | GitHub-hosted 4-core Windows larger runner | $0.022 per minute | $440 | None; GitHub bills the compute | | warp-windows-latest-x64-4x, WarpBuild hosted | $0.016 per minute | $320 | None; WarpBuild bills the compute | | BYOC Windows runner on Azure | $0.002 per minute | $40 | VM, disk, IP, and storage on your invoice | The break-even is arithmetic you can run against your own Azure rate card. For the Linux example, BYOC comes out ahead of the WarpBuild-hosted 4 vCPU runner when the all-in Azure cost of that VM stays under $0.006 per minute, which is $0.36 per VM hour including disk, IP, and storage. Against GitHub's 4-core larger runner the threshold is $0.010 per minute, or $0.60 per VM hour. Price the VM sizes on your priority list in East US, add the disk and storage lines, and compare against those two thresholds before committing to a stack. Two costs are easy to forget in that model. The storage account and container accumulates cache and telemetry data, so budget its storage and request charges. And enabling static IPs places runners in private subnets behind NAT, which adds data transfer and processing charges to the traffic a job pulls; leave static IPs off unless a third-party allowlist requires a fixed egress IP. The WarpBuild side of the bill stays simple whatever the split. ### [Compliance and proof](#compliance-and-proof)Copy Anchor Link Because BYOC keeps job execution and cache data inside your own subscription, the audit surface your security team reviews is mostly your own: your network, your storage account, your role assignments, plus the Entra consent granted to the WarpBuild CI Enterprise application. Start with the [Azure BYOC setup docs](/docs/ci/byoc/azure), size the stack against the quota table above, and check the [pricing page](/pricing) for current per-minute rates. If Azure is one of several clouds in play, the same model is documented for [BYOC on AWS](/byoc/aws), where Terraform support exists, and for [BYOC on GCP](/byoc/gcp). ## [FAQ](#faq)Copy Anchor Link **Which Azure regions can I create a BYOC stack in?** BYOC Azure is currently enabled for East US only. For additional regions, reach out to [support@warpbuild.com](mailto:support@warpbuild.com). The region cannot be changed after the stack is created, so a second region means a second stack. **Can I use Terraform to set up BYOC on Azure?** No. Terraform support exists for BYOC on AWS. Azure setup runs through the dashboard in three steps: cloud connection, stack, and custom runner. **Do snapshot runners work on BYOC Azure?** No. Snapshot-based runners are not available for BYOC Azure. Keep snapshot workloads on WarpBuild-hosted Linux runners and run the rest of the pipeline on your Azure subscription. **What does WarpBuild charge for BYOC runners on Azure?** BYOC Linux and Windows runners cost $0.002 per minute in WarpBuild fees, and Azure bills the VM, disk, IP, and storage to your subscription. --- # Current Limitations of BYOC on Azure | WarpBuild URL: https://www.warpbuild.com/byoc/azure/limitations Description: BYOC on Azure has no snapshot runners, no stack import flow, East US only, and tagging plus custom roles pending. Every documented limit with its workaround. # Current Limitations of BYOC on Azure BYOC on Azure has no snapshot runners, no stack import flow, East US only, and tagging plus custom roles pending. Every documented limit with its workaround. BYOC on Azure carries four documented limits: snapshot-based runners are not available, the stack creation flow cannot import an existing virtual network, stacks are currently enabled for East US only, and resource tagging plus custom service account roles are listed as pending. Everything else in the Azure column of the [feature matrix](/docs/ci/feature-matrix) is supported, including Windows Server 2022 x86-64 runners, static IPs, standby disks, spot instances, local SSD auto-mount, and nested virtualization. This page states each limit the way the documentation states it, then gives the workaround, including the AWS or GCP path where another cloud has a capability Azure does not carry yet. BYOC runs on AWS, GCP, and Azure, so most of the platform behaves identically across the three and the rows below are the Azure-specific part of a fleet decision. ## [Overview](#overview)Copy Anchor Link The limits come from two documented sources, both checked on 2026-08-13: the Limitations and Coming Soon sections of the [Azure configuration guide](/docs/ci/byoc/azure/config), and the Azure column of the [feature matrix](/docs/ci/feature-matrix). | Capability | BYOC on Azure | BYOC on AWS | BYOC on GCP | | --------------------------------------- | ------------------- | ------------------------ | ------------------------ | | Snapshot runners | Not supported | Not supported | Not supported | | Import existing stack resources | Not supported | Supported | Not supported | | Create stack resources in a new network | Supported | Supported | Supported | | Custom resource tags | Pending | Supported | Pending | | Custom service account roles | Pending | Supported | Supported | | Region availability | East US only today | Any region you configure | Any region you configure | | Windows runners, x86-64 | Supported | Supported | Pending | | macOS runners | Not offered on BYOC | Not offered on BYOC | Not offered on BYOC | | Static IPs | Supported | Supported | Supported | | Standby disks | Supported | Supported | Supported | | Custom VM images | Supported | Supported | Supported | | Spot instances | Supported | Supported | Supported | | Local SSD auto-mount | Supported | Supported | Supported | | Nested virtualization | Supported | Supported | Supported | | Container layer caching | Supported | Supported | Supported | | Network addons | Supported | Supported | Supported | Two rows deserve a plain reading rather than a softened one. Snapshot runners are unavailable on every BYOC cloud, so this is a hosted-versus-BYOC boundary rather than an Azure gap. macOS is absent from all three BYOC columns as well. The macOS fleet is hosted, so an iOS job in an Azure-based pipeline keeps a hosted label. ## [Architecture](#architecture)Copy Anchor Link Three properties of a stack are fixed at creation: the stack name, the storage account and container, and the region. That immutability turns the East US restriction into a planning decision rather than a setting you revisit. A second region means a second stack, a second storage container, and a second set of custom runner definitions pointing at it, and additional regions go through [support@warpbuild.com](mailto:support@warpbuild.com). The missing import flow lands in the same place. On Azure the stack provisions its own virtual network, one public and one private subnetwork, and the storage account and container used for artifact cache, container layer caches, and telemetry data. Budget an address range for a new network in East US instead of budgeting time to adopt one you already run. The [BYOC setup documentation](/docs/ci/byoc) confirms that importing resources is supported on AWS only. Deletion runs in reverse and the platform enforces the order. A cloud connection cannot be deleted while stacks depend on it, and a stack cannot be deleted while custom runner configurations depend on it, so a region migration is a create-then-repoint-then-delete sequence: build the new stack, point each custom runner at it, then remove the old runners, the old stack, and finally the connection if nothing else uses it. Quota headroom sits on your side of the line. The [Azure BYOC docs](/docs/ci/byoc/azure) publish per-stack guidance where `CON` is the number of concurrent jobs you want to support: CPU at `CON` times vCPU per job, persistent disks at `CON` times disk size, in-use regional external IPv4 addresses at 3 plus `CON`, one storage container, three NAT, one virtual network, and two subnetworks. Those quotas apply to the region holding the stack, which for now means East US. ## [Configuration](#configuration)Copy Anchor Link Two behaviors that often need flags elsewhere need no configuration at all on Azure, and both are worth knowing before you size VMs. **Local SSD.** If the selected VM sizes include local NVMe temporary storage, the disks are detected and mounted to the runner work directory at boot. A single local SSD is formatted and mounted directly, and multiple local SSDs are combined into a RAID-0 array before mounting. This applies to Linux-based images only, so a Windows Server 2022 runner set on Azure plans its throughput around managed disks. The full support matrix and the instance families that carry NVMe temporary storage are on the [local SSD runners page](/runners/local-ssd). **Nested virtualization.** Nested virtualization works automatically on Azure VM sizes that support it with the default `Standard` security type, with no label and no extra configuration. One caveat carries across BYOC clouds: it is enabled only when every instance type configured for the runner set supports it, so a fallback list mixing supporting and non-supporting sizes turns it off for consistency. Android emulator jobs still need the KVM permissions step described on the [nested virtualization page](/runners/nested-virtualization). Managed disk configuration has a documented floor. The minimum disk size is 256GB and the recommended configuration is P20, with throughput and IOPS managed automatically by Azure ([Azure managed disk types](https://learn.microsoft.com/en-us/azure/virtual-machines/disks-types#premium-ssd-size)). Pick one or more instance types in priority order so a capacity gap in East US falls back without a workflow edit, and keep the sizes close in performance so job durations stay predictable. Spot instances suit short interruptible jobs, and Azure bills that capacity to your subscription at its own published rates. A workflow absorbs the two hard limits with labels rather than with restructuring. The BYOC jobs carry the full Runner ID with its `warp-custom-` prefix, and the snapshot job and the macOS job carry hosted labels. .github/workflows/ci.yml Copy Text ``` name: ci on: [push] jobs: build: runs-on: warp-custom-azure-eastus-linux-8x steps: - uses: actions/checkout@v4 - run: make build integration: # Snapshot runners are hosted only, so this job stays on a warp- label runs-on: warp-ubuntu-latest-x64-4x;snapshot.key=integration-db steps: - uses: actions/checkout@v4 - run: ./scripts/integration.sh package-ios: runs-on: warp-macos-latest-arm64-6x steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build ``` ## [Operations](#operations)Copy Anchor Link Each limit has a documented workaround, and three of them have a path on another cloud that matters when the fleet is already multi-cloud. | Limitation | Workaround on Azure | Path on another cloud | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Snapshot runners unavailable | Keep snapshot-dependent jobs on hosted Linux runners with snapshot.key or snapshot.enabled in the same workflow | None. Snapshot runners are hosted only on all three clouds | | No import of an existing virtual network | Let the stack create its network, subnetworks, and storage; plan a fresh address range | Import mode exists on AWS and takes an existing VPC, subnets, and security group as inputs | | East US only | Create the stack in East US; request additional regions through [support@warpbuild.com](mailto:support@warpbuild.com) | AWS and GCP stacks are created in whichever region you configure | | Resource tagging pending | Attribute cost at the subscription or resource group boundary, and give GitHub Actions its own subscription if the finance split has to be clean | Custom resource tags are supported on AWS | | Custom service account roles pending | Authenticate from inside the job with GitHub OIDC ([securing deployments with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)) rather than an identity attached to the VM | Custom roles are supported on AWS and GCP | | macOS absent from BYOC | Run iOS and macOS jobs on hosted warp-macos-latest-arm64-6x labels alongside the Azure jobs | None. The macOS fleet is hosted on all three clouds | | Stack name, storage, and region fixed at creation | Treat the stack as immutable: create the replacement, repoint runners, then delete in dependency order | Same behavior on AWS and GCP | The snapshot workaround is the one with a price attached, so run the arithmetic before deciding which jobs move to Azure. Rates below come from the [pricing page](/pricing), with the GitHub list price checked on 2026-08-13. | Line item | Rate | Compute billed by | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | BYOC Linux or Windows runner minute on Azure | $0.002 per minute | Your Azure subscription: VM, disk, IP, storage | | Hosted warp-ubuntu-latest-x64-4x | $0.008 per minute | WarpBuild | | Hosted warp-windows-latest-x64-4x | $0.016 per minute | WarpBuild | | Hosted warp-macos-latest-arm64-6x | $0.08 per minute | WarpBuild | | Snapshot restore | $0.04 per job | WarpBuild | | Snapshot storage | $0.025 per snapshot-hour | WarpBuild | | GitHub-hosted 4-core Linux larger runner | $0.012 per minute ([GitHub billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked 2026-08-13) | GitHub | Take a pipeline with a 12 minute build job and a 10 minute integration job that restores a snapshot on every run. The build job on BYOC Azure costs 12 x $0.002 = $0.024 in WarpBuild fees, with the VM, disk, IP, and storage landing on your Azure invoice. The integration job stays hosted at 10 x $0.008 = $0.08 plus $0.04 for the restore, so $0.12 per run plus snapshot storage at $0.025 per snapshot-hour. The same integration job on the GitHub-hosted 4-core Linux larger runner is 10 x $0.012 = $0.12 in runner minutes with no snapshot restore available at all. Split that way, the East US limit and the snapshot limit cost a hybrid label map rather than a second platform. Next steps depend on which limit is blocking. For the full Azure setup path, the consent model, and the quota table in context, start with [BYOC runners on Azure](/byoc/azure). For the decision about which jobs belong on your subscription at all, see [hosted versus BYOC runners](/guides/hosted-vs-byoc-runners). For the two automatic behaviors above, the detail lives on the [local SSD](/runners/local-ssd) and [nested virtualization](/runners/nested-virtualization) pages. ## [FAQ](#faq)Copy Anchor Link **What are the documented limitations of BYOC on Azure?** Four. Snapshot-based runners are not available, the stack creation flow cannot import an existing virtual network, stacks are currently enabled for East US only, and resource tagging plus custom service account roles are listed as pending. Everything else in the Azure column of the feature matrix is supported, including Windows Server 2022 x86-64, static IPs, standby disks, spot instances, local SSD auto-mount, and nested virtualization. **Can I run snapshot runners on BYOC Azure?** No. The feature matrix marks snapshot runners as unsupported on all three BYOC clouds, so snapshot labels belong on WarpBuild-hosted Linux runners. A single workflow can carry both, with snapshot-dependent jobs on a `warp-` label and the rest of the pipeline on a `warp-custom-` label in your Azure subscription. **Can a BYOC Azure stack use a virtual network I already run?** No. Import of existing stack resources is supported on AWS only, so an Azure stack creates its own virtual network, its public and private subnetworks, and its storage account and container. Plan a fresh address range in East US before you start, because the stack name, the storage account and container, and the region are fixed at creation. --- # WarpBuild Control Plane Egress IPs for GitHub | WarpBuild URL: https://www.warpbuild.com/byoc/control-plane-egress-ips Description: The WarpBuild control plane calls GitHub from three static egress IPs. Where the published list lives, how to scope the allow list rule, and when to recheck it. # WarpBuild Control Plane Egress IPs for GitHub The WarpBuild control plane calls GitHub from three static egress IPs. Where the published list lives, how to scope the allow list rule, and when to recheck it. ## [Overview](#overview)Copy Anchor Link The WarpBuild control plane makes its outbound requests to GitHub from three static IPv4 addresses: `3.13.16.127`, `3.141.157.169`, and `3.151.226.250`. Allowlist them when a GitHub organization IP allow list or a firewall in front of a GitHub Enterprise Server instance would otherwise drop runner registration, lifecycle management, and GitHub API calls. The published list lives in one place, the control plane egress IPs section of the [GitHub Enterprise App documentation](/docs/ci/ghe#control-plane-egress-ips). Read that section before you write or edit a rule. The copy below was checked on 2026-08-13 and is reproduced here so a network team can see the shape of the rule, while the docs anchor stays the canonical source. Three situations call for the rule: * A GitHub organization on `github.com` with IP allow list enforcement enabled. * A GitHub Enterprise Server instance whose ingress is restricted at a firewall or load balancer. * GitHub Enterprise Cloud with data residency, for example `acme.ghe.com`, behind the same kind of restriction. An organization with no allow list enforcement has nothing to configure. The control plane reaches GitHub over the public internet the way any other API client does, and adding the addresses changes nothing. The rule is also narrower than teams expect on first read. It governs traffic from WarpBuild toward GitHub. Traffic from a runner toward your own registries, package mirrors, or internal services is a separate concern with a separate source address, covered in [outbound traffic controls for BYOC runners](/byoc/outbound-traffic-controls). ## [Architecture](#architecture)Copy Anchor Link WarpBuild splits into a control plane that WarpBuild operates and a data plane that runs the jobs. Only the first half uses these addresses. The control plane holds the GitHub App connection, watches the job queue, reconciles runner capacity against queued jobs, and calls the GitHub API on your behalf. Those calls originate from the three documented addresses. The data plane is the runner itself. On BYOC it is an ephemeral VM inside your own cloud account, and BYOC runs on AWS, GCP, and Azure. On WarpBuild-hosted capacity it is a VM in WarpBuild infrastructure. Either way the runner agent opens its own outbound connections and never inherits the control plane addresses. | Traffic | Source address | Destination | Rule you write | | ----------------------------------------------------------- | ----------------------------------- | --------------------------------------------- | -------------------------------------------------------------- | | Runner registration, lifecycle management, GitHub API calls | The three control plane egress IPs | github.com, api.github.com, or your GHES host | GitHub IP allow list entry, or inbound allow at your GHES edge | | Runner agent to the WarpBuild control plane | The runner, in your account on BYOC | api.warpbuild.com | Outbound rule in your own security group | | Runner agent to GitHub for job execution and Git operations | The runner | github.com, api.github.com | Outbound rule in your own security group | | Build steps pulling dependencies | The runner | Your registries and package mirrors | Outbound rule in your own security group | The second and third rows are the ones that get conflated with this page. A BYOC runner in a private subnet leaves through a NAT gateway, and the [AWS configuration guide](/docs/ci/byoc/aws/config) states that runners with static IPs carry the NAT gateway addresses as their external IP addresses. With static IPs disabled the runners sit in public subnets. Neither shape uses a control plane address, so an allow list built for runner traffic needs your own addresses, and an allow list built for control plane traffic needs the three below. The list does not vary by runner platform or by cloud. Every one of them registers with GitHub through the same control plane. ## [Configuration](#configuration)Copy Anchor Link The three addresses in host form, ready to paste into an allow list that expects CIDR notation: | Address | CIDR form | Purpose | | ------------- | ---------------- | ----------------------- | | 3.13.16.127 | 3.13.16.127/32 | Control plane to GitHub | | 3.141.157.169 | 3.141.157.169/32 | Control plane to GitHub | | 3.151.226.250 | 3.151.226.250/32 | Control plane to GitHub | Checked on 2026-08-13 against [the documented list](/docs/ci/ghe#control-plane-egress-ips). For an organization on `github.com`, add each entry under organization security settings, following [GitHub's guide to allowed IP addresses](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/managing-allowed-ip-addresses-for-your-organization). Give each entry a description that survives a later audit: Copy Text ``` 3.13.16.127/32 warpbuild-control-plane 3.141.157.169/32 warpbuild-control-plane 3.151.226.250/32 warpbuild-control-plane ``` For a GitHub Enterprise Server instance, the equivalent is an inbound allow on TCP 443 from those three `/32` sources to the host, written wherever your ingress is enforced: a cloud security group, a load balancer policy, or a hardware firewall. Keep the rule in the same version-controlled repository as the rest of your network configuration and reference the docs anchor in a comment so the next reviewer can verify the list without asking anyone. Order matters. Add the addresses before you enable allow list enforcement, and add them before you run the GitHub Enterprise setup flow, where allowlisted control plane egress IPs are listed as a prerequisite alongside enterprise owner access, the enterprise slug, and the enterprise host. Reversing that order produces a setup link that fails to generate, or an app that is created and then never registers a runner. Nothing in a workflow file changes. Jobs keep addressing runners by label: .github/workflows/build.yml Copy Text ``` name: build on: pull_request: branches: [main] jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test ``` ## [Operations](#operations)Copy Anchor Link Treat the address list as a dependency with a review date rather than a one-time setup step. | Symptom | Likely cause | First check | | ------------------------------------------------------------------ | ------------------------------------------------- | -------------------------------------------------------- | | Setup link fails to generate for a GHE instance | The control plane cannot reach your host | Confirm the three sources are allowed inbound on TCP 443 | | App created, runners never register | Registration calls dropped at your network edge | Confirm the entries exist in the GitHub IP allow list | | Jobs queue immediately after allow list enforcement is switched on | Enforcement enabled before the entries were added | Re-read the docs anchor and compare it against your rule | | Rule passes review but nobody can date it | No recorded check | Stamp the review date next to the rule | The review cadence follows this page. It carries a last-verified date of 2026-08-13 and a 180 day verification cadence, so the address list is re-read against the documentation twice a year. Do the same on your side, and add an unscheduled check to two moments: before you enable allow list enforcement, and during any migration that moves your GitHub host, since both are points where a stale rule turns into queued jobs rather than a clear error. Two facts help scope who owns the change. WarpBuild has no way to edit your allow list, so the entry is always made by your team. The [security documentation](/docs/ci/security) carries both, alongside the compute isolation and storage controls a reviewer reads next. None of this affects what you pay. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, and the [pricing page](/pricing) carries the current per-minute rates. For reference, `warp-ubuntu-latest-x64-4x` costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner from GitHub at the same 4 vCPU and 16 GB shape: 33 percent lower list price, with the GitHub list price taken from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and checked on 2026-08-13\. BYOC adds a WarpBuild fee of $0.002 per runner minute while your own cloud account bills the compute. Signup includes $10 free credits, which is enough to register a runner and watch a first job move through the allow list before an invoice exists. Once the rule is in place, the rest of the BYOC setup is unchanged: pick a cloud, create the stack, and point a label at it, starting from [BYOC runners on AWS](/byoc/aws). ## [FAQ](#faq)Copy Anchor Link **What are the WarpBuild control plane egress IPs?** The control plane makes its outbound requests to GitHub from three static IPv4 addresses: `3.13.16.127`, `3.141.157.169`, and `3.151.226.250`. The published list lives in the [GitHub Enterprise App documentation](/docs/ci/ghe#control-plane-egress-ips), and that page is the canonical copy to read before you edit a rule. **Do these addresses cover runner traffic as well?** No. They cover the requests WarpBuild makes to GitHub for runner registration, lifecycle management, and GitHub API calls. A BYOC runner sits in your own cloud account and leaves through your own network path, so a rule that has to match runner traffic references your NAT gateway or subnet addresses instead. **When do I need to allowlist them?** When your organization uses a GitHub IP allow list, or when a firewall restricts inbound traffic to a GitHub Enterprise Server instance. Organizations on `github.com` with no allow list enabled have nothing to configure, because the control plane reaches GitHub over the public internet like any other API client. --- # A Cost Model for BYOC Runners | WarpBuild URL: https://www.warpbuild.com/byoc/cost-model Description: BYOC billing has two lines: $0.002 per runner minute to WarpBuild and the compute on your own cloud bill. A worked monthly model with storage and transfer. # A Cost Model for BYOC Runners BYOC billing has two lines: $0.002 per runner minute to WarpBuild and the compute on your own cloud bill. A worked monthly model with storage and transfer. Last verified: 2026-08-13 A BYOC cost model has exactly two lines: WarpBuild charges $0.002 per runner minute for BYOC GitHub Actions runners, and every instance hour, volume, byte of object storage, and gigabyte of data transfer those runners consume is billed by your own cloud account at your account's rates ([pricing page](/pricing) and [BYOC documentation](/docs/ci/byoc), checked on 2026-08-13). Modeling BYOC means fixing the first line with one multiplication and estimating the second one from the runner set configuration you choose. This page gives the two-line model, the configuration decisions that create each cloud line, a worked month with every assumption stated, and the numbers to re-check once real invoices arrive. ## [Overview](#overview)Copy Anchor Link The first line is arithmetic. Runner minutes multiplied by $0.002 is the entire WarpBuild invoice for BYOC. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so a second runner set, a second stack, or a tenth engineer changes nothing except minutes consumed. | Line | Rate | Billed by | What moves it | | ----------------------------------------- | ------------------------ | ---------- | ---------------------------------------------- | | BYOC Linux runners on AWS, GCP, or Azure | $0.002 per runner minute | WarpBuild | Job minutes only | | BYOC Windows runners on AWS or Azure | $0.002 per runner minute | WarpBuild | Job minutes only | | Instance hours | Your account rate | Your cloud | Shape, region, spot share, wall clock overhead | | Root and standby disk volumes | Your account rate | Your cloud | Disk size, IOPS, throughput, pool size | | Object storage for cache, logs, artifacts | Your account rate | Your cloud | Cache volume and lifecycle retention | | Data transfer and gateway processing | Your account rate | Your cloud | Static IP setting and traffic mix | BYOC runs on AWS, GCP, and Azure. The macOS fleet is hosted rather than BYOC, so macOS jobs price from the per-minute rates on the [pricing page](/pricing) instead of from the two-line model here. Signup includes $10 free credits, which is 5,000 BYOC runner minutes of WarpBuild fees. That is enough to run a real week of workflows and read both counters before committing a fleet. ## [Architecture](#architecture)Copy Anchor Link Every cloud line traces back to a decision made when the stack or the runner set was created, which is why the model is worth building before the stack rather than after the first invoice. | Decision | Where it is made | Cloud line it sets | | --------------------------- | ------------------------------- | ------------------------------------------------ | | Region | Stack, permanent after creation | Instance, storage, and transfer rates | | Instance type list | Runner set, ordered by priority | Instance hours | | Capacity type | Runner set | On-demand or spot instance rate | | Disk size, IOPS, throughput | Runner set | Block storage volume charges | | Pool size and standby disks | Runner set | Instance hours and volume hours outside job time | | Static IPs | Runner set | Gateway data processing and egress | | Bucket lifecycle policy | Your AWS account | Object storage charges | Three of those deserve their own note. Disk configuration has a floor. The [AWS configuration guide](/docs/ci/byoc/aws/config) puts the minimum at 100GB, 125MBps, and 3000 IOPS, and recommends 150GB, 400MBps, and 4000 IOPS. Provisioned IOPS and throughput above the baseline bill separately from capacity on most providers, so a runner set tuned for a heavy build carries a volume charge that a light one does not. Object storage is a retention question. The stack's bucket holds artifact cache, container layer cache, runner logs, and other workflow artifacts, and the same guide recommends a 7-day lifecycle policy. Retention is the largest single lever on that line, because GitHub Actions cache entries churn quickly. Data transfer depends on one toggle. With static IPs disabled, runner instances sit in public subnets, and the [BYOC documentation](/docs/ci/byoc) notes that this path avoids ingress transfer charges and usually leaves minimal egress for GitHub Actions workloads. Enabling static IPs moves runners into private subnets behind a NAT gateway, which adds gateway data processing on traffic in both directions plus any inter-region transfer; the documentation puts outbound transfer from a runner to the internet at around $0.45 per GB on AWS and recommends using static IPs sparingly. Two configuration steps cut the same line further: an S3 gateway endpoint on the VPC so cache traffic reaches the bucket without transfer charges, and the ECR VPC endpoints that WarpBuild configures automatically. ## [Configuration](#configuration)Copy Anchor Link Here is a full month with every input stated. Substitute your own rates and shape; the structure is what carries over. | Input | Value | Source | | ----------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | | BYOC Linux runner minutes per month | 250,000 | Your run history | | Job shape | 4 vCPU, 16 GB | Your runs-on labels | | Average job length | 5 minutes, so 50,000 jobs | Your run history | | Job hours implied | 4,166.7 | 250,000 divided by 60 | | Instance wall clock overhead | 10 percent, so 4,583.3 instance hours | Boot, registration, teardown per job | | Share of instance hours on spot | 60 percent | Your runner set split | | Example on-demand rate | $0.20 per hour | Substitute yours from [AWS EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/) | | Spot rate assumption | 70 percent below on-demand, so $0.06 per hour | Substitute your own Spot price history | | Root volume per runner | 150 GB, the recommended configuration | [AWS configuration guide](/docs/ci/byoc/aws/config) | | Example block storage rate | $0.08 per GB-month | Substitute yours from your provider's price list | | Cache and log storage held | 200 GB average under 7-day retention | Your bucket metrics | | Example object storage rate | $0.023 per GB-month | Substitute yours from your provider's price list | | Outbound internet transfer | 300 GB per month, static IPs disabled | Your flow logs | | Transfer rate | Around $0.45 per GB on AWS | [BYOC documentation](/docs/ci/byoc) | The spot assumption sits inside a published range rather than above it. BYOC runners cost $0.002 per minute in WarpBuild fees and the compute is billed by your own cloud account, where AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices ([AWS EC2 Spot Instances](https://aws.amazon.com/ec2/spot/), checked on 2026-08-13). That ceiling is an AWS figure for its own capacity, so treat it as the boundary your realized rate moves within. | Line | Arithmetic | Monthly cost | | ------------------------ | ------------------------------------ | ------------- | | WarpBuild BYOC fee | 250,000 x $0.002 | $500.00 | | On-demand instance hours | 1,833.3 x $0.20 | $366.67 | | Spot instance hours | 2,750.0 x $0.06 | $165.00 | | Block storage | 941.8 GB-months x $0.08 | $75.34 | | Object storage | 200 x $0.023 | $4.60 | | Outbound transfer | 300 x $0.45 | $135.00 | | **Total** | WarpBuild $500.00 plus cloud $746.61 | **$1,246.61** | Block storage is prorated the same way instance hours are: 4,583.3 instance hours at 150 GB is 687,495 GB-hours, and dividing by 730 hours gives 941.8 GB-months. Volumes attached to standby disks bill continuously instead, since the disk stays while the instance is stopped. Two reference points make the total readable. The same 250,000 minutes on the hosted `warp-ubuntu-latest-x64-4x` label bill 250,000 x $0.008 = $2,000.00 with no cloud bill, no capacity planning, and no quota work. On the GitHub-hosted 4-core Linux larger runner they bill 250,000 x $0.012 = $3,000.00; `warp-ubuntu-latest-x64-4x` costs $0.008 per minute against $0.012 for that runner, 33 percent lower list price ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). Use the model above with your measured job duration and cloud rates. Per-size WarpBuild rates for every label sit on the [pricing page](/pricing). Routing is what puts each job on the line you modeled, and both label kinds live in one workflow file. Copy Text ``` name: build on: [push] jobs: integration: runs-on: warp-custom-ci-linux-4x steps: - uses: actions/checkout@v4 - run: ./scripts/integration-tests.sh docs: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/build-docs.sh ``` ## [Operations](#operations)Copy Anchor Link A model built before the first invoice needs three corrections after it, and all three come from counters you already have. **Instance hours against runner minutes.** The 10 percent overhead assumption is the first thing real data contradicts. Divide billed instance hours by job hours from the WarpBuild reports; short jobs push the ratio up because boot and teardown are a fixed cost per job, so a fleet of 90-second jobs carries far more overhead than one of 15-minute jobs. Lengthening jobs by batching steps, or raising pool size so warm capacity absorbs the start, moves that ratio in opposite directions for the same reason. **Spot interruption rework.** A reclaimed instance fails the job it was running and the job restarts from its first step, while the minutes already burned stay on both bills. At a 5 percent interruption rate on 150,000 spot minutes with jobs lost halfway through on average, rework adds 3,750 minutes, which is $7.50 in WarpBuild fees plus the matching compute. Long fallback instance type lists and a spread of availability zones are what keep the rate low. The [spot instances guide](/guides/spot-instances-github-actions-runners) covers which job classes tolerate the trade. **Storage and transfer drift.** Cache buckets grow until a lifecycle policy stops them, and transfer climbs when a workflow starts pulling large images from outside your region. Both lines are small in the worked month above and both are the ones that quietly triple. Reconciling the two invoices is a tagging problem rather than a modeling one. WarpBuild reports break down per job, per repository, and per runner label with execution time and billed time side by side; the cloud bill breaks down by resource tag. [Cost allocation tags for BYOC runners](/byoc/aws/cost-allocation-tags) covers the default tag keys the stack applies and the join between the two views, and [how BYOC billing works](/answers/how-does-byoc-billing-work) is the short version of the split. The decision the model actually informs is where each job class runs. BYOC wins when the compute line in your own account beats the hosted per-minute rate by more than the operating work costs you, which usually means committed-use discounts you already hold, a spot-tolerant job mix, or large instance shapes where the hourly rate dominates the per-minute fee. Hosted labels win when nobody wants to own quotas, images, and capacity. [The total cost of a self-operated runner fleet](/guides/self-hosted-runner-total-cost) prices that operating half in engineer hours, and it is the term most first models leave out. ## [FAQ](#faq)Copy Anchor Link **Does the WarpBuild BYOC fee change with instance size?** No. BYOC runners cost $0.002 per runner minute whether the runner set launches a 2 vCPU instance or a 64 vCPU instance. Instance size only moves the compute line, which your own cloud account bills at its own rate. **Why are my cloud instance hours higher than my billed runner minutes?** WarpBuild meters the job. Your cloud meters instance wall clock time, which includes boot, runner registration, and teardown around every job, plus any warm pool instances and the volumes attached to standby disks. Budget an overhead factor on top of job time rather than assuming the two counters match. **When does BYOC come out cheaper than hosted runners?** When the compute line in your own account beats the hosted per-minute rate by more than the operating work costs you. Teams with committed-use discounts, a spot-tolerant job mix, or large instance shapes usually clear that bar. Teams without an existing cloud footprint usually start on hosted runners and move later, which is a label change in `runs-on`. --- # BYOC Runners on GCP for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/byoc/gcp Description: Run GitHub Actions runners as Compute Engine VMs in your own GCP project. Stack layout, service account roles, quotas, and the $0.002 per minute fee. # BYOC Runners on GCP for GitHub Actions Run GitHub Actions runners as Compute Engine VMs in your own GCP project. Stack layout, service account roles, quotas, and the $0.002 per minute fee. Last verified: 2026-08-13 WarpBuild BYOC runs your GitHub Actions runners as Compute Engine instances inside your own GCP project, so job workspaces, caches, and workflow artifacts sit in infrastructure your organization owns and Google bills the compute to your billing account. BYOC runs on AWS, GCP, and Azure, and on GCP the WarpBuild fee is $0.002 per minute per Linux runner while the workflow itself changes by one `runs-on` label. This page covers the GCP shape of BYOC: what WarpBuild creates in your project, which service account and roles the connect flow needs, the quotas to raise before your first busy day, the disk and instance settings that matter, and the split of operational responsibility between your team and WarpBuild. The click path and screenshots live in the [GCP BYOC documentation](/docs/ci/byoc/gcp). ## [Architecture](#architecture)Copy Anchor Link A GCP BYOC deployment has two halves. The WarpBuild control plane runs outside your project. It stores runner configurations, receives the job event when a workflow queues a job carrying one of your runner labels, matches that label to a configuration, and issues the Compute Engine calls that create and terminate instances. It also serves the dashboard, the reports, and the API. Your GCP project holds every resource that touches your code: the Compute Engine instances that execute jobs, the VPC and subnetworks those instances boot into, and the Cloud Storage bucket that backs caches, container image layer caches, logs, and other workflow artifacts. Every resource in that list is created inside the project you connect. Three objects join the halves, and they are created in this order. **The cloud connection.** Connecting a GCP project creates a service account in that project carrying the permissions WarpBuild needs to build stacks and manage runners. WarpBuild's own service account impersonates it to generate short lived tokens, which is why the IAM Service Account Credentials API has to be enabled before you start. Deleting the connection removes that access path, and WarpBuild blocks the delete while stacks still depend on it. **The stack.** A stack is the set of infrastructure components in one region of your project that runners need: the VPC, one public and one private subnetwork, and the Cloud Storage bucket. Stacks are built through Cloud Deployment Manager V2, which is the second API you enable, and the dashboard shows a pending state while Deployment Manager works. **The custom runner.** A runner configuration names the stack it belongs to, one or more Compute Engine machine types in priority order, the disk type and size, whether instances are spot, whether they get static IPs, and optionally a service account to attach to each instance. The dashboard assigns a Runner ID prefixed with `warp-custom-`, and that ID is the label you put in `runs-on`. ### [Region and bucket are fixed at creation](#region-and-bucket-are-fixed-at-creation)Copy Anchor Link Three stack properties cannot be changed after the stack exists: the stack name, the Cloud Storage bucket, and the region. Treat the create form as a one-way decision. Picking a different region later means creating a second stack, creating runner configurations against it, and moving `runs-on` labels over, which is cheap in workflow terms and slow in review terms. The bucket name has to be globally unique across Cloud Storage. A name already taken in another organization fails the Deployment Manager create with a 403 on `storage.buckets.get`, and the error text names the Google-managed `cloudservices.gserviceaccount.com` principal rather than the name collision, which sends people looking for a permissions problem that does not exist. Prefix the bucket with something unique to your company on the first attempt. Name stacks after the product and region so the resources stay legible in the GCP console months later. One stack per region is the normal shape: a stack does not span regions, and each additional region is another stack, another bucket, and another quota conversation with Google. ### [The import flow is AWS only](#the-import-flow-is-aws-only)Copy Anchor Link BYOC GCP creates its resources through the easy create flow, which builds the VPC and subnetworks according to the layout WarpBuild ships. Importing an existing VPC is supported on [BYOC runners on AWS for GitHub Actions](/byoc/aws) and is not available on GCP today. If a policy requires runners inside a specific pre-existing VPC, that constraint decides the cloud. ### [Where this sits next to hosted runners](#where-this-sits-next-to-hosted-runners)Copy Anchor Link WarpBuild-hosted runners run in US and EU regions, and the [region-specific infrastructure options](/enterprise/data-residency-us) cover the hosted side of the placement question. With BYOC on GCP, placement is whatever region of your own project you create the stack in, and the compliance conversation moves inside your existing cloud footprint. Teams with an EU requirement usually read [EU data residency for GitHub Actions runners](/enterprise/data-residency-eu) alongside this page. ## [Permissions and Access](#permissions-and-access)Copy Anchor Link Three things have to be true in the project before the connect flow succeeds: a billing account is linked, six APIs are enabled, and the human running the flow holds enough IAM to create the resources. ### [APIs to enable](#apis-to-enable)Copy Anchor Link | API | Why WarpBuild needs it | | ---------------------------------------- | --------------------------------------------------------------------------------- | | Cloud Storage API | Caches, container image layer caches, and telemetry storage | | IAM Service Account Credentials API | Short lived tokens minted through impersonation of the connection service account | | Identity and Access Management (IAM) API | Creates the service account used for access management in your project | | Cloud Deployment Manager V2 API | Creates the cloud integration and the stack as versioned deployments | | Compute Engine API | Runner instance lifecycle | | Cloud Resource Manager API | Resource tagging and management | Enable each one against the project you plan to connect. The project dropdown in the GCP console is the usual source of a failed connect: the API gets enabled on a different project than the one being connected. ### [Roles for the person running the connect flow](#roles-for-the-person-running-the-connect-flow)Copy Anchor Link The user who creates the cloud integration and the stack needs four predefined roles: Security Admin, Storage Admin, Deployment Manager Editor, and Compute Admin. Security Admin is what allows the flow to create and bind the service account, Deployment Manager Editor is what allows the stack deployment, and the other two cover the bucket and the compute resources. These roles apply to the person running setup. Runner instances carry only the service account you attach to them, covered below. ### [The service account attached to your runners](#the-service-account-attached-to-your-runners)Copy Anchor Link Runner instances can carry a service account of your choosing, which is how a job authenticates to Artifact Registry, Cloud Storage, or any other Google API without a long-lived key in a GitHub secret. The [service account setup guide](/docs/ci/byoc/gcp/service-account) walks through it. The shape is a service account you create, a binding that lets WarpBuild's creator service account pass it, and whatever project-level roles the workload actually needs: Copy Text ``` gcloud config set project "${PROJECT_ID}" gcloud iam service-accounts create instance-sa \ --display-name="Instance Service Account" export SA_EMAIL="instance-sa@${PROJECT_ID}.iam.gserviceaccount.com" # CREATOR_SA is the runner-creating service account shown on the BYOC page gcloud iam service-accounts add-iam-policy-binding "${SA_EMAIL}" \ --member="serviceAccount:${CREATOR_SA}" \ --role="roles/iam.serviceAccountUser" gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ --member="serviceAccount:${SA_EMAIL}" \ --role="roles/artifactregistry.admin" ``` Select the service account in the `Service Account` field on the runner edit page. To confirm it took effect, open Compute Engine in the GCP console, pick a running runner instance, and check `Service account` under API and identity management. Grant this account the narrowest roles the jobs need; `roles/artifactregistry.admin` above is an example, and a reader role is enough for pipelines that only pull images. ### [Quotas, per stack, in the stack region](#quotas-per-stack-in-the-stack-region)Copy Anchor Link Quota exhaustion looks like queued jobs with no logs, so raise these before a busy day rather than during one. Assume `$CON` concurrently running jobs, for example 1000. | Resource | Additional quota per stack | Notes | | --------------------------------------- | -------------------------- | ----------------------------------------------------------------------- | | CPU | $CON times vCPU per job | Adjust for machine type and for preemptible against on-demand instances | | Persistent Disks | $CON times DISK\_TB | With Hyperdisk types, also raise provisioned IOPS and throughput quotas | | In-use regional external IPv4 addresses | 3 plus $CON | One static IP for Cloud NAT per stack, one per concurrently running job | | Cloud Storage | 1 | One bucket for artifact cache, container layer caches, and telemetry | | Cloud NAT | 3 | One per stack | | Networks | 1 | One VPC | | Subnetworks | 2 | One public and one private subnetwork per stack | Every one of these is regional. Change the region selector in the GCP console quota page to the stack region before editing, or the increase lands somewhere the runners never boot. ## [Configuration](#configuration)Copy Anchor Link ### [Point a workflow at the runner](#point-a-workflow-at-the-runner)Copy Anchor Link The Runner ID from the custom runners page is the label. A mixed workflow keeps Linux jobs on the GCP fleet and sends the jobs GCP BYOC does not serve to WarpBuild-hosted runners in the same file: .github/workflows/ci.yml Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-custom-gcp-use-linux-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test integration-tests: runs-on: warp-custom-gcp-use-linux-16x services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres ports: ["5432:5432"] steps: - uses: actions/checkout@v4 - run: ./scripts/integration-tests.sh arm-image: runs-on: warp-ubuntu-latest-arm64-4x steps: - uses: actions/checkout@v4 - run: docker build --platform linux/arm64 -t app:arm64 . ios-build: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build ``` The two `warp-custom-` labels are runner configurations in your GCP stack. `warp-ubuntu-latest-arm64-4x` and `warp-macos-15-arm64-6x` are hosted labels from the [cloud runner catalog](/docs/ci/cloud-runners). Mixing them in one workflow is normal: the label decides where the job lands, and nothing else in the file changes. ### [Instance types and spot](#instance-types-and-spot)Copy Anchor Link Give each runner configuration more than one machine type, listed in priority order. WarpBuild picks by availability, so a capacity shortfall on the first type falls through to the second instead of leaving the job queued. Keep the types close in size and price, otherwise job duration swings depending on which type won that morning. Spot instances suit short, interruptible jobs and are priced by Google against on-demand rates published on the [Compute Engine pricing page](https://cloud.google.com/compute/all-pricing). Fallback types matter more on spot than on-demand, since spot capacity moves. ### [Disks](#disks)Copy Anchor Link The minimum disk size is 100GB and the recommended starting point is 150GB. Disk type is the setting most worth attention on I/O-heavy pipelines: | Disk type | IOPS | Throughput | Notes | | ------------------ | ---------------- | ------------------ | -------------------------------------------- | | pd-balanced | Managed by GCP | Managed by GCP | Default. Balance of price and performance | | pd-ssd | Managed by GCP | Managed by GCP | Higher baseline than pd-balanced | | pd-standard | Managed by GCP | Managed by GCP | Lowest cost, for jobs that are not I/O bound | | hyperdisk-balanced | 3,000 to 160,000 | 140 to 2,400 MiB/s | Provisioned IOPS and throughput | | hyperdisk-extreme | 300 to 350,000 | Not applicable | Provisioned IOPS only | For `pd-*` types, GCP scales IOPS and throughput with disk size. For `hyperdisk-*` types, you set provisioned IOPS, and throughput on `hyperdisk-balanced`, in the runner creation form. A reasonable starting configuration for large builds, container image operations, and heavy test suites is `hyperdisk-balanced` at 3000 IOPS and 140 MiB/s, then raise it against what the job reports. Hyperdisk carries its own quotas. Provisioned IOPS and provisioned throughput are separate quota lines from disk capacity, and both are regional, so raise them in the stack region before switching a fleet to `hyperdisk-balanced`. Google documents the limits under [Hyperdisk quotas](https://cloud.google.com/compute/docs/disks/hyperdisks#quotas). A runner configuration that asks for more provisioned IOPS than the region grants fails at instance creation, which surfaces as jobs that never start. Instance types with bundled local SSDs, such as the `c3-standard-88-lssd` and `c4a-standard-4-lssd` families, are handled automatically on Linux images. One local SSD is formatted and mounted at the work directory during boot, several are combined into a RAID-0 array first. No setting controls it; the runner detects the NVMe devices at boot. ### [Networking](#networking)Copy Anchor Link Static IPs place runner instances in private subnetworks behind Cloud NAT, which is what an allowlist on a third-party service usually requires. Google bills the data processing and transfer that path adds, so turn it on for the runner configurations that need a stable egress address and leave the rest on public subnetworks, where GitHub Actions traffic patterns keep transfer charges small. Egress costs during deploy-heavy jobs are handled on the enterprise tier; see [zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress). ### [What GCP BYOC does not do](#what-gcp-byoc-does-not-do)Copy Anchor Link Nested virtualization turns on automatically when every machine type in a runner configuration supports it, so Android emulator jobs and similar workloads need no extra flag. Two limits are worth knowing before you plan a migration: * Snapshot runners are not available on BYOC GCP. Workflows that depend on snapshots keep those jobs on WarpBuild-hosted runners. * Windows BYOC runners are available on AWS and on [BYOC runners on Azure for GitHub Actions](/byoc/azure). On GCP, Windows jobs stay on hosted `warp-windows-` labels, as in the mixed workflow above. macOS is hosted only on every cloud. These are the hosted labels a GCP BYOC fleet usually keeps alongside it, with the rate WarpBuild bills for each: | Hosted label | OS | vCPU and RAM | Storage | Per minute | | --------------------------- | ------------------- | -------------- | --------- | ---------- | | warp-macos-26-arm64-6x | macOS 26 | 6 vCPU, 14 GB | 120GB SSD | $0.08 | | warp-macos-15-arm64-6x | macOS 15 | 6 vCPU, 14 GB | 120GB SSD | $0.08 | | warp-macos-15-arm64-12x | macOS 15 | 12 vCPU, 28 GB | 270GB SSD | $0.16 | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 vCPU, 16 GB | 256GB SSD | $0.016 | | warp-windows-2025-x64-8x | Windows Server 2025 | 8 vCPU, 32 GB | 256GB SSD | $0.032 | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 vCPU, 16 GB | 150GB SSD | $0.006 | ## [Who Manages What](#who-manages-what)Copy Anchor Link BYOC splits the work along a clean line: you own the cloud account and everything Google bills, WarpBuild owns the runner lifecycle and everything above it. | Area | Your GCP project | WarpBuild | | ------------------------------------ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | Billing account | Linked by you, pays Google for compute, disk, network, and bucket storage | Bills $0.002 per minute per Linux BYOC runner | | Quotas | Raised by you in the stack region | Documents the required quotas per stack | | VPC, subnetworks, Cloud NAT | Created in your project, owned by you afterwards | Defines the layout and applies it through Deployment Manager | | Cloud Storage bucket | Lives in your project, holds caches and artifacts | Reads and writes through the connection service account | | Runner instances | Capacity, quota, and machine type availability | Creates and terminates one instance per job | | Runner images | Optional custom base images you supply | Maintains and updates the default images | | Runner agent and GitHub registration | Nothing to run | Installs, registers, and deregisters the agent | | Job routing | You set runs-on to the Runner ID | Matches the label to a runner configuration | | Runner service account | You create it and grant roles | Passes it to the instances | | Stack and connection updates | You apply them when prompted | Ships them as pending updates | | Support and compliance | Your project controls and audits | [SOC 2 Type 2](https://trust.warpbuild.com/), plus Slack support channels on demand | ### [What each side charges](#what-each-side-charges)Copy Anchor Link On BYOC the per-minute fee covers the platform and the add-ons come with it: | Line item | Rate | Billed by | | ------------------------------------ | ------------------------------------ | --------- | | Linux BYOC runners | $0.002 per minute | WarpBuild | | Windows BYOC runners (AWS and Azure) | $0.002 per minute | WarpBuild | | BYOC add-ons, including cache | Included | WarpBuild | | Compute Engine instances | Your machine type rate | Google | | Persistent Disk or Hyperdisk | Your disk and provisioned IOPS rates | Google | | Cloud Storage bucket and Cloud NAT | Your storage and transfer rates | Google | The [WarpBuild pricing page](/pricing) carries the full rate card for both models. ### [A worked model at 300,000 Linux minutes a month](#a-worked-model-at-300000-linux-minutes-a-month)Copy Anchor Link Take a team running 300,000 minutes a month of Linux jobs on a 4 vCPU, 16 GB class machine. GitHub list prices below come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing), checked on 2026-08-13. | Option | Rate per minute | Monthly, 300,000 minutes | Cloud compute | | ------------------------------------------ | --------------- | ------------------------ | -------------------------------- | | GitHub-hosted 4-core Linux larger runner | $0.012 | $3,600 | Included | | WarpBuild hosted warp-ubuntu-latest-x64-4x | $0.008 | $2,400 | Included | | WarpBuild BYOC on GCP, Linux | $0.002 | $600 in WarpBuild fees | Billed by Google to your project | The middle row is a like-for-like list price comparison: `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB), a 33 percent lower list price. At 8 vCPU the same arithmetic gives $0.016 against $0.022, a 27 percent lower list price. Both GitHub list prices were checked on 2026-08-13. The third row is a different kind of number and needs care. The $600 is the WarpBuild fee only. Your total on BYOC is that fee plus what Google charges for the instances, the disks, the bucket, and the network, at whatever rates your project pays after committed use discounts and spot. Price the machine type you actually plan to run on Google's [Compute Engine pricing](https://cloud.google.com/compute/all-pricing) and add it to the $600 before comparing rows. Teams with committed use discounts already in place, or with a large spot appetite, are the ones for whom BYOC usually lands lowest. Google publishes machine and Spot VM rates on the [Compute Engine pricing page](https://cloud.google.com/compute/all-pricing). Use those rates with your measured job duration before estimating a GCP fleet's total. Two other BYOC effects belong in the model. Cache add-ons are included in the BYOC fee, and the cache itself sits in your own Cloud Storage bucket, so cache storage moves from a WarpBuild line item to a Google one. And spot instances are configurable per runner, so a fleet split between on-demand instances for merge-queue jobs and spot for pull request jobs changes the Google side of the bill without touching workflow files. ### [Automation and rollout](#automation-and-rollout)Copy Anchor Link Terraform support exists for BYOC on AWS; on GCP, the connection and the stack are created from the dashboard, then managed through the API. A rollout that works: connect the project, create one stack in the region your team already uses, create a single runner configuration at 8 vCPU, and move one noisy repository to it. Watch queue time and job duration for a week in the reports, raise quotas against what you observed, then move the rest of the Linux fleet. Keep macOS and Windows jobs on hosted labels the whole way through. ## [FAQ](#faq)Copy Anchor Link **Does WarpBuild hold standing credentials in my GCP project?** The cloud connection creates a service account in your project. WarpBuild's service account impersonates it to generate short lived tokens, so access is scoped to that service account and ends when you delete the connection. **Can I run macOS or Windows jobs on GCP BYOC?** GCP BYOC runners are Linux Compute Engine instances. MacOS and Windows jobs keep a hosted `warp-` label in the same workflow. Windows BYOC runners are available on AWS and Azure. **Which GCP quotas do I need to raise before the first busy day?** In the stack region, raise CPU, persistent disk, in-use regional external IPv4 addresses, Cloud NAT, networks, and subnetworks. With Hyperdisk volumes, also raise the provisioned IOPS and throughput quotas in that region. **Can I create a GCP stack with Terraform?** Terraform support exists for BYOC on AWS. GCP stacks are created from the WarpBuild dashboard. --- # Disk Types for BYOC Runners on GCP | WarpBuild URL: https://www.warpbuild.com/byoc/gcp/disk-types Description: GCP BYOC runners take five disk types, from pd-standard to hyperdisk-extreme, plus automatic local SSD on -lssd machine types. Tradeoffs and sizing. # Disk Types for BYOC Runners on GCP GCP BYOC runners take five disk types, from pd-standard to hyperdisk-extreme, plus automatic local SSD on -lssd machine types. Tradeoffs and sizing. A GCP BYOC runner configuration attaches one persistent disk to every Compute Engine instance it launches, and the disk type field takes one of five values: `pd-balanced`, `pd-ssd`, `pd-standard`, `hyperdisk-balanced`, and `hyperdisk-extreme`, with `pd-balanced` as the default, 100GB as the minimum size, and 150GB as the documented starting point ([GCP BYOC configuration documentation](/docs/ci/byoc/gcp/config), checked on 2026-08-13). Local SSD sits on a separate axis: it arrives with the machine type rather than with the disk fields, and WarpBuild mounts it at the runner work directory automatically on Linux images. This page lists the five types with the tradeoff documented for each, the machine types that carry bundled local SSD, and a mapping from job shape to the disk that is worth paying for. The stack, service account, and quota work that comes before any of it is on [BYOC runners on GCP](/byoc/gcp). ## [Overview](#overview)Copy Anchor Link Every row below comes from the [GCP BYOC configuration documentation](/docs/ci/byoc/gcp/config), checked on 2026-08-13. | Disk type | IOPS | Throughput | What it is for | | ------------------ | ----------------------------- | ------------------------------- | ----------------------------------------------- | | pd-balanced | Managed by GCP | Managed by GCP | The default. Balance of price and performance | | pd-ssd | Managed by GCP | Managed by GCP | Higher baseline performance than pd-balanced | | pd-standard | Managed by GCP | Managed by GCP | Lowest cost, for jobs that are not I/O bound | | hyperdisk-balanced | 3,000 to 160,000, provisioned | 140 to 2,400 MiB/s, provisioned | Pipelines that need both IOPS and throughput | | hyperdisk-extreme | 300 to 350,000, provisioned | Not applicable | The highest IOPS ceiling, provisioned IOPS only | Two mechanics separate the families, and they change how you size a runner. On the three `pd-*` types, GCP manages IOPS and throughput and scales them with disk size, which Google documents on its [block storage performance page](https://cloud.google.com/compute/docs/disks/performance). A 100GB `pd-balanced` volume and a 500GB `pd-balanced` volume are different performance objects, so the size field is a performance lever on these types even when the job never fills the disk. On the two `hyperdisk-*` types, you set provisioned IOPS directly in the runner creation form, and provisioned throughput as well on `hyperdisk-balanced`. Performance stops tracking capacity and starts tracking what you asked for, which makes the numbers predictable and makes the regional quota a hard gate. `hyperdisk-extreme` has no throughput field at all, so it fits jobs bounded by random read and write operations rather than by sequential transfer. ## [Architecture](#architecture)Copy Anchor Link The disk under a BYOC runner holds one job and then goes away. WarpBuild creates a Compute Engine instance per job in your own project, the runner work directory lives on that instance, and the instance is terminated when the job finishes. Everything a job writes, including the checkout, restored dependency caches, container layers, and build output, lands on the same volume, which is why disk type shows up in job duration on pipelines that never touch the CPU limit. Durable state sits elsewhere. The cache, the container image layer cache, and telemetry go to the Cloud Storage bucket your stack created in your project, and BYOC add-ons including cache are covered by the per-minute fee. BYOC runs on AWS, GCP, and Azure, so the same split applies on the other two clouds with their own storage products underneath. ### [Local SSD comes with the machine type](#local-ssd-comes-with-the-machine-type)Copy Anchor Link A runner configuration also takes one or more Compute Engine machine types in priority order. When those machine types ship bundled local SSDs, such as the `c3-standard-88-lssd` and `c4a-standard-4-lssd` families, WarpBuild detects the local NVMe devices during boot, creates a RAID-0 array when there is more than one, formats with ext4, and mounts the result at the runner work directory. The whole sequence runs in cloud-init and finishes before the job starts, and no field in the runner form switches it on ([local SSD documentation](/docs/ci/features/local-ssd), checked on 2026-08-13). | Runner | Local SSD auto-mount | Persistent disk type field | | -------------------- | ---------------------------------------------- | --------------------------------------------------- | | BYOC on GCP, Linux | Supported on \-lssd machine types | Applies to the attached persistent disk | | BYOC on AWS, Linux | Supported on instance families with local NVMe | Applies to the attached volume | | BYOC on Azure, Linux | Supported on VM sizes with local NVMe | Applies to the attached volume | | Windows runners | Not supported | Not applicable on GCP, where BYOC runners are Linux | | macOS runners | Not supported | Hosted only | Google documents local SSD as ephemeral, with data lost when the instance stops or terminates, which costs nothing here because the runner is discarded after the job anyway. RAID-0 gives capacity and sequential throughput with no redundancy, and for a workspace that lives for one job that is the right trade. On GCP the BYOC guests are Linux, so Windows and macOS jobs keep hosted `warp-` labels in the same workflow file. ## [Configuration](#configuration)Copy Anchor Link Disk type and disk size are set per custom runner in the WarpBuild dashboard, alongside the machine type list and the spot setting. Nothing in the workflow file changes when you edit them, so a disk change is a runner configuration change and a rerun. Three rules keep the configuration stable: * Keep every machine type inside one runner configuration on the same storage shape. WarpBuild picks by availability, so a list that mixes an `-lssd` type with a plain type gives the same workflow local SSD on some runs and the persistent disk on others, and job duration moves with it. * Start `hyperdisk-balanced` at 3,000 IOPS and 140 MiB/s, which is the documented starting configuration, then raise it against observed performance rather than against a guess. * Set the disk size from the workspace the job writes, with 150GB as the floor. On `pd-*` types that size is also buying IOPS. ### [Match the disk type to the job shape](#match-the-disk-type-to-the-job-shape)Copy Anchor Link | Job shape | What it does to the disk | Disk worth paying for | | -------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------ | | Lint, unit tests, short compiles | Small workspace, CPU bound | pd-balanced at 150GB | | Long compile jobs on a tight budget, no heavy file churn | Sequential reads and writes, low queue depth | pd-standard | | Container image builds, layer unpack, registry pushes | Writes and deletes large trees quickly | hyperdisk-balanced from 3,000 IOPS and 140 MiB/s | | Full-history monorepo checkout with fetch-depth: 0 | Hundreds of thousands of small file writes | hyperdisk-balanced, or an \-lssd machine type | | Large dependency cache restore and save | Extract and write bursts at both ends of the job | hyperdisk-balanced, or an \-lssd machine type | | Test suites restoring a database dump per run | Random reads and writes at high queue depth | hyperdisk-extreme, or an \-lssd machine type | The workflow addresses each configuration by its Runner ID, which is the runner name prefixed with `warp-custom-`. Splitting a pipeline across two disk shapes is a label change: .github/workflows/ci.yml Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: lint: runs-on: warp-custom-gcp-use-linux-4x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run lint image: runs-on: warp-custom-gcp-use-hyper-16x steps: - name: Record the volume behind the workspace run: | lsblk df -h "${GITHUB_WORKSPACE}" - uses: actions/checkout@v4 with: fetch-depth: 0 - run: docker build -t app:${{ github.sha }} . ``` The `lint` job stays on the default disk because it never fills one. The `image` job runs on the hyperdisk configuration, and the two commands in the first step confirm which volume the workspace sits on before you trust any before and after comparison. Run them once per new runner configuration, then drop them. ## [Operations](#operations)Copy Anchor Link Hyperdisk carries its own quotas. Provisioned IOPS and provisioned throughput are separate quota lines from disk capacity, both are regional, and Google documents the limits under [Hyperdisk quotas](https://cloud.google.com/compute/docs/disks/hyperdisks#quotas). A runner configuration that asks for more provisioned IOPS than the stack region grants fails at instance creation, which surfaces as jobs that never start rather than as a disk error. Raise the quota in the stack region before you move a fleet, and raise persistent disk capacity alongside it, sized at your peak concurrent jobs multiplied by the disk size per job. Read the change from the job itself. Hold the workflow constant, move one runner configuration to the new disk type, and compare the step durations that touch the filesystem the most: checkout, cache restore, the build step, and cache save. A pipeline that shows no movement across those four steps was never disk bound, and the extra provisioned IOPS is a line on your Google bill for nothing. The billing split is clean. WarpBuild charges $0.002 per minute for BYOC Linux runners whatever disk type they use, and Google bills your project for the disk, the provisioned IOPS, the provisioned throughput, and the instance at your own rates ([WarpBuild pricing page](/pricing), checked on 2026-08-13). Price the exact disk configuration on Google's [Compute Engine pricing page](https://cloud.google.com/compute/all-pricing) before you roll it out fleet-wide. Where the disk work is more than the pipeline justifies, a WarpBuild-hosted label skips it: `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) runs at $0.008 per minute on 150GB SSD storage with no stack, quota, or disk decision at all ([WarpBuild pricing page](/pricing), checked on 2026-08-13). Mixed fleets are normal, and the label in `runs-on` is the whole switch. For the storage question that sits next to this one, [does BYOC on GCP support local SSD](/answers/does-byoc-support-gcp-local-ssd) walks the machine type side in detail, and [runners with local SSD storage](/runners/local-ssd) covers it across all three clouds. ## [FAQ](#faq)Copy Anchor Link **Which disk type should a GCP BYOC runner start on?** `pd-balanced` at 150GB, which is the documented default and the documented recommended starting size. Move a runner configuration to `hyperdisk-balanced` at 3,000 provisioned IOPS and 140 MiB/s of provisioned throughput when the jobs on it are large builds, container image operations, or heavy test suites, then raise those two numbers against what the jobs report. **What is the minimum disk size for a GCP BYOC runner?** 100GB is the documented minimum disk configuration and 150GB is the documented recommended starting point ([GCP BYOC configuration documentation](/docs/ci/byoc/gcp/config)). Size above that against the workspace the job actually writes, since checkout, dependency caches, container layers, and build output all land on the same volume. **Does the disk type field control local SSD?** No. Local SSD arrives with the Compute Engine machine type, so an `-lssd` machine type carries it and the disk type field still governs the persistent disk on the same instance. WarpBuild detects the NVMe devices at boot, stripes several into a RAID-0 array, formats, and mounts them at the runner work directory on Linux images. [Local SSD, defined](/glossary/local-ssd) sets out the difference between instance-attached storage and network-attached disks. --- # Service Accounts for BYOC on GCP | WarpBuild URL: https://www.warpbuild.com/byoc/gcp/service-account Description: BYOC on GCP uses two service accounts: one the cloud connection creates in your project, one you attach to runner VMs. Roles, setup order, and review points. # Service Accounts for BYOC on GCP BYOC on GCP uses two service accounts: one the cloud connection creates in your project, one you attach to runner VMs. Roles, setup order, and review points. A GCP BYOC deployment uses two service accounts with different owners, and telling them apart answers most of the permission questions a review raises. The cloud connection creates one service account in your project that WarpBuild impersonates to create and terminate runner instances, and you create a second one that gets attached to the runner VMs so job steps reach Artifact Registry or Cloud Storage without a key in a GitHub secret. This page covers what each identity is allowed to do, the enablement order that has to land before the connect flow succeeds, how to narrow the roles on the instance account, and the checks a platform team runs afterwards. The click path with screenshots is in the [service account setup guide](/docs/ci/byoc/gcp/service-account), and the account-level view of the deployment is on [BYOC runners on GCP for GitHub Actions](/byoc/gcp). ## [Overview](#overview)Copy Anchor Link BYOC runs on AWS, GCP, and Azure. On GCP, runners are Compute Engine instances in your own project, so every identity involved lives in that project and is visible in its IAM policy. | Connection service account | Instance service account | | | ------------------------------------------------ | --------------------------------------------------- | ---------------------------------------------------------------- | | Created by | The cloud connect flow, in your project | You, with gcloud or the console | | Used by | The WarpBuild control plane, through impersonation | The runner VM, and therefore your workflow steps | | Purpose | Build stacks, create and terminate runner instances | Authenticate job steps to Google APIs | | Configured where | Once, when the project is connected | Per runner, in the Service Account field on the runner edit page | | How many | One per cloud connection | As many as you want, one per workload shape | | What a compromised build step reaches through it | Nothing; the control plane holds it | Exactly the roles you granted, and nothing else | | Removed by | Deleting the cloud connection | Deleting the service account or clearing the field | The split is what lets a deploy runner and a pull request runner hold different Google authority under one stack. Both run on the same VPC, the same bucket, and the same connection; only the attached identity differs. ## [Architecture](#architecture)Copy Anchor Link The connection side is an impersonation chain. WarpBuild's own service account calls the IAM Service Account Credentials API to mint short lived tokens for the service account that lives in your project, and those tokens are what sign the Compute Engine calls that create and terminate instances. No long-lived key is issued, which is why the credentials API has to be enabled before the connect flow starts, and why deleting the connection ends the access path. The instance side never touches WarpBuild. The service account you select is attached to the VM at boot, the metadata server serves tokens for it, and `gcloud`, `docker login` against Artifact Registry, and the Google client libraries pick those tokens up on their own. Nothing has to be stored in a repository secret, and nothing has to be rotated on a schedule. One binding joins the two. WarpBuild's runner-creating service account, shown as `CREATOR_SA` on the BYOC page in the dashboard, needs `roles/iam.serviceAccountUser` on the instance service account before it can pass that identity to a VM. Grant it with `gcloud iam service-accounts add-iam-policy-binding`, which writes the binding onto the service account resource itself. That distinction matters in review: the grant authorizes passing exactly one named identity, and every other service account in the project stays out of reach. ## [Configuration](#configuration)Copy Anchor Link Three prerequisites decide whether the connect flow succeeds, and each failure looks different in the console. | Step | What it unlocks | What fails without it | | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------ | | Link a billing account to the project | Google bills compute, disk, storage, and network to your account | The project cannot hold the resources a stack creates | | Enable the Identity and Access Management (IAM) API | Creation of the connection service account in your project | Connect fails before any resource exists | | Enable the IAM Service Account Credentials API | Short lived tokens minted by impersonating that account | Impersonation fails, so no runner instance is ever created | | Enable Compute Engine, Cloud Storage, Cloud Deployment Manager V2, and Cloud Resource Manager | Stack deployment, the cache bucket, and runner lifecycle | The stack deployment stops at the missing API | | Grant the setup user Security Admin, Storage Admin, Deployment Manager Editor, Compute Admin | Creating the integration, bucket, deployment, and compute resources | The flow stops at whichever resource the missing role covers | Enable every API against the project you plan to connect. The project dropdown in the GCP console is the usual cause of a failed connect, since the API gets enabled on a neighboring project. The full API table and the console links are in the [GCP configuration guide](/docs/ci/byoc/gcp/config). Those four setup roles belong to a human operator. Runner instances never carry them, and they are exercised when the connection and the stack are created, and again when a pending connection or stack update is applied, so teams that keep standing project admin narrow usually hold them on a setup principal and re-grant when an update is waiting. Creating the instance service account is three commands. Copy Text ``` gcloud config set project "${PROJECT_ID}" gcloud iam service-accounts create ci-runner \ --display-name="GitHub Actions runner identity" export SA_EMAIL="ci-runner@${PROJECT_ID}.iam.gserviceaccount.com" # CREATOR_SA is the runner-creating service account shown on the BYOC page gcloud iam service-accounts add-iam-policy-binding "${SA_EMAIL}" \ --member="serviceAccount:${CREATOR_SA}" \ --role="roles/iam.serviceAccountUser" ``` Then grant the account whatever the jobs actually do. The setup guide uses admin roles as an illustration; production fleets should start narrower and widen only when a step fails. | What the job does | Start with | Wider role to avoid by default | | ---------------------------------- | -------------------------------------------------- | ---------------------------------- | | Pull images from Artifact Registry | roles/artifactregistry.reader | roles/artifactregistry.admin | | Push images to Artifact Registry | roles/artifactregistry.writer | roles/artifactregistry.admin | | Read build inputs from one bucket | roles/storage.objectViewer, granted on that bucket | roles/storage.admin on the project | | Write test artifacts to one bucket | roles/storage.objectUser, granted on that bucket | roles/storage.admin on the project | Bucket-scoped grants use `gcloud storage buckets add-iam-policy-binding` rather than `gcloud projects add-iam-policy-binding`, which keeps the identity out of every other bucket in the project. Google documents the full set in the [predefined roles reference](https://cloud.google.com/iam/docs/understanding-roles). Finish by selecting the account in the `Service Account` field on the runner configuration. The field is editable on an existing runner, so a fleet that was created before the identity existed is updated in place rather than rebuilt. ## [Operations](#operations)Copy Anchor Link Verify the attachment on a live instance rather than trusting the form. Open Compute Engine in the GCP console, pick a running runner instance, and read `Service account` under API and identity management; it should match the account you created. The same check from a workflow step is one curl against the metadata server: .github/workflows/verify-identity.yml Copy Text ``` jobs: identity: runs-on: warp-custom-gcp-use-linux-8x steps: - run: | curl -sH 'Metadata-Flavor: Google' \ 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email' ``` Three review habits keep the setup honest over time. Diff the project IAM policy after every stack or connection update, since applying an update can change what the connection holds. Alert on changes to the `roles/iam.serviceAccountUser` binding on each instance service account, because that binding is what decides which identities can be attached to a VM. And split identities by workload before splitting them by team: a pull request runner that only reads images and a deploy runner that writes them are the two accounts most fleets end up with. Costs sit on a separate line from permissions and do not change when you add another identity. WarpBuild charges $0.002 per minute per Linux BYOC runner, and Google bills your own project for the Compute Engine instances, the disks, the bucket, and the network at whatever rates your account already pays ([WarpBuild pricing](/pricing), checked 2026-08-13). On GCP BYOC the Linux sets are the ones this service account is attached to; macOS and Windows jobs stay on hosted `warp-` labels in the same workflow file. For the compliance question that usually follows the IAM one: SOC 2 Type 2, with [trust.warpbuild.com](https://trust.warpbuild.com/) as the linked evidence. ## [FAQ](#faq)Copy Anchor Link **How many service accounts does BYOC on GCP use?** Two, with different owners. The cloud connection creates one in your project and WarpBuild impersonates it to create and terminate runner instances. You create the second one and select it on the runner configuration, and it is what your workflow steps authenticate as. **Do I need to put a service account key in a GitHub secret?** No. The instance service account is attached to the Compute Engine VM, so job steps get tokens from the instance metadata server and `gcloud` picks them up without a JSON key file anywhere in the repository. **Which roles does the person running the connect flow need?** Security Admin, Storage Admin, Deployment Manager Editor, and Compute Admin, granted on the project being connected. They cover creating the connection service account, the Cloud Storage bucket, the Deployment Manager deployment, and the compute resources, and they apply to the setup user rather than to the runners. --- # Requiring IMDSv2 on BYOC Runners | WarpBuild URL: https://www.warpbuild.com/byoc/imdsv2 Description: Require IMDSv2 is a per-runner setting on AWS BYOC custom runners. Where it sits in the runner definition, what it blocks, and how to enforce it fleet wide. # Requiring IMDSv2 on BYOC Runners Require IMDSv2 is a per-runner setting on AWS BYOC custom runners. Where it sits in the runner definition, what it blocks, and how to enforce it fleet wide. ## [Overview](#overview)Copy Anchor Link Require IMDSv2 is a documented field on a WarpBuild BYOC custom runner on AWS: switch it on and the EC2 instances launched for jobs on that runner class answer metadata requests only when the caller has opened a session and carries the token. A new custom runner supports both IMDS v1 and v2 by default, so requiring the session-token version is an explicit change you make per runner class, from the Update Runner page under Runner Specs or in the same section when you first create the runner ([AWS BYOC configuration guide](/docs/ci/byoc/aws/config)). The [security hardening guide](/docs/ci/byoc/aws/security) carries the same toggle as its sixth control and ends its checklist with the line "IMDSv2 required on all runners". Nothing in the workflow file changes: the `runs-on` label stays as it is, and the instances WarpBuild launches in your AWS account come up with the metadata configuration that runner class specifies. The setting sits alongside the rest of the runner definition, and two of those fields decide together how much a metadata read is worth to an attacker. | Runner definition field | Section | Relationship to instance metadata | | -------------------------------- | --------------------------- | ---------------------------------------------------------------------------- | | Require IMDSv2 | Runner Specs | Instances for this class answer metadata only on a token-carrying request | | Instance Profile ARN | Custom runner configuration | Decides whether role credentials sit behind the endpoint at all | | Instance types in priority order | Custom runner configuration | No effect. Every selected type launches with the same metadata configuration | | Spot instances | Custom runner configuration | No effect. Spot and on-demand instances carry the setting the same way | | Disk size, IOPS, throughput | Custom runner configuration | No effect | Because the field lives on the runner class rather than on the stack, one BYOC stack can hold a hardened deploy runner and an unhardened test runner while a rollout is in progress. That is what makes a staged rollout possible without a second stack. Start from the [BYOC on AWS overview](/byoc/aws) if the stack itself does not exist yet. ## [Architecture](#architecture)Copy Anchor Link A BYOC job runs on a machine in your account, and the metadata endpoint on that machine is the shortest path from a build step to AWS credentials. The chain is short: 1. GitHub queues a job for a `warp-custom-` label. 2. The WarpBuild control plane launches an EC2 instance in your AWS account from that runner definition. Instances are named `warp-*` and launch templates `tmpl-warp-*`, per the [resource naming conventions](/docs/ci/byoc/aws/config). 3. Every step in the job runs as a process on that instance, so every step can reach `http://169.254.169.254`. 4. The job finishes and the instance is terminated. Step 3 is the one the setting governs. Under version 1 a plain GET to the credentials path returns JSON containing an access key id, a secret access key, and a session token for whatever role the instance profile carries. Under version 2 the caller has to send a PUT to `/latest/api/token` with a time-to-live header, read the token out of the response, and attach it as a header on every later GET ([AWS instance metadata service documentation](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html), checked on 2026-08-13). That shape is what defeats server-side request forgery. The bug class in question is a build step that can be persuaded to fetch a URL somebody else controls: a test fixture with an injected host, a screenshot or preview renderer, a link checker, a dependency install script. Such a step issues a GET and cannot set request headers, so it fails at the first hop under version 2\. AWS adds two more properties in the same direction. Token requests carrying `X-Forwarded-For` are rejected, which closes the misconfigured-proxy variant, and the token response carries a hop limit that stops it crossing off the instance to a container network or a downstream host. The requirement stops forged requests. It does not stop a step that runs arbitrary code of its own, because that code can open the session itself. Two other controls bound that case: the least-privilege profile described on [instance profiles for BYOC runners](/byoc/aws/instance-profiles), which decides what the credentials reach, and the fresh instance per job, which ends the exposure window when the job ends. ## [Configuration](#configuration)Copy Anchor Link Enforce it one runner class at a time, and verify from AWS rather than from the dashboard. 1. Choose the first class. Pick a runner that carries an Instance Profile ARN and runs a low daily job count, so a mistake costs a few reruns. 2. Open the runner in the WarpBuild dashboard, go to Runner Specs, select Require IMDSv2, and save. New runners can be created with the field already set. 3. Wait for the next job on that class. No fleet restart is involved, since the instance is provisioned when the job is queued. 4. Read the enforced value back from AWS. The stack tags every resource it provisions, so a single query covers the fleet. Copy Text ``` aws ec2 describe-instances \ --filters "Name=tag:warpbuild-managed-by,Values=warpbuild" \ "Name=instance-state-name,Values=running" \ --query "Reservations[].Instances[].{\ id:InstanceId,\ runner:Tags[?Key=='warpbuild-runner-id']|[0].Value,\ tokens:MetadataOptions.HttpTokens,\ hop:MetadataOptions.HttpPutResponseHopLimit}" \ --output table ``` `HttpTokens` reads `required` on an enforced class and `optional` on a class that still answers version 1\. The `warpbuild-managed-by`, `warpbuild-runner-id`, and `warpbuild-runner-labels` tags are applied by the stack to every runner it launches ([AWS BYOC configuration guide](/docs/ci/byoc/aws/config)), which is what makes the query reliable as an audit rather than a spot check. Once the first class is green, keep the query running on a schedule so a new runner created without the field is caught the week it appears: .github/workflows/imds-audit.yml Copy Text ``` name: imds-audit on: schedule: - cron: "0 7 * * 1" workflow_dispatch: permissions: id-token: write contents: read jobs: audit: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/ci-readonly-ec2 aws-region: us-east-1 - name: Fail if any runner instance still accepts tokenless reads run: | optional=$(aws ec2 describe-instances \ --filters "Name=tag:warpbuild-managed-by,Values=warpbuild" \ "Name=instance-state-name,Values=running" \ --query "Reservations[].Instances[?MetadataOptions.HttpTokens=='optional'].InstanceId" \ --output text) if [ -n "$optional" ]; then echo "tokenless metadata still enabled on: $optional" >&2 exit 1 fi ``` The audit job itself runs on WarpBuild-hosted capacity at $0.004 per minute for `warp-ubuntu-latest-x64-2x` ([pricing page](/pricing), checked on 2026-08-13), so the weekly check costs a fraction of a cent and needs no BYOC capacity of its own. ## [Operations](#operations)Copy Anchor Link Run three checks before enforcing the setting across every class, because the blast radius is every tool on the machine that reads metadata. * Count the tokenless callers first. AWS publishes a per-instance CloudWatch metric, `MetadataNoToken`, that counts metadata requests made without a session token. Watch it on the target class through a full week of workflows while version 1 still answers, since that is the only window in which a version 1 caller is visible as a number rather than as a failed job. * Check the container hop. A step that reads metadata from inside a container crosses an extra network hop, and the token response hop limit defaults to 1, so those steps need testing before the switch rather than after. * Check custom images. Agents and CLIs baked into a custom AMI are the callers nobody remembers. Current AWS SDK and AWS CLI releases request a token automatically, and older vendor agents are the risk. A rollout order that survives contact with a real fleet: | Wave | Runner classes | Why this order | Signal to move on | | ---- | --------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------- | | 1 | One low-volume class with an instance profile | Real credentials behind the endpoint, small rerun cost | One day of jobs green, HttpTokens reads required | | 2 | Remaining classes with instance profiles | Largest credential exposure per instance | MetadataNoToken flat at zero on those classes | | 3 | Classes with no instance profile | Descriptive metadata only, so no urgency | Audit workflow green across the fleet | | 4 | Every new runner at creation time | Stops the fleet drifting back | Checklist line ticked on the [security checklist](/byoc/aws/security-checklist) | Rollback is the same field in reverse. Clear Require IMDSv2 on the class and the next job launches an instance that answers both versions again. No CloudFormation change, no stack upgrade, no support ticket. The hardening carries no price change. BYOC Linux runners and BYOC Windows runners both bill $0.002 per runner minute in WarpBuild fees, and AWS bills your own account for the EC2 capacity ([pricing page](/pricing), checked on 2026-08-13). BYOC runs on AWS, GCP, and Azure, so treat Require IMDSv2 as the AWS-specific control in a wider set of metadata endpoints; GCP and Azure expose their own endpoints with their own required headers. The same runner definitions carry Linux x64, Linux ARM64, macOS, and Windows runners, and the metadata question applies to the cloud-hosted Linux and Windows classes in that set. ## [FAQ](#faq)Copy Anchor Link **Does turning on Require IMDSv2 restart my existing runners?** No. The setting applies to the EC2 instances launched for jobs on that runner class, and every GitHub Actions job runs on a freshly provisioned instance that is terminated when the job finishes. Jobs already running keep the metadata configuration they launched with, and the next job queued to that class launches with the session-token requirement in place. **Which runner classes should be hardened first?** The classes that carry an Instance Profile ARN. Those are the instances whose metadata endpoint holds role credentials, so they are the ones a forged request has something to take. A class with no instance profile still answers descriptive metadata such as instance id and region, which is worth closing later in the rollout rather than first. **How do I prove the whole fleet is enforcing it?** Query AWS rather than the dashboard. WarpBuild tags every resource the stack provisions, so a describe-instances call filtered on the runner tags can report `MetadataOptions.HttpTokens` per running instance and fail a scheduled workflow when any instance still reports `optional`. --- # Running BYOC Runners in Several Regions | WarpBuild URL: https://www.warpbuild.com/byoc/multi-region Description: Run BYOC GitHub Actions runners in more than one region with one WarpBuild stack per region, per-region runner labels, in-region caches, and transfer costs. # Running BYOC Runners in Several Regions Run BYOC GitHub Actions runners in more than one region with one WarpBuild stack per region, per-region runner labels, in-region caches, and transfer costs. ## [Overview](#overview)Copy Anchor Link A BYOC fleet spans several regions by creating one WarpBuild stack per region and one or more custom runners inside each stack. Workflows then select a region by selecting a runner label, because every custom runner belongs to exactly one stack and every stack is pinned to one region of your cloud account. BYOC runs on AWS, GCP, and Azure, and the region choice is yours in every case, since the instances run in your account. The [BYOC documentation](/docs/ci/byoc) describes the three setup steps that this page repeats per region: connect the cloud account once, create a stack in a region, then create the custom runners that workflows address. Three properties of a stack are fixed at creation and drive the whole design: the stack name, the object storage location, and the region. There is no rename and no region change afterwards, so a second region is always a second stack rather than an edit to the first one. This page covers what a region owns, how labels map to regions, when a second region is the wrong answer for a residency requirement, and what crossing a region boundary costs. ## [Architecture](#architecture)Copy Anchor Link One cloud connection can serve many stacks. The connection creates the IAM role or service account WarpBuild uses, and each stack it then creates is a group of infrastructure components in a specific region: a VPC, subnets, and an object storage bucket. Adding a region adds a stack and reuses the connection. | Layer | Scope | Fixed after creation | | ------------------------------------------------------ | ---------------------------------------- | ------------------------------------------- | | Cloud connection (IAM role or service account) | One per cloud account | No, permissions can be updated | | Stack (VPC, subnets, object storage bucket) | One per region | Name, bucket location, and region are fixed | | Custom runner (instance types, disk, IP configuration) | One or more per stack | No, the configuration is editable | | Workflow label (warp-custom-) | One per custom runner | Follows the runner name | | Artifact cache, layer cache, logs | The stack bucket, in the stack region | Region follows the stack | | WarpBuild fee | $0.002 per runner minute in every region | Same rate in every region | The bucket is the part people underestimate. It holds the artifact cache, the container image layer cache, runner telemetry, and other workflow artifacts, and the [AWS configuration guide](/docs/ci/byoc/aws/config) requires it to sit in the same region as the stack. Two regions therefore run two cache namespaces, and a job that lands in the second region reads and writes there. Regions are also separate capacity domains. Instance type availability, vCPU quotas, EBS volume capacity, and free IP addresses per subnet are all per region, so a fleet that works in one region can queue in another purely on quota. Fallback instance types are the documented answer to a capacity gap, and they operate within the runner and its region rather than shifting a job across regions. The WarpBuild control plane stays outside your regions in every configuration. It watches the GitHub Actions job queue and calls your cloud API to launch and terminate instances. Adding a region does not add a control plane. ## [Configuration](#configuration)Copy Anchor Link Encode the region in names. The documented convention is to name a stack with the product and region information, for example `twitch-use1` and `alexa-use2`, and the same convention on custom runners makes the workflow label self-describing. | Stack | Cloud region | Custom runner | Workflow label | | ------------- | ------------ | ------------------- | ------------------------------- | | payments-use1 | us-east-1 | linux-x64-8x-use1 | warp-custom-linux-x64-8x-use1 | | payments-euw1 | eu-west-1 | linux-x64-8x-euw1 | warp-custom-linux-x64-8x-euw1 | | payments-euw1 | eu-west-1 | windows-x64-8x-euw1 | warp-custom-windows-x64-8x-euw1 | A workflow then routes jobs by label. The matrix below runs the build in one region and fans the integration suite across both, with the region held next to the label so the mapping stays readable in the run log: .github/workflows/build.yml Copy Text ``` name: build on: pull_request: branches: [main] jobs: build: runs-on: warp-custom-linux-x64-8x-use1 steps: - uses: actions/checkout@v4 - run: make build integration: strategy: fail-fast: false matrix: include: - region: us-east-1 runner: warp-custom-linux-x64-8x-use1 - region: eu-west-1 runner: warp-custom-linux-x64-8x-euw1 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - run: make integration-test REGION=${{ matrix.region }} ``` Each region needs its own network prerequisites. The documented recommendation is three public and three private subnets across availability zones, with at least 250 addresses per subnet for concurrent runners, plus a security group that blocks all inbound traffic and allows outbound traffic to WarpBuild, the GitHub API, and your package registries. Import mode, which places runners in a VPC you already operate, is supported on AWS. Platform coverage is the same in every region you stand up. On BYOC the WarpBuild fee covers Linux runners on AWS, GCP, and Azure and Windows runners on AWS and Azure. ## [Operations](#operations)Copy Anchor Link **Residency comes first, region count second.** A team that has to keep build data in one place is better served by one region than by two. Every extra region is another VPC, another bucket holding source-derived artifacts and logs, and another set of console evidence an auditor reads. US and EU are the regions WarpBuild names publicly for hosted capacity, and on BYOC the placement is enforced by your own cloud account boundary in the region you selected. If the requirement is that build data stays in the EU, run the EU stack alone and keep the US stack out of the fleet; the trade is worked through in [EU data residency for GitHub Actions runners](/enterprise/data-residency-eu) and [US data residency for GitHub Actions runners](/enterprise/data-residency-us). **Crossing a region boundary is billed by your cloud provider.** A runner in one region pulling a container image or a build artifact from a store in another region moves that data across the boundary, and your cloud account pays the transfer. The BYOC documentation puts outbound transfer from a runner with static IPs at roughly $0.45 per GB in data egress rates, adds a per-GB data processing fee at a similar rate, and lists inter-region transfer as a third charge on the same setup ([BYOC documentation](/docs/ci/byoc), checked on 2026-08-13). Runners without static IPs launch in public subnets, where the documentation reports no transfer charge on ingress and usually minimal egress for GitHub Actions workloads. The practical rule is to keep each region self-contained: the stack bucket in the region, as the [AWS configuration guide](/docs/ci/byoc/aws/config) requires, and the container registry a job pulls from in the same region as the runner that pulls it. On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you, whether those runners live in your cloud or ours; see [zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress). **The WarpBuild bill does not change shape.** The fee is $0.002 per BYOC runner minute in every region ([pricing](/pricing), checked on 2026-08-13), and your cloud account bills the compute, storage, and transfer at your own rates. **Run stack updates per region.** WarpBuild occasionally requires changes to cloud connections or stacks to support new features, and they arrive as updates to apply on each stack. Deletion runs in dependency order: custom runners first, then the stack that owns them, then the cloud connection. Two regions means two of those sequences, which is the recurring operational cost of the second region. For the single-region setup this page builds on, start with [BYOC runners on AWS for GitHub Actions](/byoc/aws). For the short version of the question, see [can I run BYOC in more than one region](/answers/can-i-run-byoc-in-more-than-one-region). ## [FAQ](#faq)Copy Anchor Link **Can one custom runner cover two regions?** No. A custom runner belongs to one WarpBuild stack, and a stack is created in one region of your cloud account. Two regions means two stacks, two custom runners, and two workflow labels. **Can I move a stack to a different region later?** No. The stack name, the object storage location, and the region are fixed at creation. The migration path is to create a second stack in the new region, create custom runners inside it, and switch the `runs-on` labels in your workflows. **Does a cache built in one region serve jobs in another?** No. Each stack has its own object storage bucket in its own region, and that bucket holds the artifact cache, the container image layer cache, logs, and other workflow artifacts. A job that moves to a second region starts against that region's cache. --- # Restricting Outbound Traffic from Runners | WarpBuild URL: https://www.warpbuild.com/byoc/outbound-traffic-controls Description: Runners in your own cloud account need five outbound destinations. Narrow the rest with security group rules, subnet ACLs, and an egress proxy in front. # Restricting Outbound Traffic from Runners Runners in your own cloud account need five outbound destinations. Narrow the rest with security group rules, subnet ACLs, and an egress proxy in front. You can restrict outbound traffic from GitHub Actions runners that run in your own cloud account, and on AWS the rules live on the security group that the WarpBuild CloudFormation stack attaches to every runner instance. The constraint is a short allowlist: `api.warpbuild.com`, `github.com`, `api.github.com`, outbound HTTPS for runner telemetry, and the package registries your builds pull from have to stay reachable, and everything else can be dropped ([AWS BYOC security hardening](/docs/ci/byoc/aws/security), checked on 2026-08-13). This page covers the documented destination list, a rule set that narrows everything else across the security group, the subnet ACLs, and an egress proxy, the intra-subnet deny that pairs with it, and the failure signature each blocked destination produces. Start from the [BYOC on AWS overview](/byoc/aws) if the stack does not exist yet. ## [Overview](#overview)Copy Anchor Link The stack ships open on the way out and mostly closed on the way in, which is the opposite of where a hardened runner subnet ends up. | Stack resource | Default configuration | | -------------- | --------------------------------------------------------------------------------------------- | | Security group | All egress allowed to 0.0.0.0/0; ingress allowed from VPC subnet CIDRs only | | Network ACLs | Default VPC ACLs, which allow all traffic | | VPC endpoints | S3 as a gateway endpoint, ECR API and ECR Docker as interface endpoints | | Subnets | Public subnets, with optional private subnets behind NAT gateways for static egress addresses | Rows come from the [AWS BYOC security hardening guide](/docs/ci/byoc/aws/security), checked on 2026-08-13. Five destinations survive the edit. The third column is the part that decides how much work the change is, because a security group matches addresses and the documentation names most of these by hostname. | Destination | Why the runner needs it | Expressible as an address range | | ------------------------------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | api.warpbuild.com | Runner registration and lifecycle against the WarpBuild control plane | Documented by hostname, so route it through a proxy or a resolved list you refresh | | github.com | Git operations, including actions/checkout | Yes, from the [GitHub REST meta endpoint](https://docs.github.com/en/rest/meta/meta) | | api.github.com | GitHub Actions API calls the runner makes during a job | Yes, from the same endpoint | | WarpBuild S3 over HTTPS | Runner telemetry and system logs | Partly, through the AWS managed S3 prefix list for the region | | Your package registries and deploy targets | Dependency installs, image pulls from outside AWS, deploy steps | Rarely, most registries publish no stable ranges | Two traffic classes never touch that allowlist. Cache reads and writes go to the stack S3 bucket through the gateway endpoint, and image pulls go through the ECR interface endpoints, so both stay inside the AWS network and need endpoint rules rather than internet rules. ## [Architecture](#architecture)Copy Anchor Link Three enforcement points sit between a build step and the internet, and each one can express something the others cannot. | Control | Scope | What it expresses | Its job here | | ----------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | | Security group | The network interface on each runner instance | Allow rules only, by port and by CIDR, prefix list, or peer security group. Stateful, so return traffic needs no rule | The destination allowlist | | Network ACL | The subnet the runners launch into | Numbered allow and deny rules evaluated in order. Stateless, so return traffic needs its own rule | The deny of instance-to-instance traffic | | Egress proxy or name-aware firewall | A separate subnet you route through | Hostname policy, which neither of the above can match on | Registries and any hostname without stable addresses | The intra-subnet deny is the control most teams skip, and it is the one that closes lateral movement between two jobs running side by side. Runner instances have no reason to talk to each other. The hardening guide gives two ways to enforce that: network ACLs on the runner subnets that deny traffic between instances in the same subnet while still admitting traffic to VPC endpoints and NAT gateways, or a security group whose ingress admits only the VPC endpoint interfaces instead of the full subnet CIDR. Each GitHub Actions job already runs on a distinct, freshly provisioned instance that is terminated when the job completes, so the rule protects the live window rather than any state left behind. BYOC runs on AWS, GCP, and Azure. The rule-by-rule guidance WarpBuild publishes is written against the AWS stack, so on GCP the same shape becomes VPC firewall rules with a deny on the runner tag, and on Azure it becomes network security group rules with the equivalent priority ordering. One case does not need wider egress at all. If the reason for opening a destination is a private service, the [networking addon](/docs/ci/features/networking) joins the runner to your Tailscale tailnet at job start with an ephemeral node that is removed when the job ends, which keeps the security group unchanged and moves the access decision into your tailnet ACL policy. ## [Configuration](#configuration)Copy Anchor Link Start from the documented import-mode shape, which is an empty inbound list and a single allow-all outbound rule, then replace that one outbound rule with the destination set. This is the narrowed version: Copy Text ``` { "InboundRules": [], "OutboundRules": [ { "IpProtocol": "tcp", "FromPort": 443, "ToPort": 443, "DestinationPrefixListId": "pl-0github-meta", "Description": "github.com and api.github.com ranges, refreshed from the REST meta endpoint" }, { "IpProtocol": "tcp", "FromPort": 443, "ToPort": 443, "DestinationPrefixListId": "pl-63a5400a", "Description": "AWS managed S3 prefix list for the region: stack cache bucket and runner telemetry" }, { "IpProtocol": "tcp", "FromPort": 443, "ToPort": 443, "DestinationSecurityGroupId": "sg-0vpc-endpoints", "Description": "ECR API and ECR Docker interface endpoints" }, { "IpProtocol": "tcp", "FromPort": 3128, "ToPort": 3128, "Destination": "10.20.4.0/24", "Description": "Egress proxy: api.warpbuild.com and package registry hostnames" } ] } ``` Three details in that rule set matter more than the rest. The endpoint rule points at the security group on the interface endpoints instead of a CIDR, so it keeps working when the endpoint addresses change and it does not open a path to peer instances that happen to share the subnet range. The proxy rule is where every hostname-only destination lands, including the control plane, which keeps the hostname policy in one editable place. And port 443 is the only internet-facing port on the list, so a build step that tries to reach anything on another port fails immediately rather than quietly succeeding. The subnet ACL carries the intra-subnet deny. Rule numbers decide the outcome here, because evaluation stops at the first match and the endpoint allows have to sit above the deny. | Direction | Rule | Protocol and port | Source or destination | Action | | --------- | ---- | ----------------- | ------------------------------------------ | ------ | | Outbound | 100 | TCP 443 | Interface endpoint addresses, one /32 each | Allow | | Outbound | 110 | All | The runner subnet CIDR | Deny | | Outbound | 120 | TCP 443 | 0.0.0.0/0 | Allow | | Inbound | 100 | TCP 1024-65535 | Interface endpoint addresses, one /32 each | Allow | | Inbound | 110 | All | The runner subnet CIDR | Deny | | Inbound | 120 | TCP 1024-65535 | 0.0.0.0/0 | Allow | Rules 120 in both directions carry return traffic, since a network ACL is stateless and the ephemeral port range has to be admitted explicitly. Rule 110 is the lateral movement block. Keep the endpoint addresses at rule 100 in sync with the endpoints the stack provisions, or a stack change will take image pulls down with it. ## [Operations](#operations)Copy Anchor Link Roll the change out in three passes rather than one. Apply the empty inbound list first and run a full workflow, since that step changes nothing about how a job reaches the outside. Then move one runner set onto the narrowed security group while the rest of the fleet stays on the permissive one, and run the noisiest pipeline you have against it, ideally one that pulls container images and installs packages from more than one registry. Then move the remaining sets and add the subnet ACL rules last, because ACL ordering mistakes fail loudly and across every instance in the subnet at once. When something does break, the symptom identifies the rule. | Rule you narrowed | Symptom in GitHub Actions | Check that confirms it | | --------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | GitHub ranges prefix list | The job picks up a runner, then actions/checkout fails on a connection timeout | Compare the prefix list entries against a fresh pull of the meta endpoint | | Proxy route for the control plane | An instance launches under the warp- name pattern and the job stays queued with an empty log | Look for a warp- instance in the stack region that never registered a runner | | S3 prefix list | Jobs pass and runner system logs stop arriving | Run a job and check whether telemetry lands for it | | Endpoint security group reference | Image pulls fail at the login or pull step while git traffic is healthy | Confirm the endpoint security group admits 443 from the runner group | | Subnet ACL rule numbers | Everything in that subnet fails at once, including endpoint traffic | Read the rule order and confirm the endpoint allows sit below the deny | | Proxy route for registries | One install step fails on the package manager's own connect or DNS timeout | Reach the registry through the proxy address from a debug job | The GitHub ranges move, so an address-based allowlist is a job rather than a one-time edit. This workflow refreshes the managed prefix list weekly on a WarpBuild hosted runner and needs no long-lived AWS keys: Copy Text ``` name: refresh-github-ranges on: schedule: - cron: "0 6 * * 1" workflow_dispatch: jobs: refresh: runs-on: warp-ubuntu-latest-x64-2x permissions: id-token: write contents: read env: PREFIX_LIST_ID: pl-0github-meta steps: - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/prefix-list-writer aws-region: us-east-1 - name: Build the entry set from the meta endpoint run: | curl -sS https://api.github.com/meta \ | jq '[.actions[], .api[], .git[]] | unique | map(select(test(":") | not)) | map({Cidr: ., Description: "github meta"})' > entries.json - name: Apply the entries to the prefix list run: | VERSION=$(aws ec2 describe-managed-prefix-lists \ --prefix-list-ids "$PREFIX_LIST_ID" \ --query 'PrefixLists[0].Version' --output text) aws ec2 modify-managed-prefix-list \ --prefix-list-id "$PREFIX_LIST_ID" \ --current-version "$VERSION" \ --add-entries file://entries.json ``` Two operational limits are worth checking before that job runs in anger. A managed prefix list has a fixed maximum entry count set at creation, and the meta endpoint returns more ranges than a small list holds, so size it from a real pull. And entries removed upstream stay in the list until you remove them, so a full reconcile beats an append-only refresh once the list has been alive for a few months. None of this changes what the runners cost or what your workflows say. BYOC Linux and Windows runners carry a WarpBuild fee of $0.002 per minute and your own cloud account bills the compute ([pricing page](/pricing), checked on 2026-08-13). Every cost number here carries a source link and a checked-on date, and the same numbers appear on the [pricing page](/pricing). The one line item that does move is on your AWS bill: routing runners through private subnets or an egress proxy adds gateway hours and data processing charges at your account's rates. A hardened stack registers the same labels a permissive one does, so `runs-on` stays as it was. For the vendor half of a security review, the attestation is SOC 2 Type 2, with [trust.warpbuild.com](https://trust.warpbuild.com/) as the linked evidence. The remaining controls, from IMDSv2 to instance profile scope, are collected in [the full BYOC on AWS security checklist](/byoc/aws/security-checklist), the prerequisites the rules assume are in [networking requirements for BYOC on AWS](/byoc/aws/networking), the short version of this question is answered in [can I restrict outbound traffic from BYOC runners](/answers/can-i-restrict-outbound-traffic-from-byoc-runners), and the object the rules live on is defined in [security group](/glossary/security-group). ## [FAQ](#faq)Copy Anchor Link **Should I block inbound traffic before I narrow outbound rules?** Yes. An empty inbound rule set is the documented starting posture, and jobs keep running under it, because the runner agent dials out and holds that connection open for work. Narrowing egress is the step that can stall jobs, so land the inbound change first, confirm a full workflow passes, then replace the single allow-all egress rule one destination at a time. **Do network ACLs replace the security group egress rules?** No, they cover a different scope. The security group attaches to each runner instance and holds allow rules only, so it carries the destination allowlist. A network ACL attaches to the subnet, evaluates numbered allow and deny rules in order, and is stateless, which is what lets it deny instance-to-instance traffic that a security group cannot express. Most hardened stacks use both. **Does narrowing egress change my workflow files?** No. The rules sit on the security group and the subnet in your own AWS account, and the runner set keeps the same `runs-on` label it had before, so nothing in the workflow file changes. What changes is the failure mode: a step that reaches a destination you removed now fails on a connection timeout where it used to succeed. --- # The BYOC Runner Lifecycle for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/byoc/runner-lifecycle Description: A BYOC runner is one VM created in your cloud account when a GitHub Actions job is queued and terminated when it ends. Stages, ownership, and cost. # The BYOC Runner Lifecycle for GitHub Actions A BYOC runner is one VM created in your cloud account when a GitHub Actions job is queued and terminated when it ends. Stages, ownership, and cost. ## [Overview](#overview)Copy Anchor Link A BYOC runner is a single virtual machine that WarpBuild creates in your own cloud account when a GitHub Actions job is queued for its label, and terminates when that job finishes. The decision to create and destroy it belongs to the WarpBuild control plane; the machine, the disk, and every byte of build data belong to your account. That one-job-one-VM rule is what the rest of this page unpacks: which component performs each step, what exists at each stage, and what survives termination. BYOC runs on AWS, GCP, and Azure. The stage names below use AWS vocabulary because the [BYOC AWS architecture reference](/docs/ci/byoc/aws/architecture) documents the resource graph in that form, and the sequence is the same on the other two clouds. The full cycle, from a queued job to an empty account: 1. A workflow job is queued with a `runs-on` label matching one of your custom runners. Custom runners are addressed by Runner ID, which is the runner name prefixed with `warp-custom-`. 2. The control plane matches the label to that runner configuration: instance types in priority order, disk size and throughput, subnet placement, optional instance profile, spot or on-demand. 3. The control plane assumes the role you granted at connection time and calls your cloud API to launch the instance, either fresh or by powering on a pooled standby VM. 4. The VM boots inside the stack VPC with the stack security group attached. Local NVMe volumes, where the chosen instance type has them, are detected and mounted to the work directory at boot with no configuration. 5. The runner agent registers as a just-in-time runner and holds an outbound long poll. GitHub assigns the job to it. 6. The job runs. Cache reads and writes and runner system logs go to the object storage bucket in your stack. 7. The job completes. The control plane terminates the instance, and the encrypted volume is destroyed with it. ## [Architecture](#architecture)Copy Anchor Link The split that makes the lifecycle predictable is that scheduling authority and execution live in different places. The control plane holds no build data and runs no build step. The data plane holds every instance and never decides on its own when to create one. | Responsibility | Owned by | Runs in | | ----------------------------------------------------------------------- | ---------------------------------------- | -------------------------------- | | Watching the GitHub Actions job queue through the GitHub App connection | Control plane | WarpBuild | | Matching a runs-on label to a custom runner configuration | Control plane | WarpBuild | | Deciding instance type, spot or on-demand, and fallback order | Control plane | WarpBuild | | Issuing the launch call against the cloud API | Control plane, through your granted role | API call into your account | | The instance, its encrypted volume, and the job filesystem | Data plane | Your account | | Runner agent registration and the outbound poll to GitHub | Data plane | Your account | | Artifact cache, container layer cache, and runner logs | Data plane | Your stack object storage bucket | | Issuing the terminate call when the job completes | Control plane, through your granted role | API call into your account | | Compute, storage, and data transfer charges | Data plane | Your cloud invoice | Two consequences follow. First, a WarpBuild outage cannot leave build data stranded somewhere you cannot reach, because the data never left your account. Second, the launch authority is exactly the permission set in the connection role, so revoking that role stops new instances from being created without touching anything already running. The role and its policy blocks are covered on the [AWS BYOC hub page](/byoc/aws), and the terms are defined in [control plane](/glossary/control-plane) and [data plane](/glossary/data-plane). Isolation is a property of the same lifecycle. Each runner runs in its own VM, created on demand and destroyed after each build, and each runner has its own encrypted storage volume with the same lifetime. VMs are ephemeral and never reused. WarpBuild does not access or store build secrets; secrets stay in your repository and reach only the runner environment. The details are in the [security documentation](/docs/ci/security). What exists at each stage, and who pays for it: | Stage | What exists in your account | What is billable | | --------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------- | | Job queued | Nothing new | Nothing | | Launch call issued | A launch template and a pending instance request | Nothing yet | | Boot and registration | One instance, one encrypted volume, one just-in-time runner registration | Instance and volume, at your cloud rates | | Job running | The same instance, plus cache and log objects being written | Instance, volume, storage, and the WarpBuild per-minute fee | | Termination | The instance and volume are deleted | Charges for both stop | | After the job | Cache objects and runner logs in the stack bucket | Storage only | ## [Configuration](#configuration)Copy Anchor Link Four runner settings change the shape of the cycle. Everything else in a custom runner configuration affects what the job can do rather than how the machine comes and goes. **Fallback instance types.** A runner configuration can list several instance types in priority order. When capacity for the first type is unavailable in the region, the launch call falls through to the next one, which keeps queued jobs from stalling on a single shape. Pick types with similar performance so that a fallback does not change your build times, for example `m7a.xlarge` and `m7i.xlarge`. **Spot or on-demand.** Spot instances suit short, interruptible jobs. A spot instance can be reclaimed at any time, which ends that job's run, so the trade is capacity price against a retry. The instance type is chosen from availability and price across the types you listed. **Standby disks.** A standby disk is a VM of the runner instance type that is booted once so that networking and machine configuration complete, then shut down without being terminated. When a job arrives, one is powered on and the job starts in about 15 seconds instead of building a machine from scratch. If the pool is empty, a fresh VM is created and the job starts at the normal provisioning time. Spot instances are not supported for standby disks. Sizing guidance is on the [standby disks page](/byoc/standby-disks). **Disk configuration.** Size, throughput, and IOPS are set per runner. The documented minimum is 100 GB at 125 MBps and 3000 IOPS, with 150 GB at 400 MBps and 4000 IOPS recommended. The volume is created at boot and destroyed at termination, so this is a per-job cost rather than a standing one. None of this reaches the workflow file. A job moves onto a BYOC runner by label alone: .github/workflows/ci.yml Copy Text ``` name: ci on: pull_request: branches: [main] jobs: lint: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run lint integration: runs-on: warp-custom-linux-x64-8x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run test:integration ``` The `lint` job runs on WarpBuild-hosted capacity and the `integration` job runs in your account. Both jobs get a fresh VM and both leave nothing behind, so the mixed fleet is a placement choice rather than a behavior change. ## [Operations](#operations)Copy Anchor Link The question that decides most reviews is what remains after a job ends. | Resource | State after the job | | ------------------------------------------------ | ------------------------------------------------------------------- | | Runner instance | Terminated | | Encrypted volume attached to it | Destroyed with the instance | | Workspace, checked-out source, job environment | Destroyed with the volume | | Just-in-time runner registration | Consumed by that one job | | Launch template created for the job | Deleted | | Artifact cache objects in the stack bucket | Kept until your lifecycle policy expires them, 7 days recommended | | Runner system logs in the stack bucket | Kept under the same policy | | Standby pool VMs | Shut down rather than terminated, and refilled by the control plane | | Stack VPC, subnets, security group, and bucket | Unchanged, outliving every job | | Custom runner configuration and cloud connection | Unchanged | Both bucket prefixes grow without bound if you leave them alone, so the retention rule is the one piece of housekeeping the lifecycle asks of you. Every instance carries the `warpbuild-runner-id`, `warpbuild-runner-labels`, and `warpbuild-stack-name` tags, which is how you trace a line in a cost report back to the runner class that produced it, or confirm that nothing tagged `managed-by: warpbuild` is still running after a quiet weekend. Tearing down runs in the reverse order of setup, and the dashboard enforces it: a stack cannot be deleted while custom runner configurations depend on it, and a cloud connection cannot be deleted while stacks depend on it. A full list of the resources involved is on [what WarpBuild creates in your cloud account](/answers/what-does-warpbuild-create-in-my-cloud-account). Billing follows the same boundary. WarpBuild charges $0.002 per BYOC runner minute for Linux runners and $0.002 per minute for Windows runners, measured against job time, and your cloud account bills the instance, the volume, and data transfer at your own rates. The fee does not scale with instance shape: | Shape | WarpBuild-hosted rate per minute | WarpBuild BYOC fee per minute | | --------------------- | -------------------------------- | ----------------------------- | | Linux 2 vCPU, 8 GB | $0.004 | $0.002 | | Linux 8 vCPU, 32 GB | $0.016 | $0.002 | | Linux 32 vCPU, 128 GB | $0.064 | $0.002 | | Windows 8 vCPU, 32 GB | $0.032 | $0.002 | Rates come from the [pricing page](/pricing), checked on 2026-08-13\. The BYOC fee above applies to the Linux and Windows runners you can place in your own account. ## [FAQ](#faq)Copy Anchor Link **What creates and destroys a BYOC runner instance?** The WarpBuild control plane does both, through the IAM role or service account you granted when you connected the cloud account. It watches the GitHub Actions job queue, matches the `runs-on` label to one of your custom runners, calls your cloud API to launch an instance in your VPC, and calls it again to terminate that instance when the job completes. The instance itself only ever exists in your account. **Does anything from my build stay behind after the runner is destroyed?** The instance and its encrypted volume are destroyed with the job, and the workspace, the checked-out source, and the job environment go with them. What persists is what you asked to persist: artifact cache objects and runner system logs in the stack object storage bucket, which the documentation recommends expiring on a 7 day lifecycle policy. WarpBuild does not access or store build secrets. **Is one instance reused for a second job?** No. Each GitHub Actions job runs on its own freshly provisioned VM that is terminated when the job finishes, so no state carries from one job to the next. A standby disk pool changes the boot path rather than that rule: a pooled VM is pre-initialized and shut down, powered on for one job, and terminated at the end of it. --- # Updating and Deleting a BYOC Stack | WarpBuild URL: https://www.warpbuild.com/byoc/stack-updates Description: Apply WarpBuild BYOC stack and cloud connection updates in order, drain in-flight GitHub Actions jobs first, and verify what a delete leaves in your account. # Updating and Deleting a BYOC Stack Apply WarpBuild BYOC stack and cloud connection updates in order, drain in-flight GitHub Actions jobs first, and verify what a delete leaves in your account. ## [Overview](#overview)Copy Anchor Link WarpBuild may require changes to cloud connections or stacks to support new features, and those changes show up in the dashboard as updates to apply ([BYOC documentation](/docs/ci/byoc)). Applying one issues a CloudFormation changeset against the connection or the stack inside your own cloud account, which is how a stack picks up a newer template version while keeping its runners, its bucket, and its history. Three stack properties are fixed at creation: the stack name, the object storage bucket, and the region ([AWS BYOC configuration guide](/docs/ci/byoc/aws/config)). A change to any of the three is a delete and a rebuild, and deletes run in dependency order: custom runner configurations first, then the stack, then the cloud connection. | What you want to change | Path | | ----------------------------------------------------------------- | ---------------------------------------------------------------------- | | Pick up a newer CloudFormation template version | Apply the stack update from the dashboard | | Grant permissions a new WarpBuild feature needs | Apply the cloud connection update | | Instance types, disk size, spot setting, IMDSv2, instance profile | Edit the custom runner configuration; the stack is untouched | | Rules on a security group you supplied in import mode | Edit it in your own account; the stack attaches the group you selected | | Stack name | Delete and rebuild | | Object storage bucket | Delete and rebuild | | Region | Build a second stack in the new region | The last row is the one that surprises teams mid-migration. Region is a stack property, so a move to a closer region is a new stack with its own bucket and its own runner configurations, which is also how a fleet ends up spanning [several regions](/byoc/multi-region) on purpose. BYOC runs on AWS, GCP, and Azure. The dependency rules above come from the shared BYOC documentation and hold on all three clouds. The naming, tagging, and template version details below are the AWS ones, so on GCP and Azure check the equivalent instances, disks, images, and storage in the project or subscription you connected. ## [Architecture](#architecture)Copy Anchor Link A connected AWS account carries two CloudFormation stacks, and they version separately. The connection stack creates the IAM role `warpbuild-`, whose permissions are grouped into named policy contexts: `FineGrainedEC2Permissions`, `SpotServiceLinkedRolePermissions`, `NetworkPermissions`, `StoragePermissions`, and `CloudFormationPermissions`. The last context is the one that lets WarpBuild raise changeset requests against connection and stack upgrades in create mode. Updates to this role are required when a new feature needs a permission the role does not hold yet. The WarpBuild stack holds the region, the VPC and subnets, the security group attached to every runner instance, and the object storage bucket used for the artifact cache, the container layer cache, and runner telemetry. Its template version is what a stack update moves. | Template version | Documented change | | ---------------- | ------------------------------------------------------------------------------------------- | | Before 1.3 | Runner instances are tagged, stack resources in create mode are not | | 1.3 | Stack resources in create mode carry the WarpBuild tag set; runners are tagged in all cases | | Before 1.4 | Public subnet runners may hit ECR authentication failures, seen as ecr login timeouts | | 1.4 | VPC endpoints are configured for ECR operations from both public and private subnet runners | Those two rows are the common reasons to apply an update rather than to rebuild. A registry login that times out only on public subnet runners is a template version symptom, and the documented fix is upgrading the stack to template v1.4 from the dashboard ([AWS BYOC configuration guide](/docs/ci/byoc/aws/config)). Create mode and import mode differ in what the changeset can reach. In create mode CloudFormation provisioned the VPC, the subnets, and the security group, so they move with the template. In import mode you supplied those objects and they stay under your control, so the update changes the WarpBuild stack record and the resources WarpBuild created inside it. ## [Configuration](#configuration)Copy Anchor Link ### [Applying an update](#applying-an-update)Copy Anchor Link 1. Read what the dashboard is offering, and check whether it targets the cloud connection or the stack. A connection update usually means new IAM permissions; a stack update usually means a new template version. 2. Pick a window against your busiest runner set rather than against wall clock time. A nightly batch runner set and a pull request runner set have different quiet hours. 3. Drain the runner sets you care about, using the sequence in the next section. 4. Apply the update. The changeset runs against the connection or the stack in your account under the `warpbuild-` role. 5. Re-check any permission customization you made. The two documented customization approaches are editing the CloudFormation template on the AWS redirect page before applying it, and editing the role after it is created, so read the role once the changeset completes and confirm your edits are still what you intend. 6. Run one workflow per affected runner set and watch the `Set up job` step complete. ### [Deleting in dependency order](#deleting-in-dependency-order)Copy Anchor Link WarpBuild enforces the order, so the dashboard tells you what is blocking before it refuses a delete. 1. Delete the custom runner configurations that reference the stack. Nothing on the WarpBuild side blocks this step, which is why the drain belongs here. 2. Delete the stack. The confirmation lists the custom runner configurations that depend on it, and the stack cannot be deleted until those are gone. 3. Delete the cloud connection if you are disconnecting the account. The confirmation lists the stacks that depend on it, and the connection cannot be deleted until those are gone. Step 3 is optional and usually skipped. Keep the connection when another stack still runs on it, or when you are rebuilding in a new region under the same account. The [answer on deleting a BYOC stack](/answers/how-do-i-delete-a-byoc-stack) walks the same chain from the delete side. ## [Operations](#operations)Copy Anchor Link ### [Draining in-flight jobs](#draining-in-flight-jobs)Copy Anchor Link Each GitHub Actions job runs on a distinct, freshly provisioned instance that is terminated when the job completes, with no shared state between jobs ([BYOC security hardening](/docs/ci/byoc/aws/security)). There is no long-lived agent to unregister, so the drain window is the duration of the longest job on that runner set, and the fleet turns over on its own once new work stops arriving. The creation side of that cycle is covered in [how BYOC runners are created and destroyed](/byoc/runner-lifecycle). Repoint the labels you want to protect for the length of the window: .github/workflows/test.yml Copy Text ``` jobs: unit: # During the update window, swap the BYOC label for a hosted one: # runs-on: warp-custom-linux-x64-8x runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test ``` Then confirm the runner set is empty before you apply: Copy Text ``` gh run list --status in_progress --limit 100 \ --json databaseId,workflowName,createdAt ``` Two details decide whether a drain is clean. Scheduled workflows on release branches can still carry an old label days after main is updated, so search the organization for `warp-custom-` rather than reading the default branch alone. And a job that queues against a label with no runner behind it waits rather than failing, until GitHub cancels it at 24 hours ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). ### [Verifying after an update](#verifying-after-an-update)Copy Anchor Link Run one workflow per affected runner set. On public subnet runners, watch the registry login step, since that is the step the v1.4 endpoint change addresses. Confirm the dashboard no longer lists a pending update for the connection or the stack. ### [Verifying after a delete](#verifying-after-a-delete)Copy Anchor Link The dashboard stops listing the objects once they are deleted, so verify against your own account. Filter by tag where the console allows it, since every resource the stack creates carries `warpbuild-stack-id` and `warpbuild-stack-name` alongside `warpbuild-managed-by`, `warpbuild-github-org`, `warpbuild-runner-id`, and `warpbuild-runner-labels`. Copy Text ``` aws ec2 describe-instances \ --filters "Name=tag:warpbuild-stack-id,Values=" \ --query "Reservations[].Instances[].[InstanceId,State.Name]" \ --output table ``` | Resource | How to find it | | ------------------------------------ | ---------------------------------------- | | EC2 runner instances and EBS volumes | Name pattern warp-\* | | Launch templates | Name pattern tmpl-warp-\* | | Object storage bucket | Name pattern warpbuild-\* | | Connection IAM role | warpbuild- | | Custom images and their snapshots | Images built for custom VM image runners | The tag filter and the name patterns are both worth running on an older fleet. Stack resources in create mode are tagged from template version 1.3 onward, so a stack that was never updated past 1.2 leaves resources that the tag query misses and the name pattern finds. That is the same version history the update flow exists to close. Deleting a stack stops the WarpBuild meter for its runners. BYOC carries a WarpBuild fee of $0.002 per runner minute for Linux runners and $0.002 per runner minute for Windows runners ([pricing page](/pricing), checked on 2026-08-13), while your own cloud account bills the compute, storage, and data transfer at your rates. Jobs on hosted `warp-` labels keep running through a BYOC stack update, which is what makes the label swap above a workable drain. The full AWS setup, quota, and cost picture is on the [BYOC runners on AWS page](/byoc/aws). ## [FAQ](#faq)Copy Anchor Link **What does applying a BYOC stack update actually do?** It issues a CloudFormation changeset against the cloud connection or the stack in your own account, which is how a stack picks up a newer template version without being rebuilt. WarpBuild may require these changes to cloud connections or stacks to support new features, and they appear in the dashboard as updates to apply. **Can an update change the stack region or the object storage bucket?** No. The stack name, the object storage bucket, and the region are fixed at creation, so a change to any of the three means deleting the stack and building a new one. Everything else, including template versions and new IAM permissions, arrives through an update. **Do in-flight GitHub Actions jobs need to finish before I apply an update?** Plan for it. Each job runs on a distinct, freshly provisioned instance that is terminated when the job completes, so the drain window is the duration of the longest job on that runner set. Repoint the `runs-on` labels you want to protect, let the running jobs finish, then apply. --- # Standby Disks for BYOC GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/byoc/standby-disks Description: Standby disks keep BYOC GitHub Actions runners warm by holding pre-initialized VMs shut down, so a queued job starts in about 15 seconds. Setup and cost. # Standby Disks for BYOC GitHub Actions Runners Standby disks keep BYOC GitHub Actions runners warm by holding pre-initialized VMs shut down, so a queued job starts in about 15 seconds. Setup and cost. Last verified: 2026-08-13 ## [Overview](#overview)Copy Anchor Link Standby disks keep BYOC GitHub Actions runners warm by holding a pool of pre-initialized VMs of the same instance type as the runner, each booted once and then shut down without being terminated. When GitHub queues a job, WarpBuild powers one of those VMs back on and the job starts in about 15 seconds, per the [standby disks documentation](/docs/ci/byoc/standby-disks). The feature has one configuration value, the pool size per custom runner, and it requires no workflow change. The `runs-on` label stays the same, the job steps stay the same, and the difference shows up in the `Set up job` duration in the GitHub Actions log. Standby disks address the front of the job only: runner allocation and machine boot. Toolchain installs, dependency restores, and first-run compilation are untouched, and those are handled by caching and by the levers covered in the guide to [removing cold starts from GitHub Actions jobs](/guides/github-actions-cold-starts). Four facts decide whether the feature fits your fleet: * WarpBuild does not charge for standby disks. Your cloud account carries the VM initialization time and the disk. * Spot instances are not supported for standby disks. * The pool is per custom runner, so a runner type with 24-way test fan-out and a runner type with one deploy job are sized separately. * An empty pool costs you speed alone. WarpBuild creates a fresh VM for the job, which starts at the normal time. Standby disks are a BYOC feature. BYOC runs on AWS, GCP, and Azure, and the setup path for each cloud is in the [BYOC documentation](/docs/ci/byoc). The AWS specifics, including instance selection and quotas, are on the [AWS BYOC page](/byoc/aws). ## [How It Works](#how-it-works)Copy Anchor Link A standby disk is a VM of the same type as the runner that is immediately shut down after boot up. That boot does real work: it initializes the VM, sets up networking, and applies the other machine configuration a runner needs before it can accept a job. Shutting the instance down puts it into a `shutdown` state without terminating it, so the initialized state survives on the network disk. When GitHub requests a runner, the WarpBuild control plane checks whether a standby disk is available for that custom runner: 1. A disk is available. It is powered on and allocated to the job, and the job starts in about 15 seconds. 2. No disk is available. A new VM is created and allocated to the job, and the job starts at the normal BYOC provisioning time. The control plane maintains the pool in the background, within reconciliation time limits of about a minute. A burst that drains an eight-disk pool is refilled on that timescale, which is why the sizing question is about peak concurrency rather than daily job volume. Jobs arriving during that refill window fall into case 2 and still run. Instance type selection follows the runner configuration exactly. The instance type of a standby disk is the same as the runner type, and where a custom runner lists fallback instance types, the standby disk will be one of those multiple instance types. A runner configured with `m7a.xlarge` and `m7i.xlarge` in priority order can therefore hold a pool of either shape, which is one more reason to choose fallback types with similar performance. ### [Constraints worth reading before you enable it](#constraints-worth-reading-before-you-enable-it)Copy Anchor Link | Constraint | Documented behavior | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Spot instances | Not supported for standby disks. Spot instances may be terminated at any time and it cannot be guaranteed that a standby instance will be available. | | Fallback instance types | The standby disk is one of the instance types selected on the runner, so a mixed fallback list produces a mixed pool. | | Empty pool | A new VM is created for the job. The job runs, and it starts at the normal provisioning time. | | Pool refill | Handled by the WarpBuild control plane within reconciliation time limits of about a minute. | | Instance state | The VM is put into a shutdown state and is never terminated, so the initialized disk persists. | | WarpBuild charge | None. WarpBuild does not charge for standby disks. | The spot constraint is the one that changes plans. Spot capacity and standby pools are mutually exclusive on the same custom runner, so a fleet that wants both ends up splitting runner types: on-demand types with standby pools for the interactive, pull request path, and spot types for long batch jobs where a reclaim and a retry cost less than the discount is worth. AWS states that Spot Instances run at up to a 90 percent discount compared to On-Demand prices ([AWS EC2 Spot Instances](https://aws.amazon.com/ec2/spot/), checked on 2026-08-13), and that trade is worked through in the guide to [spot instances for GitHub Actions runners](/guides/spot-instances-github-actions-runners). ## [Configuration](#configuration)Copy Anchor Link The number of standby disks is configurable per custom runner. Set it in the custom runners view of the WarpBuild dashboard on the runner you want to start quickly, and choose the number of standby disks based on the number of jobs you expect to run concurrently for that runner type. Size the pool from peak concurrency rather than from weekly volume. A repository that runs 2,000 jobs a week but never more than four at once on a given runner type needs four disks, and a repository that runs 300 jobs a week in bursts of 24 needs 24. | Workflow shape on one runner type | Peak concurrent jobs | Pool size | What the overflow gets | | ---------------------------------------------------- | -------------------- | --------- | ----------------------------------------- | | One build job per pull request, 6 open pull requests | 6 | 6 | Nothing overflows | | 8-way sharded test matrix, 3 pull requests in flight | 24 | 24 | Nothing overflows | | 8-way sharded test matrix, sized at 8 | 24 | 8 | 16 jobs start at normal provisioning time | | Nightly batch of 40 jobs, no interactive wait | 40 | 0 | All 40 start at normal provisioning time | The last row is deliberate. A pool that exists for a nightly batch pays for disks all day to save seconds nobody is waiting on, so leave those runner types at zero and spend the pool on the runner types that gate a code review. ### [Referencing the runner in a workflow](#referencing-the-runner-in-a-workflow)Copy Anchor Link A BYOC runner is addressed by its Runner ID, which is the runner name prefixed with `warp-custom-`. Enabling standby disks changes nothing in this file: .github/workflows/test.yml Copy Text ``` name: test on: pull_request: branches: [main] jobs: lint: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run lint unit: runs-on: warp-custom-linux-x64-8x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npx jest --shard=${{ matrix.shard }}/8 ``` The `unit` job fans out to eight concurrent jobs on one custom runner, so that runner wants a pool of eight. The `lint` job runs on WarpBuild-hosted capacity at $0.004 per minute and needs no pool at all, which is the usual mixed-fleet shape: hosted labels for the small jobs, BYOC labels for the jobs that must stay inside your account. Because the workflow file is untouched, the before and after measurement is clean. Hold the workflow constant, change the pool count, and compare the `Set up job` duration across a week of runs on that runner type. ### [Managing the pool without the dashboard](#managing-the-pool-without-the-dashboard)Copy Anchor Link Terraform support exists for BYOC on AWS, which covers BYOC runner images and custom runner sets, so a team that keeps runner definitions in version control can move the count through the same review process as any other infrastructure change. Both paths are described in the [BYOC documentation](/docs/ci/byoc). ## [Cost](#cost)Copy Anchor Link Three documented mechanics set the bill, and only two of them land on anyone's invoice. **WarpBuild does not charge for standby disks.** The WarpBuild fee stays at $0.002 per BYOC runner minute for Linux runners and $0.002 per minute for Windows runners, measured against job time. A pool of 40 disks and a pool of zero produce the same WarpBuild line item. **The initializing VM is billable for about 90 seconds.** Each disk boots once so that the VM, networking, and configuration are ready, after which it is shut down. Your cloud account bills those 90 seconds at your own rate for that instance type. This is largely the boot you would have paid inside the billed job window anyway, moved ahead of the job. **The network disk stays billable while the VM is shut down.** Once the VM is shut down, the network disk is still billable but the VM is not. That is the standing line item: provisioned storage that exists whether or not any job is running. ### [Working the numbers for a pool](#working-the-numbers-for-a-pool)Copy Anchor Link Take a custom runner with a pool of 8, a 150 GB disk per runner, and 400 jobs a week landing on that runner type. Multiply by your own cloud rates, since AWS, GCP, and Azure each price instance-seconds and provisioned storage differently. | Line | Arithmetic | Result | | -------------------------- | --------------------- | --------------------------------- | | Initial pool fill | 8 disks x 90 seconds | 12 instance-minutes, one time | | Steady-state refills | 400 jobs x 90 seconds | 600 instance-minutes per week | | Standing storage | 8 disks x 150 GB | 1,200 GB provisioned continuously | | WarpBuild fee for the pool | 0 | $0.00 | The second row is the one people miss. Every job served from the pool consumes a disk, the control plane replaces it, and that replacement pays its own 90 seconds of initialization. Pool cost therefore tracks job count, and only the storage row tracks pool size. ### [Where the runner minutes themselves land](#where-the-runner-minutes-themselves-land)Copy Anchor Link Standby disks change start time while the per-minute rate stays the same. The rates around them, checked against GitHub's published list prices on 2026-08-13: | Shape | GitHub-hosted list price per minute | WarpBuild-hosted rate per minute | WarpBuild BYOC fee per minute | | --------------------- | -------------------------------------------- | -------------------------------- | ----------------------------- | | Linux 2 vCPU, 8 GB | $0.006 (ubuntu-latest, private repositories) | $0.004 | $0.002 | | Linux 8 vCPU, 32 GB | $0.022 (8-core larger runner) | $0.016 | $0.002 | | Linux 16 vCPU, 64 GB | $0.042 (16-core larger runner) | $0.032 | $0.002 | | Windows 8 vCPU, 32 GB | $0.042 (8-core larger runner) | $0.032 | $0.002 | GitHub rates come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. WarpBuild rates come from the [pricing page](/pricing). The BYOC column is the WarpBuild fee alone; your cloud provider bills the compute, storage, and data transfer separately at your own rates. Run the 400-job week through it. At 6 minutes of job time each, that is 2,400 runner minutes: $52.80 on the GitHub-hosted 8-core larger runner, $38.40 on `warp-ubuntu-latest-x64-8x`, and $4.80 in WarpBuild fees on BYOC plus whatever your cloud bills for the instances. Compare those concrete amounts with the cloud bill for your chosen instances. A standby pool cannot use spot instances, so do not apply a spot discount to this model. A standby pool moves your cloud bill alone and leaves that structure as it is. SSO is available for a flat $250 per month, whatever the user count. On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores during deployments with zero egress cost to you, whether those runners live in your cloud or ours; see [zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress). If you are still choosing between account placement and hosted capacity, start with [WarpBuild-hosted runners compared with BYOC runners](/guides/hosted-vs-byoc-runners). ## [FAQ](#faq)Copy Anchor Link **What is a standby disk on a BYOC runner?** A VM of the same instance type as the runner that is booted once so that networking and the rest of the machine configuration complete, then shut down without being terminated. When GitHub requests a runner, WarpBuild powers a waiting disk back on and the job starts in about 15 seconds instead of building a machine from scratch. **How many standby disks should I configure?** The count is set per custom runner, and the documented guidance is to choose it from the number of jobs you expect to run concurrently on that runner type. A pull request workflow that fans out to eight shards on one runner type wants at least eight, otherwise the first shards start quickly and the rest wait for fresh VMs. **Do standby disks work with spot instances?** No. Spot instances are not supported for standby disks, because a spot instance can be reclaimed at any time and a waiting standby instance cannot then be guaranteed. Run standby disks on on-demand instance types and keep spot for runner types where a slower start is acceptable. **What do standby disks cost?** WarpBuild does not charge for standby disks. Your own cloud account pays for about 90 seconds of VM time while each disk initializes, and for the network disk, which stays billable while the VM is shut down. Nothing else changes on the WarpBuild invoice, which stays at $0.002 per BYOC runner minute. --- # Compare Runner Providers | WarpBuild URL: https://www.warpbuild.com/compare Description: Comparisons of GitHub Actions runner providers, with every fact linked to its source and a visible last-updated date. # Compare Runner Providers How WarpBuild compares with other GitHub Actions runner options, fact by fact with sources. * [WarpBuild vs Actions Runner Controller (ARC)ARC is free Apache-2.0 software you operate in your own Kubernetes cluster. Compare the work that carries against WarpBuild managed runners, with sources.](/compare/arc) * [WarpBuild vs Blacksmith for GitHub ActionsBlacksmith and WarpBuild both run managed GitHub Actions runners. Compare platform coverage, BYOC, data residency, pricing shape, SSO, and tooling.](/compare/blacksmith) * [Comparison changelogEvery factual change to a comparison page, dated.](/compare/changelog) * [WarpBuild vs Depot for GitHub ActionsDepot and WarpBuild compared for GitHub Actions runners: macOS shapes, BYOC clouds, pricing model, SSO, support, and egress, each fact linked and dated.](/compare/depot) * [WarpBuild vs GitHub-Hosted RunnersWarpBuild and GitHub-hosted runners compared on per-minute price, concurrency caps, runner sizes, macOS depth, and BYOC, with every GitHub number linked.](/compare/github-actions) * [WarpBuild vs NamespaceNamespace and WarpBuild both sell managed GitHub Actions runners. Compare bring your own cloud, concurrency caps, macOS depth, pricing structure and support.](/compare/namespace) --- # WarpBuild vs Actions Runner Controller (ARC) | WarpBuild URL: https://www.warpbuild.com/compare/arc Description: ARC is free Apache-2.0 software you operate in your own Kubernetes cluster. Compare the work that carries against WarpBuild managed runners, with sources. # WarpBuild vs Actions Runner Controller (ARC) ARC is free Apache-2.0 software you operate in your own Kubernetes cluster. Compare the work that carries against WarpBuild managed runners, with sources. Last updated: 2026-08-13 [Actions Runner Controller](https://github.com/actions/actions-runner-controller) (ARC) is a Kubernetes operator, published by GitHub under the Apache License 2.0, that runs GitHub Actions runners as pods in a cluster you operate. WarpBuild is a managed runner platform that gives you the same ephemeral autoscaled runners on WarpBuild's cloud or inside your own AWS, GCP, or Azure account, with no cluster, controller, or listener for your team to run. The ARC software costs nothing to license. The cost of a self-hosted runner fleet built on ARC is your cluster bill plus the engineering time in the maintenance inventory below. This page lists that work with a link to the ARC documentation or repository file that establishes each item, shows where WarpBuild is ahead, and states the cases where staying on ARC is the right answer. Arrived with a different question? See [managed alternatives to ARC](/alternatives/arc) for the wider field, or [migrate from ARC to WarpBuild](/migrate/arc-to-warpbuild) for the switching runbook. ## [What ARC Does Well](#what-arc-does-well)Copy Anchor Link **It is free and open source.** ARC ships under the [Apache License 2.0](https://github.com/actions/actions-runner-controller/blob/master/LICENSE). There is no paid tier, no pricing page, and no commercial edition (verified absent on 2026-08-13). A team can read every line of the controller, fork it, and patch it. **GitHub adopted it and supports the modern mode.** GitHub's docs state that the ARC project [was adopted by GitHub to release as a new GitHub product](https://docs.github.com/en/actions/concepts/runners/support-for-arc), and that GitHub supports the latest Autoscaling Runner Sets version. The [README](https://github.com/actions/actions-runner-controller/blob/master/README.md) describes it as developed and maintained in collaboration with the GitHub Actions team, external maintainers, and the community. **Everything runs in your cluster.** The [prerequisites](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/get-started) are a Kubernetes cluster and Helm 3\. There is no vendor control plane in the path, and the only outbound dependency is the runner's own connection to the GitHub Actions Service, which you already depend on by using GitHub Actions. **Scaling is event driven.** In the modern scale-set mode the listener [holds an HTTPS long poll](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller) to the GitHub Actions Service and stays idle until a Job Available message arrives. Runners are registered with just-in-time configuration tokens and are ephemeral, so each job gets a clean pod. **You control the machine shape completely.** The full PodSpec is exposed through the [scale-set chart](https://github.com/actions/actions-runner-controller/blob/master/charts/gha-runner-scale-set/values.yaml): image, resources, securityContext, volumes, node selection, and environment. Anything your cluster can express, an ARC runner can be. **The project has real momentum.** The [repository](https://github.com/actions/actions-runner-controller) carries 6,433 stars and 1,462 forks, created 2020-01-28, read on 2026-08-13\. Those are repository popularity signals rather than adoption counts; ARC publishes no customer list or case studies (verified absent on 2026-08-13). ## [What Running ARC Involves](#what-running-arc-involves)Copy Anchor Link Every row below is a task that ARC's own documentation, chart files, or issue tracker establishes. No hour estimates appear here, because none are published anywhere and inventing them would be dishonest. Price the list with your own staffing numbers. ### [Upgrades](#upgrades)Copy Anchor Link | Task | What the documentation says | Source | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Upgrade the controller and charts | "Because there is no support for upgrading or deleting CRDs with Helm, it is not possible to use Helm to upgrade ARC." The documented procedure is five steps: uninstall every gha-runner-scale-set installation, wait for resource cleanup, uninstall ARC, remove the actions.github.com CRDs when they changed, reinstall. | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets#upgrading-arc) | | Avoid downtime during that upgrade | The documented mitigation is a high availability deployment, which requires "two distinct Kubernetes clusters deployed in separate regions" plus a distinct runner group per scale set. | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets#high-availability-and-automatic-failover) | | Accept arbitrary job placement in that HA pair | "If both runner scale sets are online, jobs assigned to them will be distributed arbitrarily (assignment race). You cannot configure the job assignment algorithm." | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets#high-availability-and-automatic-failover) | | Rehearse upgrades | GitHub recommends "testing the new versions in a staging environment that matches your production environment first". | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets#upgrading-arc) | | Track the release stream | The gha-runner-scale-set chart shipped 5 releases in the 12 months to 2026-08-13, the latest being 0.14.2 on 2026-05-22. | [releases](https://github.com/actions/actions-runner-controller/releases) | | Stay off master | "The master branch is highly unstable, and we cannot guarantee that the charts in the master branch will work at any given time." | [docs](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/troubleshoot) | ### [Runner image maintenance](#runner-image-maintenance)Copy Anchor Link | Task | What the documentation says | Source | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Install your own toolchain | The shipped image "contains the least amount of packages necessary for the container runtime and the runner binaries. To install additional software, you can create your own runner image." | [docs](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller) | | Keep the custom image structurally valid | The base image must run the self-hosted runner application, the binary lives under /home/runner/ and starts through /home/runner/run.sh, Kubernetes mode needs the container hooks under /home/runner/k8s, and the container must be named runner. | [docs](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller) | | Track the base image by hand | "To view the current base image, check the FROM line in the runner image Dockerfile, then search for that tag in the dotnet/dotnet-docker repository." | [docs](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller) | | Rebuild inside the 30-day window | "If you do not perform a software update within 30 days, the GitHub Actions service will not queue jobs to your runner." Ephemeral container runners are the case GitHub names for disabling the auto-updater and rebuilding the image on your own schedule. | [docs](https://docs.github.com/en/actions/reference/runners/self-hosted-runners#runner-software-updates-on-self-hosted-runners) | | Keep up with the runner release cadence | actions/runner shipped 11 stable releases in the 12 months to 2026-08-13, v2.328.0 through v2.336.0\. Pair that with the 30-day rule above to size the rebuild obligation. | [releases](https://github.com/actions/runner/releases) | | Mirror images into a private registry | Copying the controller and runner images into your registry and setting image.repository, imagePullPolicy, and imagePullSecrets is documented, and flagged as possibly outside GitHub Support's scope. | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets) | | Build your own dependency cache | Image caching sits on GitHub's out-of-scope list, and "Ephemeral runner dependency caching" is an open feature request from 2023-07-07 with 33 reactions. | [support scope](https://docs.github.com/en/actions/concepts/runners/support-for-arc), [issue 2726](https://github.com/actions/actions-runner-controller/issues/2726) | ### [Scaling configuration](#scaling-configuration)Copy Anchor Link | Task | What the documentation says | Source | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Change scale bounds | maxRunners and minRunners are static Helm values, so a change is a redeploy. | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets) | | Build time-of-day scaling yourself | "ARC does not support scheduled maximum and minimum configurations. You can use a cron job or any other scheduling solution to update the configuration on a schedule." The feature request has been open since 2024-02-29 with 66 reactions, the highest in the repository. | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets), [issue 3313](https://github.com/actions/actions-runner-controller/issues/3313) | | Pay for warm capacity | minRunners is documented as "the min number of idle runners". Idle runner pods hold cluster nodes continuously. Scaling to zero trades that spend for a cold pod start on the next job. | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets) | | Tune reconcile throughput | flags.runnerMaxConcurrentReconciles defaults to 2. Raising it "may also increase the load on the API server and the external service (e.g. GitHub API)". | [values.yaml](https://github.com/actions/actions-runner-controller/blob/master/charts/gha-runner-scale-set-controller/values.yaml) | | Plan names and runner groups | ARC limits resource names to 63 characters, scale-set names to 45, and namespaces to 63\. Two scale sets can share a name only when they sit in different runner groups. | [troubleshoot](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/troubleshoot), [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets) | | Expect transient over-provisioning | On cancelled runs, "the listener will scale back down to the desired number of runners. In the meantime, you may see extra runners." | [troubleshoot](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/troubleshoot) | ### [Authentication, secrets, and networking](#authentication-secrets-and-networking)Copy Anchor Link | Task | What the documentation says | Source | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Rotate credentials per namespace | The GitHub App or personal access token secret must live in the same namespace as the scale-set installation, and every scale set carries its own githubConfigSecret. | [auth docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/authenticate-to-the-api) | | Keep a classic PAT alive for enterprise runners | "You cannot authenticate using a GitHub App for runners at the enterprise level." Enterprise-level runners require personal access token (classic) authentication, which expires and needs rotation. | [auth docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/authenticate-to-the-api) | | Keep cluster clocks in sync | A 401 Unauthorized when obtaining a GitHub App token can come from NTP drift: "if the environment is more than a few seconds ahead, 401 errors will occur when using GitHub App." | [troubleshoot](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/troubleshoot) | | Inject custom CA certificates twice | "Since the custom certificate authority certificates are not bundled with the controller or runner containers, you must inject them into their respective trust stores." The documented path also assumes a Debian-based runner image. | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets) | | Configure proxies and service meshes | Outbound proxy settings live in proxy.http.url, proxy.https.url, and proxy.noProxy. A sidecar proxy such as Istio has to be configured to allow controller traffic to the Kubernetes API server. | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets), [troubleshoot](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/troubleshoot) | | Provide storage for Kubernetes mode | "To use Kubernetes mode, you must create persistent volumes that the runner pods can claim and use a solution that automatically provisions these volumes on demand." Storage provisioners and PVCs are on GitHub's out-of-scope list, and volume ownership mismatches have a documented failure mode requiring fsGroup or an initContainers workaround. | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets), [support scope](https://docs.github.com/en/actions/concepts/runners/support-for-arc) | ### [Observability and the support boundary](#observability-and-the-support-boundary)Copy Anchor Link | Task | What the documentation says | Source | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | Turn metrics on | Metrics are off by default: with the metrics: object absent or commented out, the metrics flags are applied with empty values and metrics are disabled. | [values.yaml](https://github.com/actions/actions-runner-controller/blob/master/charts/gha-runner-scale-set-controller/values.yaml) | | Build the log pipeline before production | "We recommend ensuring you have implemented a way to collect and retain logs from the controller, listeners, and ephemeral runners before deploying ARC in production workflows." | [get started](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/get-started) | | Handle counter resets in dashboards | "Listener metrics that have the counter type are reset when the listener pod restarts." | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets#enabling-metrics) | | Staff a Kubernetes expert | "To ensure a smooth adoption of Actions Runner Controller, we recommend that organizations have a Kubernetes expert on staff." GitHub's out-of-scope list names container orchestration, networking, policy application, managed Kubernetes providers, storage provisioners, installation tooling other than Helm, and best practices such as metrics servers and image caching. | [support scope](https://docs.github.com/en/actions/concepts/runners/support-for-arc) | | Weigh every customization against that boundary | Three customization sections on the deploy page carry the same warning: the option "may be outside the scope of what GitHub Support can assist with and may cause unexpected behavior when configured incorrectly." | [docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets) | ### [Open operational issues, read on 2026-08-13](#open-operational-issues-read-on-2026-08-13)Copy Anchor Link These are open issues with dates and community signal, offered as evidence that operational work exists. They are not claims that ARC is broken. Counts drift, so treat them as a point-in-time reading. This page applies the same discipline to itself: every factual change to it is logged in the [comparison changelog](/compare/changelog). | Issue | Opened | Signal | | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------- | | [4595](https://github.com/actions/actions-runner-controller/issues/4595) AutoscalingRunnerSet permanently deadlocks in Outdated phase | 2026-08-07 | open, 23 reactions, 22 comments, filed against chart 0.14.2 | | [4470](https://github.com/actions/actions-runner-controller/issues/4470) Increased runner queue wait times and listener restarts after upgrade | 2026-04-23 | open, 32 comments | | [4307](https://github.com/actions/actions-runner-controller/issues/4307) Ephemeral runners stuck when a job is canceled | 2025-11-07 | open, 14 reactions, 27 comments | | [4155](https://github.com/actions/actions-runner-controller/issues/4155) EphemeralRunner and pods left Running after runner OOMKILL | 2025-06-27 | open, 21 reactions, 14 comments | | [3159](https://github.com/actions/actions-runner-controller/issues/3159) docker dind sidecar iptables issue | 2023-12-15 | open, 14 reactions, 27 comments | | [3010](https://github.com/actions/actions-runner-controller/issues/3010) Pods stuck in Terminating state | 2023-10-20 | open, 18 reactions, 22 comments | The [repository issue tracker](https://github.com/actions/actions-runner-controller/issues) carried 289 open issues, 1,432 issues in total, and 103 open pull requests on 2026-08-13. ## [Where WarpBuild Is Ahead](#where-warpbuild-is-ahead)Copy Anchor Link **Runners in your own cloud account without cluster operations.** WarpBuild BYOC runs on [AWS](/byoc/aws), [GCP](/byoc/gcp), and [Azure](/byoc/azure). Setup is three steps in the [BYOC guide](https://www.warpbuild.com/docs/ci/byoc): connect the cloud account through an IAM role or service account, create a stack that pins the region, VPC, and object storage, then define a custom runner with its instance types, disks, and IP configuration. The VMs live in your account and your region. There is no operator, listener, CRD, or Helm release for your team to own, and [a Terraform provider](https://www.warpbuild.com/docs/ci/api-keys/terraform) covers BYOC on AWS. **macOS and Windows, which ARC's documentation does not cover.** The GitHub-maintained ARC runner image is Linux only: its [Dockerfile](https://github.com/actions/runner/blob/main/images/Dockerfile) bases on `mcr.microsoft.com/dotnet/runtime-deps:8.0-noble` and sets `ENV ImageOS=ubuntu24`, and the [publish workflow](https://github.com/actions/runner/blob/main/.github/workflows/docker-publish.yml) builds `linux/amd64` and `linux/arm64` only. ARC documentation does not cover macOS runners, verified by a full-repository search of `actions/actions-runner-controller` plus a sweep of GitHub's ARC documentation pages on 2026-08-13\. For Windows, the only runner documentation in the repository is the [legacy-mode page](https://github.com/actions/actions-runner-controller/blob/master/docs/configuring-windows-runners.md), which states "there isn't a default Windows image so for Windows deployments you will have to build your own image"; the modern-mode request, [issue 1001](https://github.com/actions/actions-runner-controller/issues/1001), has been open since 2021-12-10\. WarpBuild publishes Linux x64, Linux ARM64, macOS, and Windows runners: [macOS 14, 15, and 26](https://www.warpbuild.com/docs/ci/cloud-runners) in 6 vCPU and 12 vCPU sizes, with the macOS 26 image carrying Xcode 27.0 and the iOS, tvOS, watchOS, and visionOS simulator runtimes, and Windows Server 2022 and 2025 images in four sizes each. macOS runners are available on WarpBuild's cloud rather than on BYOC, which the [feature matrix](https://www.warpbuild.com/docs/ci/feature-matrix) states plainly. **Observability that ships turned on, plus a debugger.** On ARC, metrics are disabled until you configure them, logs go to stdout for a pipeline you build, and debugging is `kubectl logs` against labelled pods. WarpBuild ships [runner observability](https://www.warpbuild.com/docs/ci/features/observability) with resource metrics and logs per instance and right-sizing recommendations grouped by repository, workflow, job, and instance type, plus the [Action Debugger](https://www.warpbuild.com/docs/ci/tools/action-debugger) for an SSH session into a paused job. Snapshot runners, remote Docker builders, and an MCP server sit in the same product surface. **Support with a person on the other end.** ARC's community support is the repository issues and discussions, and GitHub's own [support scope page](https://docs.github.com/en/actions/concepts/runners/support-for-arc) names a long list of Kubernetes topics outside it. Slack support channels are available on demand for WarpBuild customers, and SAML 2.0 and OIDC [single sign-on](/docs/sso) costs a flat $250 per month, whatever the user count ([pricing](/pricing)). **A control plane with an audited compliance posture.** WarpBuild is SOC 2 Type 2 certified across Security, Availability, and Confidentiality, with evidence at [trust.warpbuild.com](https://trust.warpbuild.com/). ARC publishes no compliance certification of its own (verified absent on 2026-08-13); certification scope belongs to your cluster and your cloud, which means your team carries the evidence work. **Concurrency without a ceiling you configure.** ARC concurrency is bounded by `maxRunners`, by whatever your cluster can hold, and by GitHub's documented limit of 10,000 self-hosted runners per runner group. Generally available WarpBuild Linux and Windows runners do not have plan-level concurrency caps; macOS capacity and beta runners have separate constraints. **Regions and egress.** WarpBuild names US and EU regions. On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you, and zero egress applies whether your runners live in your cloud or ours. **Runner provisioning speed.** Braintrust reports consistent sub-minute runner provisioning on both ARM64 and x86 WarpBuild runners ([Braintrust case study](/customers/braintrust), checked 2026-08-13). ## [When ARC Is the Right Choice](#when-arc-is-the-right-choice)Copy Anchor Link ARC is the right answer for a real set of teams, and this section exists because pretending otherwise would waste your time. **You already run Kubernetes and staff it.** GitHub states the prerequisite plainly: "we recommend that organizations have a Kubernetes expert on staff". An organization that already has that expert, an existing cluster, an upgrade process, a logging pipeline, and an on-call rotation is absorbing a marginal workload rather than standing up a platform. Most of the inventory above is work your team already does for other workloads. **Policy forbids third-party vendors in your build path.** ARC is Apache-2.0 software that runs entirely inside your cluster with no vendor control plane. If your security policy or your customer contracts rule out a managed provider touching build infrastructure, that constraint decides the question, and WarpBuild's managed control plane does not fit it. **Runners must sit inside a specific network you control end to end.** ARC documents outbound proxy configuration for controller, listener, and runners, custom CA injection into both trust stores, and private container registry support. Those are the knobs an air-gapped or heavily proxied environment needs. **You need hardware or scheduling behavior only your own cluster expresses.** The full PodSpec is yours: resources, securityContext, capabilities, node selection, topology spread, priority classes, and volumes. If your build fleet needs to land on specific nodes next to specific workloads, ARC expresses that directly. **You are on GitHub Enterprise Server 3.9 or later and want autoscaling runners in the same estate.** ARC is documented as supported on [GHES 3.9 and greater](https://docs.github.com/en/actions/concepts/runners/support-for-arc). **Your cluster capacity is already bought and idle.** Where a platform team runs a cluster with headroom, the marginal infrastructure cost of ARC is the runner pods. The honest framing is that ARC moves cost from a vendor invoice to your own cloud bill and your own staff time. Whether that trade is good depends on numbers only you have, which is what the next section is for. ## [Cost Questions for Your Team](#cost-questions-for-your-team)Copy Anchor Link ARC publishes no pricing page, no cost calculator, and no total-cost guidance (verified absent on 2026-08-13). Any hour figure or dollar figure on a vendor page comparing ARC to a managed runner would be invented, so this section gives you questions instead. Each one is grounded in a documented ARC requirement. 1. **Idle capacity.** What do the nodes cost that hold your `minRunners` idle runner pods around the clock, at the sizes your heaviest jobs need? `minRunners` is documented as "the min number of idle runners". 2. **Cluster overhead.** What does the managed control plane cost in your cloud, and do you need a separate cluster given the guidance "We recommend running production workloads in isolation. GitHub Actions workflows are designed to run arbitrary code, and using a shared Kubernetes cluster for production workloads could pose a security risk"? 3. **A second cluster for upgrade days.** If runner availability during upgrades matters, what does the documented high availability configuration cost, given it requires two clusters in separate regions? 4. **Storage.** What do the persistent volumes cost for Kubernetes mode, where "you must create persistent volumes that the runner pods can claim and use a solution that automatically provisions these volumes on demand"? 5. **Logs and metrics.** What does your logging pipeline cost for controller, listener, and runner logs, which GitHub recommends collecting and retaining before production? 6. **Egress and registry.** What does your cloud bill for image pulls, artifact pulls, and NAT processing on the paths your jobs take? ARC publishes nothing about egress cost; the bill comes from whichever cloud hosts your cluster. 7. **Staff time.** How many engineer hours per quarter go to the upgrade, image, scaling, secret, and storage tasks in the inventory above, at your loaded cost per engineer? Use your own tracked numbers, and remember the 5 chart releases and 11 runner releases in the trailing 12 months set the floor on rebuild and upgrade frequency. 8. **The support boundary.** When a runner fleet incident lands outside GitHub Support's published scope, who answers the page, and what is an hour of that outage worth? The WarpBuild side of the arithmetic is short. Pricing is usage based and billed per minute, with no base subscription fee, no platform fee, and no seat fee. Signup includes $10 in free credits. Per-minute rates by runner type are on the [pricing page](/pricing), and BYOC runner minutes are billed at a flat per-minute rate with add-ons included, on top of the cloud bill you already pay in your own account. ## [Feature Comparison](#feature-comparison)Copy Anchor Link __WarpBuild compared with Actions Runner Controller__ | Dimension | WarpBuild | Actions Runner Controller | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Platform coverage | Linux x64, Linux ARM64, macOS, and Windows runners, plus remote Docker builders. | A Kubernetes operator that runs GitHub Actions runners as pods in a cluster the customer operates. The GitHub-maintained runner image is Linux only, based on Ubuntu 24.04, and published for linux/amd64 and linux/arm64\. No Windows image ships for the scale-set mode; the only Windows runner documentation covers the legacy mode and states you have to build your own image, and the modern-mode request (issue 1001) has been open since 2021-12-10.[Source, checked 2026-08-13](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller) | | macOS depth | macOS 14, 15, and 26 images on M4 Pro hardware in 6 vCPU and 12 vCPU sizes. The macOS 26 image ships Xcode 27.0 and the macOS 27 SDKs with iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 simulator runtimes while GitHub's upstream macOS 27 runner image is in beta. A dedicated macOS 27 image follows once that image is released. | No macOS runners. The ARC runner image is Linux only, published for linux/amd64 and linux/arm64, and ARC documentation does not cover macOS runners. Verified by a full-repository search of actions/actions-runner-controller plus a sweep of GitHub's ARC documentation pages on 2026-08-13.[Source, checked 2026-08-13](https://github.com/actions/runner/blob/main/images/Dockerfile) | | Bring your own cloud | BYOC runs on AWS, GCP, and Azure. Runner minutes are $0.002 and add-ons are included. | Everything runs in the customer's own Kubernetes cluster and there is no vendor control plane. Prerequisites are a Kubernetes cluster and Helm 3\. No Terraform provider or module is published in the repository, verified absent on 2026-08-13; the installation surface is Helm charts and raw manifests, and GitHub Support scope excludes installation tooling other than Helm.[Source, checked 2026-08-13](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/get-started) | | Data residency | US and EU regions. | Residency is whatever the customer's own cluster and cloud region provide, and ARC publishes no region list, verified absent on 2026-08-13\. The one documented geographic requirement is the high availability configuration, which needs two distinct Kubernetes clusters deployed in separate regions with a distinct runner group per scale set.[Source, checked 2026-08-13](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets#high-availability-and-automatic-failover) | | Egress cost handling | On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you. Zero egress applies whether your runners live in your cloud or ours. | ARC publishes nothing about egress cost, verified absent on 2026-08-13, and egress is billed by whichever cloud hosts the cluster. The documented network surface is outbound proxy configuration through proxy.http.url, proxy.https.url, and proxy.noProxy, plus mirroring the controller and runner images into a private registry, which the docs flag as possibly outside GitHub Support's scope.[Source, checked 2026-08-13](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets) | | Concurrency | Generally available Linux and Windows runners do not have plan-level concurrency caps. Beta features may have limits, and macOS uses a per-organization quota arranged with support. | Bounded by maxRunners and minRunners in values.yaml, which are static Helm values that need a redeploy to change. With both commented out, ARC scales to the number of jobs assigned to the scale set and down to zero. GitHub caps a runner group at 10,000 self-hosted runners. The practical ceiling is the cluster capacity the customer provisions and pays for; ARC publishes no vendor-side cap.[Source, checked 2026-08-13](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets) | | Pricing model | Usage based, billed per minute. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits. | Free and open-source software under the Apache License 2.0, with no pricing page, no paid tier, and no commercial edition, verified absent on 2026-08-13\. The cost of a deployment is the customer's own self-managed infrastructure and staff time: cluster nodes holding idle minRunners pods, the managed control plane, persistent volumes for Kubernetes mode, egress, a private registry, a log and metric pipeline, and the Kubernetes expert GitHub recommends having on staff.[Source, checked 2026-08-13](https://github.com/actions/actions-runner-controller) | | SSO | SAML 2.0 and OIDC single sign-on for enterprise teams. | ARC provides none, because it has no hosted console and no user accounts. Authentication is machine to machine against the GitHub API using a GitHub App or a personal access token (classic), and enterprise-level runners require a classic personal access token because GitHub App authentication is unavailable there. Vault-backed credentials are public preview with Azure Key Vault only.[Source, checked 2026-08-13](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/authenticate-to-the-api) | | Support | Slack support channels are available on demand. | Community support in the repository, plus GitHub Support for the latest Autoscaling Runner Sets version only; legacy ARC is community-maintained. GitHub recommends organizations have a Kubernetes expert on staff and publishes an out-of-scope list covering container orchestration, networking, Kubernetes policies, managed Kubernetes providers, storage provisioners and PVCs, template spec customization, and installation tooling other than Helm. The repository carried 289 open issues, 1,432 issues in total, and 103 open pull requests on 2026-08-13.[Source, checked 2026-08-13](https://docs.github.com/en/actions/concepts/runners/support-for-arc) | | Observability tooling | CI observability with OpenTelemetry metrics correlated to GitHub Actions job logs, plus the Action Debugger for an SSH session into a running job. | Metrics and logs with no vendor UI, dashboard, or debugger. The controller, listener, and runners write logs to stdout and the operator supplies the collection pipeline, which GitHub recommends having in place before production. Metrics are disabled when the metrics object is absent or commented out, which is the chart default, and listener counter metrics reset when the listener pod restarts. Debugging is kubectl logs against labelled pods.[Source, checked 2026-08-13](https://github.com/actions/actions-runner-controller/blob/master/charts/gha-runner-scale-set-controller/values.yaml) | | API and Terraform | WarpBuild publishes an API for CI configuration and automation. A Terraform provider covers BYOC on AWS. | The configuration surface is Kubernetes CRDs plus Helm values. Modern kinds are AutoscalingRunnerSet, AutoscalingListener, EphemeralRunner, and EphemeralRunnerSet in actions.github.com/v1alpha1, with the legacy actions.summerwind.net group community-maintained. There is no REST API and no Terraform provider, verified absent on 2026-08-13.[Source, checked 2026-08-13](https://github.com/actions/actions-runner-controller/tree/master/charts/gha-runner-scale-set-controller/crds) | | Compliance | SOC 2 Type 2, with evidence at trust.warpbuild.com. | ARC publishes no compliance certification of its own; certification scope belongs to the customer's cluster and cloud. Verified absent across the repository and GitHub's ARC documentation on 2026-08-13\. The nearest published document is the repository security policy. Documented security guidance includes running production workloads in isolation, separate namespaces for runner and operator pods, and a privileged container for Docker-in-Docker mode.[Source, checked 2026-08-13](https://github.com/actions/actions-runner-controller/blob/master/SECURITY.md) | | Shipping cadence | Runner image and platform changes are published monthly in the public docs changelog. | GitHub Releases rather than a changelog page, with no public changelog outside releases, verified absent on 2026-08-13\. The gha-runner-scale-set chart shipped 5 releases in the 12 months to 2026-08-13, the latest being 0.14.2 on 2026-05-22\. The runner binary the images track, actions/runner, shipped 11 stable releases in the same window, which matters because of the 30-day runner-update rule.[Source, checked 2026-08-13](https://github.com/actions/actions-runner-controller/releases) | | Proof | Named customer case studies with before and after numbers, plus public repositories running warp- labels in their GitHub Actions workflows. | 6,433 stars and 1,462 forks, created 2020-01-28, read on 2026-08-13; repository popularity signals rather than adoption counts. Developed and maintained in collaboration with the GitHub Actions team, two named external maintainers, and the community, with a CII Best Practices badge and an Artifact Hub listing. No customer logo wall and no case studies are published, verified absent on 2026-08-13.[Source, checked 2026-08-13](https://github.com/actions/actions-runner-controller) | ## [Migration Path](#migration-path)Copy Anchor Link The workflow change is one line. ARC routes jobs to a scale set through the Helm installation name or through `runnerScaleSetLabels`, so a workflow reads `runs-on: arc-runner-set`. WarpBuild runners register under `warp-` labels for hosted runners and `warp-custom-` labels for BYOC runners, so the same job reads `runs-on: warp-ubuntu-latest-x64-8x` or `runs-on: warp-custom-`. Everything else in the workflow file stays as it is, which makes the change reversible in a single commit while your cluster stays warm. The cluster work is the longer half: draining scale sets by setting `maxRunners` and `minRunners` to zero, uninstalling the scale-set releases before the controller, removing the `actions.github.com` CRDs, and cleaning up the credential secrets, proxy secrets, and custom CA ConfigMaps that the charts left behind. [Migrate from ARC to WarpBuild](/migrate/arc-to-warpbuild) covers the configuration mapping, the parallel-run plan, the rollback path, and the decommissioning order in full. ## FAQ ### Is Actions Runner Controller free? Yes. ARC is published under the Apache License 2.0 and there is no paid tier, no pricing page, and no commercial edition (verified absent on 2026-08-13). The cost of an ARC deployment is your own: cluster nodes, control plane, storage, egress, registry, observability, and the staff time to run all of it. ### Does ARC support macOS runners? The ARC runner image is Linux only. Its Dockerfile base is a .NET runtime-deps image on Ubuntu 24.04 and the image is published for linux/amd64 and linux/arm64 only. ARC documentation does not cover macOS runners, verified across the ARC repository and GitHub's ARC documentation on 2026-08-13\. WarpBuild runs macOS 14, macOS 15, and macOS 26 runners in 6 vCPU and 12 vCPU sizes. ### Can I upgrade ARC with helm upgrade? No. GitHub's documentation states that because Helm has no support for upgrading or deleting CRDs, it is not possible to use Helm to upgrade ARC. The documented procedure is a five-step uninstall and reinstall, and the documented way to avoid downtime during it is a second Kubernetes cluster in another region. ### Can ARC scale runners on a schedule? Not on its own. GitHub's documentation states that ARC does not support scheduled maximum and minimum configurations and suggests a cron job or another scheduling solution to update the configuration. The matching feature request, issue 3313, has been open since 2024-02-29 and carries the highest reaction count in the repository. ### What changes in my workflows when I move from ARC to WarpBuild? The runs-on line. ARC targets a runner scale set by its installation name or by runnerScaleSetLabels; WarpBuild runners register under warp- labels for hosted runners and warp-custom- labels for BYOC runners. The rest of the workflow file is unchanged, which is what makes the move reversible in a single commit. ### Can WarpBuild runners stay inside my own cloud account? Yes. BYOC runs on AWS, GCP, and Azure, with the runner VMs created inside your account and a Terraform provider for BYOC on AWS. There is no Kubernetes cluster, controller, or listener for your team to operate. --- # WarpBuild vs Blacksmith for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/compare/blacksmith Description: Blacksmith and WarpBuild both run managed GitHub Actions runners. Compare platform coverage, BYOC, data residency, pricing shape, SSO, and tooling. # WarpBuild vs Blacksmith for GitHub Actions Blacksmith and WarpBuild both run managed GitHub Actions runners. Compare platform coverage, BYOC, data residency, pricing shape, SSO, and tooling. Last updated: 2026-08-13 Blacksmith and WarpBuild both replace GitHub-hosted runners with managed machines that a workflow selects through a `runs-on` label, and both bill per minute with no base subscription fee. The structural difference is where the compute lives: Blacksmith publishes a vendor-operated fleet procured from data center providers in the US and EU, while WarpBuild runs jobs on WarpBuild-hosted runners or inside your own AWS, GCP, or Azure account. Arrived with a different question? [Blacksmith alternatives](/alternatives/blacksmith) covers the wider option set with cited facts, and [Blacksmith to WarpBuild migration](/migrate/blacksmith-to-warpbuild) has the label diff, the cache notes, and the rollback steps. Every Blacksmith fact on this page links to the Blacksmith page that states it, checked on 2026-08-13\. See the [comparison changelog](/compare/changelog) for what changed since the last verification pass. Recommendations are editorial and are marked as such. ## [What Blacksmith Does Well](#what-blacksmith-does-well)Copy Anchor Link **Adoption is a one-line change with a wizard behind it.** Their [quickstart](https://docs.blacksmith.sh/introduction/quickstart) publishes a literal `runs-on:` diff plus a Migration Wizard that rewrites workflow files, with published mappings from `ubuntu-latest`, `windows-latest`, and `macos-latest` to their equivalent labels. Adoption friction is close to zero. **The free tier is real and permanent.** Their [pricing page](https://www.blacksmith.sh/pricing) prints 3,000 free minutes per month on every pay-as-you-go tier and requires no credit card at signup. Their [runner docs](https://docs.blacksmith.sh/blacksmith-runners/overview) publish the denomination (x64 2vCPU minutes) and the conversion ratios for ARM, Windows, and macOS families, which is more transparency than most vendors give a free tier. **Caching is transparent by default.** Their [dependency cache docs](https://docs.blacksmith.sh/blacksmith-caching/dependencies-actions) state that zero code changes are required: standard `actions/cache` and `setup-*` actions hit storage colocated with the runners, with 25 GB per repository per week free. On top of that they ship sticky disks, Docker layer caching, Docker container and service-image caching, [Bazel build caching](https://docs.blacksmith.sh/blacksmith-caching/bazel-build-caching) with no `.bazelrc` changes, and a beta git checkout cache. That is a deeper caching product surface than most runner vendors publish. **Observability is a genuine strength.** Their docs cover a [GitHub Actions analytics dashboard](https://docs.blacksmith.sh/blacksmith-observability/dashboard) with p99 job duration and cost by repository, [run history](https://docs.blacksmith.sh/blacksmith-observability/history), [cross-run log search with a query syntax](https://docs.blacksmith.sh/blacksmith-observability/logs), [machine metrics with no configuration](https://docs.blacksmith.sh/blacksmith-observability/metrics), [test analytics](https://docs.blacksmith.sh/blacksmith-observability/test-analytics), [Slack monitors with VM retention](https://docs.blacksmith.sh/blacksmith-observability/monitors), and [SSH access into a running job](https://docs.blacksmith.sh/blacksmith-observability/ssh-access) at no additional cost. **Concurrency is parity, and their docs say so plainly.** Their runner docs state that Blacksmith does not impose any concurrency limits on how many vCPUs or jobs run simultaneously. WarpBuild takes the same position: run as many jobs as your workflows need, and generally available Linux and Windows runners do not have plan-level concurrency caps. Neither side wins this dimension. **Compliance is parity too.** Their [security page](https://www.blacksmith.sh/security) states SOC 2 Type 2 and GDPR with quarterly penetration testing, plus a published DPA, a [sub-processor list](https://docs.blacksmith.sh/about/sub-processors) with an RSS feed, a trust center at [trust.oneleet.com](https://trust.oneleet.com/blacksmith-sh), and a public [status page](https://status.blacksmith.sh). WarpBuild holds SOC 2 Type 2 with evidence at [trust.warpbuild.com](https://trust.warpbuild.com/). Treat this row as a tie. **Platform coverage is broad and current.** Their runner docs publish Ubuntu 24.04 and Ubuntu 22.04 on x64 and ARM at five sizes each, Windows Server 2025 (labelled public beta in their own docs), and macOS with two shapes. They also publish nested virtualization on x64 Linux runners and automatic free size upgrades when their fleet has spare capacity. **They write about their systems, including the bad days.** Their engineering blog includes a [public postmortem of a July 2026 outage](https://www.blacksmith.sh/blog/blacksmith-outage-on-july-21-2026) alongside architecture writing. They also publish nine customer stories and roughly 29 named customers on their [customers page](https://www.blacksmith.sh/customers), an OSS program, and a [Series B led by Peak XV Partners announced August 12, 2026](https://www.blacksmith.sh/blog/announcing-blacksmiths-series-b-led-by-peak-xv-partners) claiming more than 6,000 companies running GitHub Actions on the platform. That is a strong product with real momentum. The rest of this page covers the dimensions where the two platforms differ. ## [Where WarpBuild Is Ahead](#where-warpbuild-is-ahead)Copy Anchor Link ### [Runners inside your own cloud account](#runners-inside-your-own-cloud-account)Copy Anchor Link WarpBuild BYOC runs on AWS, GCP, and Azure, with the runner VMs provisioned inside your account and your network. No BYOC, VPC, or customer-account deployment option appears anywhere Blacksmith publishes: not on their [pricing page](https://www.blacksmith.sh/pricing), not on their [security page](https://www.blacksmith.sh/security), and nowhere in their [docs sitemap](https://docs.blacksmith.sh/sitemap.xml) as of 2026-08-13\. Their published architecture is the opposite design by choice: their runner docs describe maintaining their own homogenous pool of compute so that customer jobs avoid competing with other on-demand workloads on hyperscalers. This matters when a job needs to reach a private database, an internal registry, or a service behind your own VPC boundary, and when a security review asks which account the compute runs in. ### [Data residency you can name and select](#data-residency-you-can-name-and-select)Copy Anchor Link WarpBuild names US and EU regions. Blacksmith names three regions in their [Docker container caching docs](https://docs.blacksmith.sh/blacksmith-caching/docker-container-caching): US West, EU West, and EU Central, and their security page states the fleet is procured from data center providers in the US and EU. What their docs do not publish is a way for a customer to choose one. `BLACKSMITH_REGION` appears in their docs only as an environment variable read inside a [sticky disk example](https://docs.blacksmith.sh/blacksmith-caching/dependencies-sticky-disks), and no page documents a region picker for a job or an organization. If your compliance answer requires naming the region your GitHub Actions jobs run in and pinning it, that is a WarpBuild capability today and an open question on their side. ### [Egress cost during deployments](#egress-cost-during-deployments)Copy Anchor Link On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you. Zero egress applies whether your runners live in your cloud or ours. Deploy-heavy workflows that move gigabytes out of object storage on every release are where this shows up on the bill. Blacksmith publishes nothing on the subject in either direction. We found no egress claim, no free-egress statement, and no data-transfer billing statement on their homepage, pricing page, security page, or product docs on 2026-08-13\. This page does not claim they charge for egress. The accurate statement is that they publish no position on it. ### [API surface and Terraform](#api-surface-and-terraform)Copy Anchor Link On the Blacksmith side, [their llms.txt](https://docs.blacksmith.sh/llms.txt) advertises an OpenAPI spec at `https://docs.blacksmith.sh/api-reference/openapi.json`, and that URL returned HTTP 404 on 2026-08-13, as did `/api-reference` and `/api-reference/introduction`. No `api-reference` URL appears in their sitemap. The word Terraform appears in none of their product or legal docs pages. Their published programmatic surfaces are the `blacksmith` CLI for Testboxes (early beta) and their `useblacksmith/*` GitHub Actions. The honest reading is that their API reference is unavailable rather than that no API exists, and that no Terraform provider is published. Platform teams that manage runner configuration as code, or that want GitHub Actions infrastructure in the same Terraform state as the rest of the account, have a path on WarpBuild today. ### [macOS image and Xcode coverage](#macos-image-and-xcode-coverage)Copy Anchor Link Counted on both sides on 2026-08-13: | WarpBuild | Blacksmith | | | --------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------- | | macOS versions | 3 (macOS 14, 15, 26) | 2 (macOS 26 and 15, via 3 selectors) | | Sizes | 6 vCPU and 12 vCPU | 6 vCPU and 12 vCPU | | Published Xcode information | Xcode 27.0 on the macOS 26 image with iOS, tvOS, watchOS, and visionOS simulator runtimes | No Xcode version list or Xcode selector published | Their [runner docs](https://docs.blacksmith.sh/blacksmith-runners/overview) state that their macOS runners follow the corresponding GitHub-hosted macOS runner images, and their smallest macOS runner is 6 vCPU. We checked their full docs sitemap for an Xcode version list, a per-Xcode label, or a beta-Xcode selector and found none. WarpBuild publishes its Xcode versions per image, ships Xcode 27.0 on the macOS 26 image while the upstream GitHub macOS 27 image is in beta, and offers multiple sizes and configurations per chip. For an iOS team that pins a specific Xcode build in GitHub Actions, that difference decides the evaluation. ### [Single sign-on](#single-sign-on)Copy Anchor Link WarpBuild offers SAML 2.0 and OIDC single sign-on for a flat $250 per month, whatever the user count, listed on the [pricing page](/pricing). Blacksmith authenticates through GitHub only. Their settings docs state that Blacksmith currently only supports login using GitHub, their [permissions docs](https://docs.blacksmith.sh/blacksmith-administration/permissions) state that Blacksmith uses GitHub for authentication and inherits your GitHub organization's structure, and their Enterprise tier bullets on the pricing page list no SSO row as of 2026-08-13\. Access derives from GitHub org role and repository permissions, with no independent role model. For a team whose identity provider is the system of record for access reviews, GitHub-only authentication is a real gap. For a team that already governs everything through GitHub org membership, it costs nothing. ### [Published shipping cadence](#published-shipping-cadence)Copy Anchor Link WarpBuild publishes runner image and platform changes every month in the public [docs changelog](/docs/ci/changelog), with an RSS feed. Blacksmith publishes no changelog we could find: `https://www.blacksmith.sh/changelog` returned HTTP 404 on 2026-08-13, no changelog URL appears in either of their sitemaps, and four common product RSS paths all returned 404\. One narrow RSS feed does exist, at [their sub-processor changelog](https://docs.blacksmith.sh/about/sub-processors/rss.xml), and it tracks sub-processor changes rather than product releases. Their [engineering blog](https://www.blacksmith.sh/blog) is the nearest public shipping signal, and it carries a four month gap and a three month gap in the last twelve months. Editorial: a changelog matters to a buyer who needs to know when a runner image changed under a green build that started failing. ### [The shape of the bill](#the-shape-of-the-bill)Copy Anchor Link WarpBuild pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits. Blacksmith also publishes usage-based per-minute pricing with no base subscription, platform, or seat fee, so the difference is meter count rather than a floor. Their [pricing page](https://www.blacksmith.sh/pricing) lists priced add-ons for Docker layer caching, sticky disks, and static IPs, their [support terms](https://docs.blacksmith.sh/about/support-terms) price Premium Support as a formula, and their coding agent product `[code]smith` meters agent compute units on [its own docs page](https://docs.blacksmith.sh/codesmith/overview). A buyer forecasting spend there tracks runner minutes plus up to five further meters. See their pricing page for current rates; this page restates none of them. ### [Debugging and runner tooling](#debugging-and-runner-tooling)Copy Anchor Link WarpBuild ships snapshot runners, remote Docker builders, CI observability, an MCP server, and the [Action Debugger](/docs/ci/tools/action-debugger). Blacksmith's [SSH access](https://docs.blacksmith.sh/blacksmith-observability/ssh-access) and [monitors](https://docs.blacksmith.sh/blacksmith-observability/monitors) cover much of the same live-debugging ground, and their analytics dashboard is strong. Two pieces have no published Blacksmith equivalent as of 2026-08-13: an MCP server, which we could not find anywhere in their 33-URL docs sitemap, and snapshot runners that boot a later job from a captured VM state. ## [When Blacksmith Is the Better Choice](#when-blacksmith-is-the-better-choice)Copy Anchor Link Some real cases favor them, and pretending otherwise wastes your time. **You want a permanent free monthly allowance.** Their 3,000 free minutes per month per organization renew every month with no credit card. WarpBuild grants $10 in credits at signup, which is a one-time grant against usage-based rates. For a small repository with light GitHub Actions usage, their free tier can cover the whole workload. **Your builds depend on a remote Bazel cache with no configuration.** Their [Bazel build caching](https://docs.blacksmith.sh/blacksmith-caching/bazel-build-caching) is transparent, with no `.bazelrc` changes and no workflow configuration. WarpBuild publishes no Bazel remote cache equivalent. If that feature is carrying your build times, moving costs you something real. **You rely on sticky disks for arbitrary persistent paths.** Their [sticky disks](https://docs.blacksmith.sh/blacksmith-caching/dependencies-sticky-disks) mount persistent ext4 volumes by key, up to five per job. WarpBuild covers the Docker case with remote Docker builders that hold a persistent layer cache, and the dependency case with the WarpBuild cache action, and snapshot runners cover whole-VM state. Arbitrary keyed volumes are a Blacksmith feature with no direct WarpBuild counterpart. **You want transparent interception of `actions/cache` with no workflow edit.** WarpBuild caching is a drop-in action swap to `WarpBuilds/cache@v1` rather than a transparent redirect. That is one line per cache step. **Your team is happy with GitHub-only access control** and has no SAML requirement, no BYOC requirement, and no region-pinning requirement. On those three dimensions the WarpBuild advantages do not apply to you. ## [Feature Comparison](#feature-comparison)Copy Anchor Link Every cell below either carries a link to the vendor page stating the fact or is marked verified absent with the date we checked. __WarpBuild compared with Blacksmith__ | Dimension | WarpBuild | Blacksmith | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Platform coverage | Linux x64, Linux ARM64, macOS, and Windows runners, plus remote Docker builders. | Linux x64 and Linux ARM64 on Ubuntu 24.04 and 22.04, five sizes each (2 to 32 vCPU), Windows Server 2025 labelled public beta in their docs, and macOS in two sizes. Nested virtualization is x64 Linux only, and their docs state Blacksmith is limited to GitHub organizations.[Source, checked 2026-08-13](https://docs.blacksmith.sh/blacksmith-runners/overview) | | macOS depth | macOS 14, 15, and 26 images on M4 Pro hardware in 6 vCPU and 12 vCPU sizes. The macOS 26 image ships Xcode 27.0 and the macOS 27 SDKs with iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 simulator runtimes while GitHub's upstream macOS 27 runner image is in beta. A dedicated macOS 27 image follows once that image is released. | Two sizes, 6 vCPU and 12 vCPU, and three image selectors resolving to macOS 26 and macOS 15\. Their docs state the images follow the corresponding GitHub-hosted macOS runner images. No Xcode version list or Xcode selector is published anywhere in their docs.[Source, checked 2026-08-13](https://docs.blacksmith.sh/blacksmith-runners/overview) | | Bring your own cloud | BYOC runs on AWS, GCP, and Azure. Runner minutes are $0.002 and add-ons are included. | verified absent on 2026-08-13 | | Data residency | US and EU regions. | Three regions are named in their docs: US West, EU West, and EU Central, with the fleet procured from data center providers in the US and EU. No self-serve region selection for a job or an organization is documented, and no residency commitment is published.[Source, checked 2026-08-13](https://docs.blacksmith.sh/blacksmith-caching/docker-container-caching) | | Egress cost handling | On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you. Zero egress applies whether your runners live in your cloud or ours. | verified absent on 2026-08-13 | | Concurrency | Generally available Linux and Windows runners do not have plan-level concurrency caps. Beta features may have limits, and macOS uses a per-organization quota arranged with support. | Their docs state Blacksmith does not impose any concurrency limits on how many vCPUs or jobs run simultaneously. This dimension is parity with WarpBuild.[Source, checked 2026-08-13](https://docs.blacksmith.sh/blacksmith-runners/overview) | | Pricing model | Usage based, billed per minute. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits. | Usage-based per-minute rates published for Ubuntu x64, Ubuntu ARM, Windows x64, and macOS, plus a quote-only Enterprise tier, with no base subscription, platform, or seat fee on the pricing page. Separately metered add-ons for Docker layer caching, sticky disks, and static IPs, priced Premium Support, and separately metered agent compute. 3,000 free minutes per month per organization, no credit card.[Source, checked 2026-08-13](https://www.blacksmith.sh/pricing) | | SSO | SAML 2.0 and OIDC single sign-on for enterprise teams. | Authentication is through GitHub only. Their settings docs state that Blacksmith currently only supports login using GitHub, and access derives from GitHub organization and repository permissions. No SAML, SCIM, or IdP integration is published, and no SSO row appears on the Enterprise tier.[Source, checked 2026-08-13](https://docs.blacksmith.sh/introduction/settings) | | Support | Slack support channels are available on demand. | Three published plans. Standard is best effort with no uptime SLA. Premium requires a purchased add-on or a committed contract and carries no uptime SLA. Enterprise adds a dedicated private Slack channel, priority escalation, and a 99.9 percent monthly uptime SLA with service credits. Tickets go through their dashboard; email is not a designated support channel.[Source, checked 2026-08-13](https://docs.blacksmith.sh/about/support-terms) | | Observability tooling | CI observability with OpenTelemetry metrics correlated to GitHub Actions job logs, plus the Action Debugger for an SSH session into a running job. | CI analytics dashboard with p99 job duration and cost by repository, run history, cross-run log search with a query syntax, machine metrics, test analytics, Slack monitors with VM retention, and SSH access into a running job at no additional cost. Testboxes are labelled early beta. No MCP server is published.[Source, checked 2026-08-13](https://docs.blacksmith.sh/blacksmith-observability/dashboard) | | API and Terraform | WarpBuild publishes an API for CI configuration and automation. A Terraform provider covers BYOC on AWS. | No public API reference: the OpenAPI spec advertised in their docs index returns HTTP 404 as of 2026-08-13, and no api-reference URL appears in their sitemap. No Terraform provider, module, or documentation appears anywhere in their docs. The published programmatic surfaces are the blacksmith CLI for Testboxes (early beta) and their useblacksmith GitHub Actions.[Source, checked 2026-08-13](https://docs.blacksmith.sh/llms.txt) | | Compliance | SOC 2 Type 2, with evidence at trust.warpbuild.com. | SOC 2 Type 2 and GDPR, with HIPAA stated as in progress and quarterly penetration testing. Trust center at trust.oneleet.com, published DPA, sub-processor list with RSS, and a public status page. Jobs run in Firecracker microVMs destroyed after the job.[Source, checked 2026-08-13](https://www.blacksmith.sh/security) | | Shipping cadence | Runner image and platform changes are published monthly in the public docs changelog. | No public changelog found on 2026-08-13: /changelog returns HTTP 404, neither sitemap lists one, and four common product RSS paths return 404\. A sub-processor-only RSS feed exists. Their engineering blog is the nearest public shipping signal and carries a four month gap and a three month gap in the last twelve months.[Source, checked 2026-08-13](https://www.blacksmith.sh/blog) | | Proof | Named customer case studies with before and after numbers, plus public repositories running warp- labels in their GitHub Actions workflows. | Nine published customer stories and roughly 29 named customers, including Ashby, VEED, Supabase, Clerk, and Mintlify, several with quantified outcome quotes. An OSS program names Celery, Ladybird, Zen, and Limbo, and a startup program is published.[Source, checked 2026-08-13](https://www.blacksmith.sh/customers) | Proof on the WarpBuild side: named customers on the homepage logo wall include SonarQube, Comcast, Sky, Braintrust, Bitcoin, and LanceDB, with case studies carrying before and after numbers and public repositories running `warp-` labels in their workflows. ## [Migration Path](#migration-path)Copy Anchor Link The label change is one line per job, in both directions. `blacksmith-2vcpu-ubuntu-2404` becomes `warp-ubuntu-latest-x64-2x`, and the rest of the workflow file stays as it is unless it uses `useblacksmith/*` actions. Three things need attention beyond the label: workflows that adopted `useblacksmith/setup-docker-builder` and `useblacksmith/build-push-action` had their `cache-from` and `cache-to` directives removed per Blacksmith's own guidance and need a replacement path, sticky disk steps have no direct equivalent, and the first run on any cache key is a cold run while caches warm. Rollback is the same one-line change in reverse and can be done the same day. [Blacksmith to WarpBuild migration](/migrate/blacksmith-to-warpbuild) has the full label mapping table, a complete workflow example, the cache notes, the compatibility table with unsupported rows stated plainly, and guidance for running both platforms side by side on a subset of workflows. ## FAQ ### Does Blacksmith offer a bring-your-own-cloud or VPC deployment? No such option is published. On 2026-08-13 we checked their pricing page, their security page, their runner docs, and their complete 33-URL docs sitemap and found no BYOC, VPC, or customer-account deployment. Their security page describes a vendor-operated fleet of 32 vCPU boxes procured from data center providers in the US and EU (https://www.blacksmith.sh/security). WarpBuild runs BYOC on AWS, GCP, and Azure (https://www.warpbuild.com/docs/ci/byoc). ### Which platform is faster? No public head-to-head benchmark exists for the two platforms, and WarpBuild publishes no benchmark numbers of its own in marketing copy. Both vendors run jobs on dedicated compute rather than shared hyperscaler instances, so the honest way to decide is a parallel run on your own workflows for a week. The migration guide at /migrate/blacksmith-to-warpbuild explains how to run both platforms on a subset of jobs. ### Does Blacksmith support SAML or SCIM single sign-on? No. Their settings docs state that Blacksmith currently only supports login using GitHub, and their permissions docs state that Blacksmith uses GitHub for authentication and inherits your GitHub organization's structure (https://docs.blacksmith.sh/introduction/settings). There is no SSO row on the Enterprise tier of their pricing page as of 2026-08-13\. WarpBuild offers SAML 2.0 and OIDC single sign-on for a flat $250 per month, whatever the user count (https://www.warpbuild.com/pricing). ### How many free minutes does Blacksmith give and what does WarpBuild give? Blacksmith publishes 3,000 free minutes per month per organization with no credit card, denominated as x64 2vCPU minutes, with published conversion ratios for ARM, Windows, and macOS runners (https://www.blacksmith.sh/pricing). WarpBuild signup includes $10 free credits against usage-based per-minute rates, with no base subscription fee, no platform fee, and no seat fee (https://www.warpbuild.com/pricing). ### Do both vendors hold SOC 2 Type 2? Yes. Blacksmith states SOC 2 Type 2 and GDPR compliance on their security page, with a trust center at https://trust.oneleet.com/blacksmith-sh, and announced Type 2 in September 2024\. WarpBuild holds SOC 2 Type 2 with evidence at https://trust.warpbuild.com/. Compliance is parity between the two and should not decide the evaluation. ### Can I keep my Blacksmith caches when I switch? No cache state is portable between the two platforms in either direction. Blacksmith's own Docker caching docs state that the first Docker run after a cache-backend change is an uncached run, which is the same expectation on the way in and on the way out (https://docs.blacksmith.sh/blacksmith-caching/docker-builds). Plan for one cold run per cache key, then hydrated runs after that. --- # Comparison Changelog | WarpBuild | WarpBuild URL: https://www.warpbuild.com/compare/changelog Description: Every factual change to WarpBuild's comparison pages, dated. Facts are re-verified against vendor sites on a fixed cadence. # Comparison changelog Every factual change to a WarpBuild comparison page is logged here with its date. Competitor facts are re-verified against vendor sites at least every 60 days. Latest verification pass: 2026-08-13. * 2026-08-13 /blog Corrected the Blacksmith comparison post: Blacksmith runs Ubuntu 24.04 and 22.04 images (the post previously said 22.04 only), and platform coverage now reflects their current runner matrix. First-party benchmark numbers were removed pending the public benchmarks page. * 2026-08-13 /compare/blacksmith, /compare/namespace, /compare/depot, /compare/github-actions, /compare/arc Initial publication of the comparison set. Every competitor fact was verified against the vendor's live pages on 2026-08-13; each dimension cell links its source. * 2026-08-13 /compare/github The GitHub comparison moved to /compare/github-actions with a permanent redirect. --- # WarpBuild vs Depot for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/compare/depot Description: Depot and WarpBuild compared for GitHub Actions runners: macOS shapes, BYOC clouds, pricing model, SSO, support, and egress, each fact linked and dated. # WarpBuild vs Depot for GitHub Actions Depot and WarpBuild compared for GitHub Actions runners: macOS shapes, BYOC clouds, pricing model, SSO, support, and egress, each fact linked and dated. Last updated: 2026-08-13 Depot and WarpBuild both run GitHub Actions jobs on managed runners that you select by changing a `runs-on` label. Depot pairs those runners with a container build service and a separate CI engine of their own, while WarpBuild concentrates on the runner fleet: managed runners inside your AWS, GCP, or Azure account, more macOS shapes, and usage based pricing with no subscription underneath it. Arrived with a different question? [Depot alternatives](/alternatives/depot) covers the wider field, and [migrating from Depot to WarpBuild](/migrate/depot-to-warpbuild) has the label mapping, the cache changes, and the rollback path. Every Depot fact below links to the Depot page that states it, checked on 2026-08-13, and every factual correction to this page after that date is logged on the [comparison changelog](/compare/changelog). The [feature comparison](#feature-comparison) table is the fact layer. The section headings around it are our reading of the tradeoffs, and you should treat them as such. ## [What Depot Does Well](#what-depot-does-well)Copy Anchor Link Container builds are their strongest product and the place their engineering shows. They publish [live per commit benchmark pages](https://depot.dev/benchmark/posthog) for public repositories including PostHog, Mastodon, Moby, and Temporal, with the methodology printed on the page: for every commit, one job builds the project with `docker/build-push-action` and another builds it with `depot build`, and both durations are recorded. A continuously updated, reproducible measurement against a named public repository is rare in this category. Read the scope correctly when you compare, because those pages measure `depot build` against `docker build`. They do not measure their GitHub Actions runners against GitHub-hosted runners. The infrastructure work behind it is public too. [Depot Metal](https://depot.dev/blog/announcing-depot-metal) covers bare metal EC2 with microVMs and NVMe backed storage, the Switchyard orchestrator is described in the [Depot CI docs](https://depot.dev/docs/ci/overview), and the RAM disk write buffer they [launched as Ultra Runners](https://depot.dev/blog/introducing-github-actions-ultra-runners) is now documented as a per size [disk accelerator](https://depot.dev/docs/github-actions/runner-types) on their Linux and macOS runners, included at no extra cost on every plan. Concurrency is a tie. Depot documents [no concurrency limits](https://depot.dev/docs/github-actions/overview) and prints unlimited build concurrency on every plan on their [pricing page](https://depot.dev/pricing), including the entry plan. On WarpBuild, run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Neither side wins this row, and a comparison page that awards it to one of them is selling you something. Billing granularity is theirs to keep. Usage is tracked per second with no one minute minimum, in their own words on the [pricing page](https://depot.dev/pricing), so a 30 second job bills as 30 seconds. Users are unlimited on their middle and top plans, so there is no per seat charge above the entry tier. Self imposed usage caps that halt builds at a ceiling you set are published on the same page. The product surface is broad and coherent: container builds, GitHub Actions runners, Depot CI, [Depot Cache](https://depot.dev/docs/cache/overview), [Depot Registry](https://depot.dev/docs/registry/overview), agent sandboxes, and test result analytics. Data transfer into and out of their network is free, including [registry](https://depot.dev/docs/registry/overview) and [cache](https://depot.dev/blog/introducing-depot-cache) traffic. [Egress filtering](https://depot.dev/docs/github-actions/how-to-guides/egress-filtering), an allow and deny control on outbound traffic from Linux runners, is available on every plan. Observability is real on their side. Their docs cover CPU and memory utilization with [automated runner sizing recommendations](https://depot.dev/docs/github-actions/observability/github-actions-metrics), step level timing breakdowns, failure rate tracking, live runner status, and test result analytics. Shipping cadence belongs to them as well: their [changelog](https://depot.dev/changelog) carries 222 entries with an RSS feed and covers every month from May 2022 through August 2026 with a single gap month, and their 2026 months are the busiest on that page. They also publish an [llms.txt index](https://depot.dev/llms.txt) and a markdown variant of every docs, blog, changelog, and customer page, which is a stronger agent readiness posture than most vendors in this category have. ## [Where WarpBuild Is Ahead](#where-warpbuild-is-ahead)Copy Anchor Link ### [macOS shapes and published image detail](#macos-shapes-and-published-image-detail)Copy Anchor Link Depot publishes three macOS labels, `depot-macos-26`, `depot-macos-15`, and `depot-macos-14`, and all three land on the same hardware shape of 8 CPUs, 24 GB of memory, and a 400 GB disk, per their [runner types page](https://depot.dev/docs/github-actions/runner-types). WarpBuild publishes five macOS labels across the same three macOS versions in two sizes, 6 vCPU with 14 GB and 12 vCPU with 28 GB of memory, on M4 Pro hardware. The full list is in [cloud runners](/docs/ci/cloud-runners). Image contents are documented differently on each side. Depot publishes no Xcode version matrix and points at GitHub's runner-images READMEs for image contents, with their own caveat that their images may lag GitHub's. A published Xcode selector or Xcode version list on depot.dev was verified absent on 2026-08-13\. WarpBuild publishes the Xcode version its macOS 26 image ships, Xcode 27.0 build 27A266a, together with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes bundled with it. Depot also documents a capacity constraint on macOS that has no equivalent on their Linux and Windows runners. Their [runner types page](https://depot.dev/docs/github-actions/runner-types) states: "due to licensing constraints from Apple, our macOS runner capacity is not fully elastic like our other runner types. We periodically update capacity to match demand, but macOS jobs can experience longer queue times during times of high demand." Their [troubleshooting page](https://depot.dev/docs/github-actions/troubleshooting) breaks the same point down into no autoscaling, a fixed pool size, and first in first out processing. In fairness, that page also publishes a remedy: dedicated macOS instances reserved for one organization, on an annual commitment arranged with their team. WarpBuild asks teams planning unusually high macOS concurrency to talk to support before they scale up, which is documented in [cloud runners](/docs/ci/cloud-runners). Ask both vendors the same question about your peak macOS job count before you commit. ### [Which clouds your runners can live in](#which-clouds-your-runners-can-live-in)Copy Anchor Link [Depot Managed](https://depot.dev/docs/managed/overview) places their data plane in your own AWS account as a single tenant sub account, and their docs gate it two ways: AWS only, and Business plan only. Google Cloud is described there as something they are "considering support for" in the future, and Azure does not appear. Both were verified on 2026-08-13\. Terraform is absent from their published surface: their own [llms.txt site index](https://depot.dev/llms.txt) returns no provider, module, or documentation page for it as of 2026-08-13. WarpBuild BYOC runs on AWS, GCP, and Azure, with a Terraform path for AWS. See [BYOC](/docs/ci/byoc) and the [Terraform docs](/docs/ci/api-keys/terraform). ### [Pricing shape and meter count](#pricing-shape-and-meter-count)Copy Anchor Link Depot's self serve plans are monthly subscriptions with included allowances, and usage above those allowances is metered separately for Depot CI, GitHub Actions jobs, Docker builds, cache storage, agent sandboxes, registry storage, and test results. That is seven meters on top of a plan fee, counted from their [pricing page](https://depot.dev/pricing) on 2026-08-13\. A permanent free tier was verified absent on the same date; what they publish is a 7 day trial with no credit card. Included minutes are consumed at a size multiplier, so a large runner burns the allowance faster than elapsed time suggests, per their [runner types page](https://depot.dev/docs/github-actions/runner-types). WarpBuild pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, and signup includes $10 in free credits. Per minute rates by runner label are published on the [pricing page](/pricing). ### [SSO posture](#sso-posture)Copy Anchor Link Depot publishes what single sign on costs, which is more transparent than most of this category. The SAML and SCIM row on their [pricing page](https://depot.dev/pricing) shows it unavailable on the entry plan, available on the middle plan as an optional monthly add on with the price printed in the cell, and included on the top plan. Read the shape rather than the number: single sign on is an add on charged on top of an already paid plan, and the add on is priced against that plan. WarpBuild publishes the same number in one form: SAML 2.0 and OIDC single sign on for a flat $250 per month, whatever the user count. Runner pricing underneath it stays usage based, with no base subscription fee, no platform fee and no seat fee. See [SSO](/docs/sso) and the [pricing page](/pricing). ### [Support channels](#support-channels)Copy Anchor Link Depot's support rows on their [pricing page](https://depot.dev/pricing) read: Discord community on all three plans, email support from the middle plan up, and Slack Connect on the top plan only. Their [help page](https://depot.dev/help) lists three surfaces, documentation, email, and the community Discord. A published SLA for the self serve plans was verified absent on 2026-08-13; [Depot Managed](https://depot.dev/docs/managed/overview) carries the Business plan SLA. WarpBuild makes Slack support channels available on demand. ### [Interactive debugging inside GitHub Actions](#interactive-debugging-inside-github-actions)Copy Anchor Link Both sides ship observability. The asymmetry is interactive debugging. Depot's first party SSH into a running job is a [Depot CI feature](https://depot.dev/docs/ci/how-to-guides/debug-with-ssh). For their GitHub Actions runners, their [troubleshooting page](https://depot.dev/docs/github-actions/troubleshooting) routes you to the third party `action-tmate` action and adds: "Depot doesn't maintain the `action-tmate` action. Please report any issues or problems on the project GitHub page." A first party interactive debugger for their GitHub Actions runners was verified absent on 2026-08-13. WarpBuild ships the [Action Debugger](/docs/ci/tools/action-debugger), an open source action that pauses a workflow and opens an SSH session on the runner, alongside [snapshot runners](/docs/ci/features/snapshot-runners) that boot later jobs from a captured VM state, [CI observability](/docs/ci/features/observability) with right sizing recommendations, and an [MCP server](/docs/ci/mcp) for driving the API from an agent. ### [Data residency and published compliance](#data-residency-and-published-compliance)Copy Anchor Link Depot Cloud runs in two named AWS regions, us-east-1 and eu-central-1, per the FAQ on their [pricing page](https://depot.dev/pricing). Custom regions are a Business plan row on the same page, and region pinning for their GitHub Actions runners specifically is not documented, verified absent on 2026-08-13\. WarpBuild names US and EU regions, and enterprise deployments can be placed on [region specific infrastructure](/products/region-specific-infrastructure). On compliance, compare what each side publishes rather than what each side holds. Depot's public attestation statement is a [SOC 2 Type I announcement](https://depot.dev/blog/depot-soc-2) from March 2024, plus an AICPA SOC badge in their site footer that links to AICPA's general page. A public Type 2 statement or a trust portal on depot.dev was verified absent on 2026-08-13, which is a statement about their published evidence and nothing more. WarpBuild is SOC 2 Type 2 across Security, Availability, and Confidentiality, with the report requestable at [trust.warpbuild.com](https://trust.warpbuild.com/). ### [Two different egress bills](#two-different-egress-bills)Copy Anchor Link Two separate bills carry the word egress, and mixing them produces a wrong comparison. The first is data transfer on the vendor's own network. Depot does not charge for it. Their words: "No egress charges. We don't charge you for egress traffic," from their [post on self hosting GitHub Actions](https://depot.dev/blog/self-hosting-github-actions). Transfer into and out of their registry and cache is free on the same terms. The second is the bill your own cloud sends you when runners pull large images and artifacts out of ECR, S3, and similar stores. Depot has a published answer here too, and it is co-location: their [cost analysis post](https://depot.dev/blog/slow-builds-are-costing-you-money) argues that because their runners are hosted in AWS, keeping data in the same region removes transfer costs. That argument is scoped to teams whose infrastructure already sits in AWS, and it lives in a blog post rather than on the plan table. On the WarpBuild enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you. Zero egress applies whether your runners live in your cloud or ours, so a team on Azure or GCP gets the same outcome as a team on AWS. ### [Two limits Depot documents on their own runners](#two-limits-depot-documents-on-their-own-runners)Copy Anchor Link Both of these are Depot facts about Depot, quoted from their pages. Neither implies a WarpBuild capability; check the WarpBuild docs against your workload before you plan around either. Windows containers. From their [runner types page](https://depot.dev/docs/github-actions/runner-types): "Windows runners don't come equipped with Hyper-v because of an AWS limitation on EC2\. Therefore, if you use things that require it like `docker`, than Depot Windows Runners are unlikely to work for you." Their [Windows launch post](https://depot.dev/blog/windows-github-actions-runners) scopes the same limit to Windows containers and Hyper-V features and says they are looking at adding support in the future. WarpBuild Windows runner labels, sizes, and images are documented in [cloud runners](/docs/ci/cloud-runners). Repository ownership. From their [GitHub Actions overview](https://depot.dev/docs/github-actions/overview): "To use Depot runners, your repository must be owned by a GitHub organization (not a personal account)." Their [quickstart](https://depot.dev/docs/github-actions/quickstart) adds that configuring the runners requires the organization owner role. ## [When Depot Is the Better Choice](#when-depot-is-the-better-choice)Copy Anchor Link Three cases, stated plainly. **Your bottleneck is container builds.** If most of your GitHub Actions minutes go into `docker build`, Depot's container build product is the center of their engineering and the exact path their [public benchmarks](https://depot.dev/benchmark/posthog) measure. Their [cache orchestration](https://depot.dev/docs/cache/overview) and [registry](https://depot.dev/docs/registry/overview) are built around that workload. A runner swap alone will not move a container build bottleneck on any platform, so a team in that position should evaluate build products rather than runner fleets. **You want to move off GitHub Actions.** Depot ships a CI engine of their own, and if leaving the GitHub Actions execution model is on your roadmap, buying runners from a vendor whose deeper investment lands there is a coherent choice. The callout below has the facts. **You want one vendor for the whole build platform.** Runners, container builds, cache, registry, agent sandboxes, and test analytics on one bill and one support channel has real operational value, and Depot covers more of that surface than a runner focused vendor does. Depot runners and Depot CI are two different products * Depot GitHub Actions runners keep your workflows in `.github/workflows/` and change one `runs-on` label. * Depot CI is a separate engine. Their [docs](https://depot.dev/docs/ci/overview) describe it as "an entirely new CI engine" that "sits on top of Depot's own orchestrator and compute layer", and workflows move from `.github/workflows/` to `.depot/workflows/`. * Depot CI runs on Linux x86\_64 only. Their words: "Depot CI doesn't provide sandboxes for Arm, macOS, or Windows." macOS and Windows jobs stay where they are. * Depot CI executes GitHub Actions YAML against a published [compatibility matrix](https://depot.dev/docs/ci/compatibility), and their own answer to whether workflows need changes is "In most cases, no." * Adopting it is designed to be parallel: `.github/workflows/` keeps running on GitHub while the `.depot/workflows/` copies run on Depot, with their caveat to "be mindful of workflows that write to external systems (deploys, artifact updates) since they will execute twice." * The first party SSH debugging, AI failure analysis, and Datadog CI Visibility integration are Depot CI features rather than GitHub Actions runner features, which matters if you intend to stay on GitHub Actions. ## [Feature Comparison](#feature-comparison)Copy Anchor Link The table below is generated from the evidence layer that backs this page. Every Depot cell carries the URL where the fact is stated and the date it was checked, and every WarpBuild cell maps to a claim ID in this page's frontmatter. __WarpBuild compared with Depot__ | Dimension | WarpBuild | Depot | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Platform coverage | Linux x64, Linux ARM64, macOS, and Windows runners, plus remote Docker builders. | Linux x64, Linux ARM64, Windows, and macOS runners. Linux and Windows families each publish a six rung size ladder from 2 CPU with 8 GB up to 64 CPU with 256 GB. Depot CI, their separate CI engine, runs on Linux x86\_64 only.[Source, checked 2026-08-13](https://depot.dev/docs/github-actions/runner-types) | | macOS depth | macOS 14, 15, and 26 images on M4 Pro hardware in 6 vCPU and 12 vCPU sizes. The macOS 26 image ships Xcode 27.0 and the macOS 27 SDKs with iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 simulator runtimes while GitHub's upstream macOS 27 runner image is in beta. A dedicated macOS 27 image follows once that image is released. | Three macOS labels, macos-14, macos-15, and macos-26, all on one hardware shape of 8 CPUs, 24 GB memory, and a 400 GB disk. Their docs state macOS capacity is not fully elastic and that jobs can queue during high demand, with dedicated instances available on an annual commitment. No Xcode version matrix is published; the docs point at GitHub's runner-images READMEs.[Source, checked 2026-08-13](https://depot.dev/docs/github-actions/runner-types) | | Bring your own cloud | BYOC runs on AWS, GCP, and Azure. Runner minutes are $0.002 and add-ons are included. | Depot Managed deploys the Depot data plane into the customer's own AWS account as a single tenant sub account, on the Business plan only. Google Cloud is described as under consideration; Azure does not appear. No Terraform provider, module, or documentation is published on depot.dev.[Source, checked 2026-08-13](https://depot.dev/docs/managed/overview) | | Data residency | US and EU regions. | Depot Cloud runs on AWS in the us-east-1 and eu-central-1 regions, with project level region selection self serve and custom regions on the Business plan. Region pinning for GitHub Actions runners specifically is not documented.[Source, checked 2026-08-13](https://depot.dev/pricing) | | Egress cost handling | On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you. Zero egress applies whether your runners live in your cloud or ours. | No egress or ingress charges on Depot's own network, including free data transfer for their registry and cache. Separately, a blog post argues that co-locating runners in AWS removes customer data transfer costs, scoped to customers whose infrastructure is already in AWS. Egress cost handling does not appear on their pricing page or plan table.[Source, checked 2026-08-13](https://depot.dev/blog/self-hosting-github-actions) | | Concurrency | Generally available Linux and Windows runners do not have plan-level concurrency caps. Beta features may have limits, and macOS uses a per-organization quota arranged with support. | No concurrency limits. Their docs state 'No concurrency limits, cache size limits, or network limits. Run as many jobs as you want in parallel', and the pricing table lists unlimited build concurrency on Developer, Startup, and Business. This dimension is parity with WarpBuild.[Source, checked 2026-08-13](https://depot.dev/docs/github-actions/overview) | | Pricing model | Usage based, billed per minute. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits. | Three plans. Developer and Startup carry a published monthly subscription and Business is quote based. Usage above the included allowances is metered across seven meters: Depot CI, GitHub Actions jobs, Docker builds, cache storage, agent sandboxes, registry storage, and test results. Usage is tracked per second with no one minute minimum. A 7 day trial replaces a permanent free tier, and users are unlimited above the entry plan.[Source, checked 2026-08-13](https://depot.dev/pricing) | | SSO | SAML 2.0 and OIDC single sign-on for enterprise teams. | SAML and SCIM single sign on. Unavailable on Developer, an optional monthly add on with a published price on Startup, and included on Business. Role based access control is a separate row, available on Startup and Business.[Source, checked 2026-08-13](https://depot.dev/pricing) | | Support | Slack support channels are available on demand. | Discord community on all three plans, email support on Startup and Business, and Slack Connect on Business only. Their help page lists three surfaces: documentation, email, and the community Discord. Depot Managed carries the Business plan SLA; a published SLA for the self serve plans was verified absent on 2026-08-13.[Source, checked 2026-08-13](https://depot.dev/pricing) | | Observability tooling | CI observability with OpenTelemetry metrics correlated to GitHub Actions job logs, plus the Action Debugger for an SSH session into a running job. | Job performance analytics with CPU and memory utilization tracking, automated runner sizing recommendations, step level timing breakdowns, failure rate tracking, live runner status, and test result analytics from JUnit XML. Interactive SSH debugging is a Depot CI feature; for GitHub Actions runners their troubleshooting page routes users to the third party action-tmate action and disclaims maintaining it.[Source, checked 2026-08-13](https://depot.dev/docs/github-actions/observability/github-actions-metrics) | | API and Terraform | WarpBuild publishes an API for CI configuration and automation. A Terraform provider covers BYOC on AWS. | Connect based APIs with gRPC and HTTP JSON, available on every plan, covering Depot CI, container builds, and sandboxes, with Node and Go SDKs. The Sandbox SDK is in private beta. A documented API for provisioning GitHub Actions runner settings, and any Terraform provider or module, were verified absent on depot.dev on 2026-08-13.[Source, checked 2026-08-13](https://depot.dev/docs/api/overview) | | Compliance | SOC 2 Type 2, with evidence at trust.warpbuild.com. | The published attestation statement is a SOC 2 Type I announcement from March 2024, plus an AICPA SOC badge in the site footer linking to AICPA's general page. A public SOC 2 Type 2 statement, a trust portal, and ISO 27001, HIPAA, or FedRAMP pages were verified absent on depot.dev on 2026-08-13\. Their security docs page is an architecture and controls narrative with no attestation claims. Audit logging and advanced compliance controls are Business plan rows.[Source, checked 2026-08-13](https://depot.dev/blog/depot-soc-2) | | Shipping cadence | Runner image and platform changes are published monthly in the public docs changelog. | Public changelog with an RSS feed carrying 222 entries, covering every month from May 2022 through August 2026 with a single gap month in April 2025, and an accelerating 2026\. Every entry is also served as markdown. This dimension is a Depot strength rather than a WarpBuild win.[Source, checked 2026-08-13](https://depot.dev/changelog) | | Proof | Named customer case studies with before and after numbers, plus public repositories running warp- labels in their GitHub Actions workflows. | Ten named case studies with concrete headline numbers, plus live per commit public benchmark pages for PostHog, Mastodon, Moby, and Temporal with published methodology. Those pages measure depot build against docker build rather than their GitHub Actions runners against GitHub-hosted runners.[Source, checked 2026-08-13](https://depot.dev/benchmark/posthog) | ## [Migration Path](#migration-path)Copy Anchor Link Moving in either direction is a `runs-on` change, which keeps a trial low effort and a rollback just as simple. Copy Text ``` jobs: build: - runs-on: depot-ubuntu-24.04-8 + runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 ``` Two things do not travel with the label. Caching changes, because Depot substitutes [their cache for the GitHub Actions cache API](https://depot.dev/docs/cache/integrations/github-actions) automatically while jobs run on their labels, while WarpBuild caching uses the drop-in `WarpBuilds/cache` and `WarpBuilds/setup-*` actions documented in [caching](/docs/ci/features/caching). And `depot build` steps inside a workflow belong to their container build product, so they keep running against Depot until you replace them. [Migrating from Depot to WarpBuild](/migrate/depot-to-warpbuild) has the full label map across Linux, Windows, and macOS, the cache feature by feature comparison, the compatibility table with unsupported rows stated plainly, the parallel run plan, and the rollback steps. ## FAQ ### Does Depot limit concurrency? No. Depot documents no concurrency limits and lists unlimited build concurrency on Developer, Startup, and Business plans (https://depot.dev/pricing, checked 2026-08-13). Generally available WarpBuild Linux and Windows runners do not have plan-level concurrency caps, so this dimension is a tie for those platforms. ### Does Depot offer BYOC on GCP or Azure? Not as of 2026-08-13\. Depot Managed deploys their data plane into your AWS account on the Business plan, their docs list Google Cloud as something they are considering, and Azure does not appear (https://depot.dev/docs/managed/overview). WarpBuild BYOC runs on AWS, GCP, and Azure, with a Terraform path for AWS. ### How many macOS runner sizes does Depot offer? One hardware shape, at 8 CPUs and 24 GB of memory, shared by their macos-14, macos-15, and macos-26 labels (https://depot.dev/docs/github-actions/runner-types, checked 2026-08-13). WarpBuild publishes two macOS sizes, 6 vCPU with 14 GB and 12 vCPU with 28 GB, across macOS 14, 15, and 26. ### Can Docker workloads run on Depot Windows runners? Their docs say Windows runners come without Hyper-V because of an AWS limitation on EC2, so tooling that requires it is unlikely to work for you (https://depot.dev/docs/github-actions/runner-types, checked 2026-08-13). Check the WarpBuild Windows capabilities on https://www.warpbuild.com/docs/ci/cloud-runners against your own workload before assuming a label swap covers this case. ### Does Depot charge for egress? No. Depot states they charge for neither egress nor ingress on their own network, including registry and cache transfer (https://depot.dev/blog/self-hosting-github-actions). A separate bill is what your own cloud charges when runners pull large artifacts out of ECR or S3\. On the WarpBuild enterprise tier, that egress cost drops to zero, and it applies whether your runners live in your cloud or ours. ### Is Depot cheaper than WarpBuild? Price it against GitHub-hosted runners, not against Depot's plans, because that is the only comparison with numbers on both sides. warp-ubuntu-latest-x64-2x costs $0.004 per minute against $0.006 per minute for GitHub-hosted ubuntu-latest: 33 percent lower list price. GitHub list price checked on 2026-08-13 at https://docs.github.com/en/billing/reference/actions-minute-multipliers. Depot sells monthly subscription plans with included allowances plus separate usage meters; see their own numbers at https://depot.dev/pricing. WarpBuild is purely usage based with no base subscription fee, no platform fee, and no seat fee (https://www.warpbuild.com/pricing). Price both plans against a month of your own workflow minutes. --- # WarpBuild vs GitHub-Hosted Runners | WarpBuild URL: https://www.warpbuild.com/compare/github-actions Description: WarpBuild and GitHub-hosted runners compared on per-minute price, concurrency caps, runner sizes, macOS depth, and BYOC, with every GitHub number linked. # WarpBuild vs GitHub-Hosted Runners WarpBuild and GitHub-hosted runners compared on per-minute price, concurrency caps, runner sizes, macOS depth, and BYOC, with every GitHub number linked. Last updated: 2026-08-13 WarpBuild runs your GitHub Actions jobs on its own runner fleet, and you switch by changing the `runs-on` label in a workflow file. GitHub-hosted runners remain the better choice for public repositories and for teams that stay inside GitHub's included minutes. WarpBuild is ahead on per-minute price at matched shapes, on concurrency, and on running runners inside your own cloud account. Every GitHub fact below is quoted from GitHub's own documentation, linked where it appears, and was checked on 2026-08-13\. WarpBuild figures come from the [pricing page](/pricing) and the [cloud runner catalog](/docs/ci/cloud-runners). Every factual correction to this page is dated on the [comparison changelog](/compare/changelog). Arrived with a different question? [GitHub Actions larger runner alternatives](/alternatives/github-actions) covers the wider vendor set, and [migrate from GitHub-hosted runners to WarpBuild](/migrate/github-hosted-to-warpbuild) has the label diff, the cache notes, and the rollback steps. ## [What GitHub-Hosted Runners Do Well](#what-github-hosted-runners-do-well)Copy Anchor Link **Zero setup, and they are the default.** Each GitHub-hosted runner is a fresh virtual machine with the runner application and tooling preinstalled, updated weekly, with no installation, cluster, or account linking of any kind. That is documented on [GitHub's hosted runner concepts page](https://docs.github.com/en/actions/concepts/runners/github-hosted-runners). **Free for public repositories, with a published allowance.** GitHub states that "GitHub Actions usage is free for self-hosted runners and for public repositories that use standard GitHub-hosted runners" on the [Actions billing page](https://docs.github.com/en/billing/concepts/product-billing/github-actions). Public repositories also get the larger 4 vCPU and 16 GB Linux and Windows shapes rather than the 2 vCPU and 8 GB shapes private repositories get, per the [hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). For an open source project on standard runners, no vendor beats free. The one caveat GitHub states in the same billing page: larger runners are always charged, even on public repositories. **Included minutes on every plan.** 2,000 Actions minutes a month on Free, 3,000 on Pro and Team, and 50,000 on GitHub Enterprise Cloud, reset at the start of each month, per the [Actions billing page](https://docs.github.com/en/billing/concepts/product-billing/github-actions). **Native integration.** Runners, billing, permissions, secrets, OIDC, and metrics all live in the same product as the repository. GitHub ships usage metrics and performance metrics covering job run time, queue time, and failure rate at repository and organization level, broken down by workflow, repository, runtime OS, and runner type, per the [Actions metrics docs](https://docs.github.com/en/actions/concepts/metrics). **Platform breadth.** Linux x64 and arm64, Windows x64, the `windows-11-arm` and `windows-11-vs2026-arm` images, macOS on Intel and on Apple Silicon, plus larger runner classes carrying a Tesla T4, per the [hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) and the [larger runners reference](https://docs.github.com/en/actions/reference/runners/larger-runners). WarpBuild offers Linux x64, Linux ARM64, macOS, and Windows runners and does not offer the last two GitHub classes, so this row belongs to GitHub. **Open, auditable images.** Every image manifest is public, per image, with exact tool and Xcode version lists and build IDs, at [actions/runner-images](https://github.com/actions/runner-images). **A high, public shipping cadence.** Eight Actions entries landed on the [GitHub changelog](https://github.blog/changelog/label/actions/) between 2026-06-25 and 2026-07-30 alone. **A first-party management API.** Organization-level hosted runners, custom image definitions, machine sizes, platforms, and limits all have REST endpoints, per the [hosted runners API reference](https://docs.github.com/en/rest/actions/hosted-runners). ## [Where WarpBuild Is Ahead](#where-warpbuild-is-ahead)Copy Anchor Link ### [macOS price at a matched shape](#macos-price-at-a-matched-shape)Copy Anchor Link GitHub publishes per-minute list prices, so this comparison uses real numbers on both sides. The arithmetic is in [Cost Comparison](#cost-comparison) below. The headline: the 6 vCPU, 14 GB WarpBuild macOS runner costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14 GB, which is 22 percent lower list price, and it carries the same memory. GitHub list price checked on 2026-08-13 at [actions-minute-multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers). ### [macOS image and Xcode depth](#macos-image-and-xcode-depth)Copy Anchor Link WarpBuild publishes macOS 14, macOS 15, and macOS 26 images, each in a 6 vCPU and a 12 vCPU size, per the [cloud runner catalog](/docs/ci/cloud-runners). The macOS 26 image ships Xcode 27.0 (build 27A266a) with iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes, on top of the Xcode versions already present in the upstream GitHub image. On GitHub's side, macOS 26 and macOS 15 are GA, macOS 14 is deprecated, and the `xcode-27` image is in preview, per [actions/runner-images](https://github.com/actions/runner-images), checked on 2026-08-13\. So the counted difference today is one extra Xcode line, generally available rather than in preview, on an image that otherwise matches GitHub's. ### [Concurrency](#concurrency)Copy Anchor Link GitHub publishes hard caps per plan at [Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13. | Runner class | Plan | Concurrent jobs | Concurrent macOS jobs | | ------------ | ---------- | --------------- | --------------------- | | Standard | Free | 20 | 5 | | Standard | Pro | 40 | 5 | | Standard | Team | 60 | 5 | | Standard | Enterprise | 500 | 50 | | Larger | Team | 1,000 | 5 | | Larger | Enterprise | 1,000 | 50 | Source: [docs.github.com Actions limits](https://docs.github.com/en/actions/reference/limits), checked 2026-08-13\. GitHub notes on the same page that the macOS cap is shared across standard and larger runners, so a Team organization stays at 5 concurrent macOS jobs whichever class it picks. Run as many jobs as your workflows need on WarpBuild. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts dynamically. For a macOS fleet that needs sustained high concurrency, WarpBuild asks you to contact support first so capacity is staged ahead of the run, per the [cloud runner docs](/docs/ci/cloud-runners). Fair reading of this row: 500 concurrent standard jobs on GitHub Enterprise is a large number in absolute terms. The WarpBuild advantage is the absence of a ceiling and the absence of a plan gate, rather than any suggestion that GitHub's caps are small. ### [Runner sizes and how they are gated](#runner-sizes-and-how-they-are-gated)Copy Anchor Link GitHub's default labels on a private repository give you 2 vCPU and 8 GB on Linux and Windows, per the [hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Anything bigger means larger runners, which GitHub documents as available to organizations and enterprises on the Team and Enterprise Cloud plans, and which never draw on included minutes. WarpBuild publishes 2, 4, 8, 16, and 32 vCPU Linux runners on x64 and ARM64, 4 to 32 vCPU Windows runners, and 6 and 12 vCPU macOS runners, on every account, with no plan gate. Sizes and labels are in the [cloud runner catalog](/docs/ci/cloud-runners). State the top end plainly: GitHub's larger runners reach 96 vCPU and 384 GB, per the [larger runners reference](https://docs.github.com/en/actions/reference/runners/larger-runners), above WarpBuild's largest 32 vCPU and 128 GB general runner. For work that needs more than 32 cores, WarpBuild's answer is [remote Docker builders](/docs/ci/docker-builders), which reach 192 vCPU and 384 GB for container builds, rather than a larger general-purpose runner. Configuration is the other half of this row. WarpBuild runners take dynamic labels for [snapshot runners](/docs/ci/features/snapshot-runners) and [nested virtualization](/docs/ci/features/nested-virtualization), and BYOC deployments add custom VM images, static IPs, and spot instances, per the [feature matrix](/docs/ci/feature-matrix). ### [BYOC and data residency](#byoc-and-data-residency)Copy Anchor Link BYOC runs on AWS, GCP, and Azure, with Terraform support for BYOC on AWS. Runners boot inside your own cloud account and your data stays in the account you already audit. See the [BYOC docs](/docs/ci/byoc) and the [Terraform docs](/docs/ci/api-keys/terraform). GitHub-hosted runners have no equivalent. GitHub states that it "hosts Linux and Windows runners on virtual machines in Microsoft Azure" and hosts macOS runners in Azure data centers, on the [hosted runner concepts page](https://docs.github.com/en/actions/concepts/runners/github-hosted-runners); no customer-selectable region for hosted runner compute is documented, and nothing runs in your account. Bring-your-own-cloud for GitHub-hosted runners was verified absent on 2026-08-13\. GitHub Enterprise Cloud with data residency is a separate product on GHE.com covering the EU, Australia, the US, and Japan, per [GitHub's data residency docs](https://docs.github.com/en/enterprise-cloud@latest/admin/data-residency/about-github-enterprise-cloud-with-data-residency); that page does not state where Actions runner compute executes, so treat it as a repository-data control rather than a runner-placement control. WarpBuild names US and EU regions for runner placement. Self-hosting the whole runner fleet inside your own account is the BYOC path above. ### [Observability, the Action Debugger, and snapshot runners](#observability-the-action-debugger-and-snapshot-runners)Copy Anchor Link WarpBuild ships [CI observability](/docs/ci/features/observability) with OpenTelemetry system metrics from the runner agent correlated with GitHub Actions job logs, the [Action Debugger](/docs/ci/tools/action-debugger) for an SSH session into a paused job, [snapshot runners](/docs/ci/features/snapshot-runners) that boot later jobs from a captured VM state, and an [MCP server](/docs/ci/mcp) that exposes supported tools to an agent. GitHub's first-party equivalent is the metrics surface described above, which is real and worth using. An interactive debugger session on a hosted runner and a snapshot mechanism were verified absent from [GitHub's Actions metrics docs](https://docs.github.com/en/actions/concepts/metrics) and the [hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) on 2026-08-13. ### [Egress cost on the enterprise tier](#egress-cost-on-the-enterprise-tier)Copy Anchor Link On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you. Zero egress applies whether your runners live in your cloud or ours. See [region-specific infrastructure](/products/region-specific-infrastructure). Keep this separate from GitHub's published transfer allowances, which concern GitHub's own network: 1 GB of outbound transfer a month on Free, 10 GB on Pro and Team, and 100 GB on Enterprise Cloud for Packages, with data transferred in free of charge, per the [Packages billing docs](https://docs.github.com/en/billing/concepts/product-billing/github-packages). Those are different bills from the egress your cloud provider charges you during a deployment. ### [Pricing model](#pricing-model)Copy Anchor Link WarpBuild pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, and signup includes $10 free credits. GitHub layers per-minute Actions rates on top of a per-user seat subscription, with plan prices published on the [GitHub pricing page](https://github.com/pricing). SSO on WarpBuild costs a flat $250 per month, whatever the user count, published on the [pricing page](/pricing) and set up from the [SSO docs](/docs/sso). GitHub gates SAML single sign-on behind GitHub Enterprise Cloud, and states that the Team plan is not eligible, on the [SAML SSO docs](https://docs.github.com/en/organizations/managing-saml-single-sign-on-for-your-organization/about-identity-and-access-management-with-saml-single-sign-on). GitHub's SSO cost is transparent, and it is a per-seat plan upgrade for every user in the organization rather than a flat fee. Slack support channels are available on demand on WarpBuild. GitHub publishes four support levels, Community, Standard, Enterprise, and purchasable Premium, on its [support page](https://docs.github.com/en/support/learning-about-github-support/about-github-support). WarpBuild holds SOC 2 Type 2, with the report and controls at [trust.warpbuild.com](https://trust.warpbuild.com/). ## [When GitHub-Hosted Is the Better Choice](#when-github-hosted-is-the-better-choice)Copy Anchor Link **Public repositories on standard runners.** Free is free. If your project is open source and your jobs fit the standard shapes, there is no cost argument for switching, and GitHub says so itself on the [Actions billing page](https://docs.github.com/en/billing/concepts/product-billing/github-actions). **Private repositories that stay inside included minutes.** 2,000 minutes on Free and 3,000 on Team cover a lot of small projects. If your monthly Actions bill is $0 today, a per-minute vendor cannot beat it. **Teams that never touch the caps.** If you run 15 concurrent jobs on a 60-job cap and no macOS, the concurrency row does not matter to you. **Workloads that need what WarpBuild does not offer.** Larger runner classes with a Tesla T4 and the `windows-11-arm` images are GitHub-only in this comparison, per the [larger runners reference](https://docs.github.com/en/actions/reference/runners/larger-runners) and [actions/runner-images](https://github.com/actions/runner-images). **Small macOS jobs that fit 3 vCPU.** GitHub's standard Apple Silicon macOS runner at $0.062 per minute is the cheapest macOS minute on either price list, and it draws on included minutes. WarpBuild's macOS advantage applies at the larger shapes. **Personal accounts.** WarpBuild registers runners in the Default runner group of a GitHub organization, per the [public repository setup docs](/docs/ci/public-repos), so an organization is a prerequisite. ## [Cost Comparison](#cost-comparison)Copy Anchor Link All GitHub rates below come from [GitHub's Actions minute multipliers page](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13, quoted with GitHub's own billing SKU strings so you can match them against your usage export. All WarpBuild rates come from the [pricing page](/pricing) and the [cloud runner catalog](/docs/ci/cloud-runners), also checked on 2026-08-13. ### [macOS](#macos)Copy Anchor Link | Runner | vCPU | RAM | USD per minute | USD per hour | | ----------------------------------------------------- | ---- | ----- | -------------- | ------------ | | WarpBuild warp-macos-latest-arm64-6x | 6 | 14 GB | 0.080 | 4.80 | | GitHub macos-latest-xlarge (macos\_xl, Apple Silicon) | 5 | 14 GB | 0.102 | 6.12 | | WarpBuild warp-macos-latest-arm64-12x | 12 | 28 GB | 0.160 | 9.60 | | GitHub macos-latest-large (macos\_l, Intel) | 12 | 30 GB | 0.077 | 4.62 | | GitHub macos-latest (actions\_macos, Apple Silicon) | 3 | 7 GB | 0.062 | 3.72 | GitHub rates: [docs.github.com actions-minute-multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked 2026-08-13\. Hourly figures are the per-minute rate multiplied by 60. The matched Apple Silicon pair is the first two rows. 0.102 minus 0.080 is 0.022 lower per minute, which is 22 percent lower list price, or 1.32 lower per hour. A 30-minute macOS build costs 3.06 on the GitHub shape and 2.40 on the WarpBuild shape, a difference of 0.66 per build; at 500 builds a month that is 330 lower per month. Two rows in that table go the other way, and they matter. GitHub's 3 vCPU standard macOS runner is the cheapest macOS minute anywhere in this comparison, and standard runners draw on your included minutes. GitHub's 12 vCPU Intel larger runner is also cheaper per minute than the WarpBuild 12 vCPU Apple Silicon runner; the shapes differ by architecture, so pick by what your build needs rather than by the rate alone. ### [Linux x64](#linux-x64)Copy Anchor Link | Shape | WarpBuild label | WarpBuild USD per minute | GitHub SKU | GitHub USD per minute | Difference | | --------------- | -------------------------- | ------------------------ | --------------------------------------------- | --------------------- | ----------------------------- | | 2 vCPU, 8 GB | warp-ubuntu-latest-x64-2x | 0.004 | actions\_linux (ubuntu-latest, private repos) | 0.006 | 0.002 lower, 33 percent lower | | 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | 0.008 | linux\_4\_core | 0.012 | 0.004 lower, 33 percent lower | | 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | 0.016 | linux\_8\_core | 0.022 | 0.006 lower, 27 percent lower | | 16 vCPU, 64 GB | warp-ubuntu-latest-x64-16x | 0.032 | linux\_16\_core | 0.042 | 0.010 lower, 24 percent lower | | 32 vCPU, 128 GB | warp-ubuntu-latest-x64-32x | 0.064 | linux\_32\_core | 0.082 | 0.018 lower, 22 percent lower | GitHub rates: [docs.github.com actions-minute-multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked 2026-08-13\. GitHub sizes above 32 vCPU (64-core and 96-core) have no WarpBuild counterpart and are left out of the table rather than compared to a smaller shape. ### [Linux ARM64](#linux-arm64)Copy Anchor Link | Shape | WarpBuild label | WarpBuild USD per minute | GitHub SKU | GitHub USD per minute | Difference | | --------------- | ---------------------------- | ------------------------ | ----------------------------------------------------- | --------------------- | ----------------------------- | | 2 vCPU, 8 GB | warp-ubuntu-latest-arm64-2x | 0.003 | actions\_linux\_arm (ubuntu-24.04-arm, private repos) | 0.005 | 0.002 lower, 40 percent lower | | 4 vCPU, 16 GB | warp-ubuntu-latest-arm64-4x | 0.006 | linux\_4\_core\_arm | 0.008 | 0.002 lower, 25 percent lower | | 8 vCPU, 32 GB | warp-ubuntu-latest-arm64-8x | 0.012 | linux\_8\_core\_arm | 0.014 | 0.002 lower, 14 percent lower | | 16 vCPU, 64 GB | warp-ubuntu-latest-arm64-16x | 0.024 | linux\_16\_core\_arm | 0.026 | 0.002 lower, 8 percent lower | | 32 vCPU, 128 GB | warp-ubuntu-latest-arm64-32x | 0.048 | linux\_32\_core\_arm | 0.050 | 0.002 lower, 4 percent lower | GitHub rates: [docs.github.com actions-minute-multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked 2026-08-13\. The gap on ARM64 narrows to 0.002 per minute at every size, so at 16 and 32 vCPU this row is close to parity. ### [Windows](#windows)Copy Anchor Link | Shape | WarpBuild label | WarpBuild USD per minute | GitHub SKU | GitHub USD per minute | Difference | | --------------- | --------------------------- | ------------------------ | ----------------- | --------------------- | ----------------------------- | | 4 vCPU, 16 GB | warp-windows-latest-x64-4x | 0.016 | windows\_4\_core | 0.022 | 0.006 lower, 27 percent lower | | 8 vCPU, 32 GB | warp-windows-latest-x64-8x | 0.032 | windows\_8\_core | 0.042 | 0.010 lower, 24 percent lower | | 16 vCPU, 64 GB | warp-windows-latest-x64-16x | 0.064 | windows\_16\_core | 0.082 | 0.018 lower, 22 percent lower | | 32 vCPU, 128 GB | warp-windows-latest-x64-32x | 0.128 | windows\_32\_core | 0.162 | 0.034 lower, 21 percent lower | GitHub rates: [docs.github.com actions-minute-multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked 2026-08-13\. GitHub's standard `windows-latest` on a private repository is 2 vCPU and 8 GB at 0.010 per minute; WarpBuild removed its 2 vCPU Windows runners on 2026-06-08, so the smallest comparable pair starts at 4 vCPU. ### [A worked monthly model](#a-worked-monthly-model)Copy Anchor Link Take a team running 20,000 Linux minutes a month on a 4 vCPU shape and 2,000 macOS minutes a month on the larger Apple Silicon shape, all in private repositories. * Linux on GitHub: 20,000 minutes at 0.012 is 240 a month. Included minutes do not apply, because GitHub states larger runners are always charged. * Linux on WarpBuild: 20,000 minutes at 0.008 is 160 a month. Difference: 80. * macOS on GitHub: 2,000 minutes at 0.102 is 204 a month. * macOS on WarpBuild: 2,000 minutes at 0.08 is 160 a month. Difference: 44. * Total difference: 124 a month, before the $10 in signup credits. Three billing rules change this arithmetic and all three come from GitHub's own pages, checked on 2026-08-13\. GitHub "rounds the minutes and partial minutes each job uses up to the nearest whole minute", so a 12-second job bills a full minute, per the [minute multipliers page](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Included minutes reset monthly and cannot be applied to larger runners, per the [Actions billing page](https://docs.github.com/en/billing/concepts/product-billing/github-actions). And usage is blocked once quota is exhausted if no payment method is on file, per the same page. WarpBuild also bills per minute, so a matrix of very short jobs rounds up on both platforms. WarpBuild add-ons are metered separately and listed on the [pricing page](/pricing): cache storage, cache operations, snapshot restores, and snapshot storage. Network add-ons such as Tailscale are $0\. On BYOC, runner minutes are $0.002 and the add-ons are included, because the compute bill goes to your own cloud account. ## [Feature Comparison](#feature-comparison)Copy Anchor Link __WarpBuild compared with GitHub-hosted runners__ | Dimension | WarpBuild | GitHub-hosted runners | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Platform coverage | Linux x64, Linux ARM64, macOS, and Windows runners, plus remote Docker builders. | Standard runners cover Linux x64 and arm64, Windows x64 and arm64, macOS Intel and Apple Silicon. Larger runners add classes carrying a Tesla T4 on Team and Enterprise Cloud.[Source, checked 2026-08-13](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) | | macOS depth | macOS 14, 15, and 26 images on M4 Pro hardware in 6 vCPU and 12 vCPU sizes. The macOS 26 image ships Xcode 27.0 and the macOS 27 SDKs with iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 simulator runtimes while GitHub's upstream macOS 27 runner image is in beta. A dedicated macOS 27 image follows once that image is released. | Standard macOS runners are 3 vCPU and 7 GB on Apple Silicon and 4 vCPU and 14 GB on Intel. Larger macOS shapes add 12 vCPU Intel and 5 vCPU Apple Silicon. macOS 26 and macOS 15 images are GA, macOS 14 is deprecated, and the xcode-27 image is in preview.[Source, checked 2026-08-13](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) | | Bring your own cloud | BYOC runs on AWS, GCP, and Azure. Runner minutes are $0.002 and add-ons are included. | verified absent on 2026-08-13 | | Data residency | US and EU regions. | GitHub hosts Linux and Windows runners on Azure virtual machines and macOS runners in Azure data centers. No customer-selectable region is documented for hosted runner compute. GitHub Enterprise Cloud with data residency is a separate product on GHE.com and does not state where Actions compute executes.[Source, checked 2026-08-13](https://docs.github.com/en/actions/concepts/runners/github-hosted-runners) | | Egress cost handling | On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you. Zero egress applies whether your runners live in your cloud or ours. | Actions egress is not billed as a separate meter. The published transfer allowance covers Packages: 1 GB per month on Free, 10 GB on Pro and Team, 100 GB on Enterprise Cloud, with inbound transfer free and Actions downloads not counted against the hosting repository. This is egress on GitHub's own network, a different bill from the egress a customer's cloud provider charges during artifact pulls.[Source, checked 2026-08-13](https://docs.github.com/en/billing/concepts/product-billing/github-packages) | | Concurrency | Generally available Linux and Windows runners do not have plan-level concurrency caps. Beta features may have limits, and macOS uses a per-organization quota arranged with support. | Published hard caps per plan: 20 concurrent jobs on Free, 40 on Pro, 60 on Team, 500 on Enterprise, and 1,000 for larger runners on Team and Enterprise. Concurrent macOS jobs cap at 5 on Free, Pro, and Team and 50 on Enterprise, shared across standard and larger runners.[Source, checked 2026-08-13](https://docs.github.com/en/actions/reference/limits) | | Pricing model | Usage based, billed per minute. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits. | Per-minute usage rates layered on a per-user seat subscription, with included Actions minutes reset monthly: 2,000 on Free, 3,000 on Pro and Team, 50,000 on Enterprise Cloud. Standard runners are free on public repositories. Larger runners are always charged and cannot draw on included minutes. Jobs bill rounded up to the whole minute. Linux 2-core is $0.006 per minute, Windows 2-core $0.010, and macOS $0.062, checked 2026-08-13.[Source, checked 2026-08-13](https://docs.github.com/en/billing/reference/actions-minute-multipliers) | | SSO | SAML 2.0 and OIDC single sign-on for enterprise teams. | SAML single sign-on requires GitHub Enterprise Cloud, and their docs state the Team plan is not eligible. The gating cost is published as the Enterprise seat price, so it is a per-seat plan upgrade for every user in the organization rather than a flat SSO fee.[Source, checked 2026-08-13](https://docs.github.com/en/organizations/managing-saml-single-sign-on-for-your-organization/about-identity-and-access-management-with-saml-single-sign-on) | | Support | Slack support channels are available on demand. | Four levels: GitHub Community Support, Standard, Enterprise, and purchasable Premium. Free gets Community only, Pro and Team add Standard, Enterprise Cloud and Enterprise Server add Enterprise. Response times, coverage hours, and channel types are not stated on that page.[Source, checked 2026-08-13](https://docs.github.com/en/support/learning-about-github-support/about-github-support) | | Observability tooling | CI observability with OpenTelemetry metrics correlated to GitHub Actions job logs, plus the Action Debugger for an SSH session into a running job. | Actions usage metrics and Actions performance metrics covering job run time, queue time, and failure rate, at repository and organization level, broken down by workflow, repository, runtime OS, and runner type including self-hosted against GitHub-hosted. No published equivalent of an interactive job debugger or of snapshot runners was found on the metrics and hosted runner pages on 2026-08-13.[Source, checked 2026-08-13](https://docs.github.com/en/actions/concepts/metrics) | | API and Terraform | WarpBuild publishes an API for CI configuration and automation. A Terraform provider covers BYOC on AWS. | A REST API manages organization-level hosted runners, custom image definitions and versions, GitHub-owned and partner images, machine sizes, platforms, and limits. No first-party GitHub Terraform provider was found on 2026-08-13; the widely used integrations/github provider is not published by GitHub.[Source, checked 2026-08-13](https://docs.github.com/en/rest/actions/hosted-runners) | | Compliance | SOC 2 Type 2, with evidence at trust.warpbuild.com. | GitHub operates a Trust Center. The fetched page surfaced no enumerated certification list that could be quoted on 2026-08-13, so consult the Trust Center for the current reports.[Source, checked 2026-08-13](https://github.com/trust-center) | | Shipping cadence | Runner image and platform changes are published monthly in the public docs changelog. | Public changelog with an Actions label. Eight Actions entries were published between 2026-06-25 and 2026-07-30, covering runner controls, image previews, and workflow features.[Source, checked 2026-08-13](https://github.blog/changelog/label/actions/) | | Proof | Named customer case studies with before and after numbers, plus public repositories running warp- labels in their GitHub Actions workflows. | Runner images are open source with a public manifest per image listing installed tools, Xcode versions, and image build IDs. GitHub Actions is the default platform for the ecosystem and publishes no comparative proof page.[Source, checked 2026-08-13](https://github.com/actions/runner-images) | Every GitHub cell above links the GitHub page the fact came from, with the date it was checked. Cells marked verified absent were checked on the listed date against the pages named in the source column. ## [Migration Path](#migration-path)Copy Anchor Link The switch is a label change. GitHub's `ubuntu-latest` on a private repository is 2 vCPU and 8 GB, which maps to `warp-ubuntu-latest-x64-2x` at the same shape, or to `warp-ubuntu-latest-x64-4x` if the job is CPU bound: Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-4x ``` Full label mapping for `ubuntu-latest`, `windows-latest`, `macos-latest`, and the version-pinned labels, plus cache behavior, secrets, billing during a parallel run, and the rollback steps, is on [migrate from GitHub-hosted runners to WarpBuild](/migrate/github-hosted-to-warpbuild). Rollback is the same edit in reverse and takes effect on the next workflow run, because GitHub-hosted runners need no setup to return to. If you arrived from a "make GitHub Actions faster" search rather than a vendor comparison, start with [speed up GitHub Actions workflows](/guides/speed-up-github-actions) for the workflow-level fixes, or [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) if container build time is what hurts. Braintrust reports consistent sub-minute runner provisioning on both ARM64 and x86 WarpBuild runners, per the [Braintrust case study](/customers/braintrust). Build-time comparisons wait for the public benchmarks page, so run the duplicated job yourself and read your own numbers. ## FAQ ### Do I have to rewrite my workflows to move off GitHub-hosted runners? No. WarpBuild runners register as self-hosted runners against your GitHub organization, so the only required edit is the runs-on label. Actions from the marketplace, secrets, environments, OIDC, and GITHUB\_TOKEN keep working the way GitHub documents them at https://docs.github.com/en/actions/reference/runners/github-hosted-runners. ### Is WarpBuild cheaper than GitHub-hosted macOS runners? At the closest matched Apple Silicon shape, yes. GitHub lists its larger Apple Silicon macOS runner (5 vCPU, 14 GB) at $0.102 per minute at https://docs.github.com/en/billing/reference/actions-minute-multipliers, checked on 2026-08-13\. The WarpBuild 6 vCPU, 14 GB macOS runner is $0.08 per minute at https://www.warpbuild.com/pricing, which is 22 percent lower list price. GitHub's 3 vCPU standard macOS runner at $0.062 per minute is still the cheapest macOS minute on either price list. ### What concurrency limits does each platform publish? GitHub publishes hard per-plan caps at https://docs.github.com/en/actions/reference/limits, checked on 2026-08-13\. The caps are 20 concurrent jobs on Free, 40 on Pro, 60 on Team, 500 on Enterprise, 1,000 for larger runners, and a macOS cap of 5 concurrent jobs on Free, Pro, and Team that is shared across standard and larger runners. Generally available WarpBuild Linux and Windows runners do not have plan-level concurrency caps; macOS capacity and beta runners have separate constraints. ### Can I keep GitHub-hosted runners for some workflows and use WarpBuild for others? Yes. The runs-on label is set per job, so you can move one job, one workflow, or one repository at a time and leave everything else on GitHub-hosted runners. Running both side by side for a week is the recommended validation path on https://www.warpbuild.com/migrate/github-hosted-to-warpbuild. ### Does WarpBuild offer everything GitHub-hosted runners offer? No. GitHub ships runner classes with a Tesla T4, and the windows-11-arm images, listed at https://docs.github.com/en/actions/reference/runners/larger-runners and https://github.com/actions/runner-images, checked on 2026-08-13\. WarpBuild offers Linux x64, Linux ARM64, macOS, and Windows runners plus remote Docker builders, and does not offer those two classes. ### How often is this comparison rechecked? Every 60 days. GitHub changes runner images, prices, and limits often, so each number here carries the page it came from and the date it was checked. Report an error to support@warpbuild.com and the correction ships within 48 hours. --- # WarpBuild vs Namespace | WarpBuild URL: https://www.warpbuild.com/compare/namespace Description: Namespace and WarpBuild both sell managed GitHub Actions runners. Compare bring your own cloud, concurrency caps, macOS depth, pricing structure and support. # WarpBuild vs Namespace Namespace and WarpBuild both sell managed GitHub Actions runners. Compare bring your own cloud, concurrency caps, macOS depth, pricing structure and support. Last updated: 2026-08-13 Namespace and WarpBuild both sell managed GitHub Actions runners that you adopt by changing a `runs-on` label. The decision usually comes down to where the runners run: Namespace runs every job on hardware it owns, while WarpBuild runs jobs either on its own fleet or inside your AWS, GCP or Azure account. Two sibling pages cover the other intents: [Namespace alternatives](/alternatives/namespace) for the wider provider list, and [migrating from Namespace to WarpBuild](/migrate/namespace-to-warpbuild) for the label, cache and rollback steps. Every Namespace fact below links to the Namespace page where it is stated, checked on 2026-08-13\. The matrix and the cited figures are facts. The recommendation sections are marked as editorial. ## [What Namespace Does Well](#what-namespace-does-well)Copy Anchor Link **They own the hardware.** Namespace has run more than 95 percent of its platform on its own infrastructure since early 2024, with data centers on the US East Coast, the US West Coast and in Europe ([so you want to build your own datacenter](https://namespace.so/blog/so-you-want-to-build-your-own-datacenter)). That vertical integration is what underwrites their price and performance posture, and it is a real engineering investment rather than a reseller margin. **Their macOS host catalog is wide.** Their docs list four macOS versions, Sonoma, Sequoia, Tahoe and Golden Gate, and six macOS machine shapes reaching 12 vCPU and 56 GB of RAM on bare metal Apple Silicon ([macOS compute docs](https://namespace.so/docs/architecture/compute/macos), [machine shapes](https://namespace.so/docs/architecture/compute/machine-shapes)). Xcode is picked with image selectors, including a beta channel through `image.with=xcode-beta` and a bleeding edge image option in the runner profile editor ([runner configuration](https://namespace.so/docs/reference/github-actions/runner-configuration)). They also ship VNC remote display for macOS runners and an RSS feed for macOS base image updates. The [macOS section](#macos-sizes-sdk-currency-and-price-against-github-hosted-runners) below sets out what WarpBuild ships against that catalog: two sizes per chip, the current Apple SDKs and simulator runtimes, and the price against GitHub-hosted macOS. **Linux on Apple Silicon is a distinct platform for them.** Apple M-series support for Linux workloads is offered as early access, and their pricing matrix gates it to the Business and Enterprise plans ([machine shapes](https://namespace.so/docs/architecture/compute/machine-shapes), [pricing](https://namespace.so/pricing)). No other provider in this comparison set offers it. **Caching is a genuine moat.** Their GitHub Actions caching documentation names eleven distinct cache surfaces: Cache Volumes on local NVMe, the `nscloud-cache-action`, Namespace Artifacts, container image caching, Git checkout caching, toolchain download caching, action download caching, a Bazel remote cache, a Turborepo cache, a Gradle cache and sccache ([caching docs](https://namespace.so/docs/solutions/github-actions/caching)). Cache Volumes are local disks rather than a network cache API, which is a different architecture from the cache products most providers ship. **The security story is specific.** Egress policies run in ALLOW, BLOCK and ADVISORY modes with secret injection and proxy rules, and they are supported on Linux and macOS ([egress policy docs](https://namespace.so/docs/security/egress-policy)). Dedicated egress pools with unique IP addresses are an enterprise feature ([networking security](https://namespace.so/docs/architecture/networking/security)). They publish SOC 2 Type II, a public subprocessor list and a July 2025 penetration test on their [trust center](https://trust.namespace.so). **Debugging tooling goes past the checkbox stage.** Breakpoints pause a workflow for interactive inspection, SSH attaches to a running instance without pausing it, and they ship per-core CPU, memory, disk and network metrics with automatic out-of-memory detection and an insights dashboard ([debugging docs](https://namespace.so/docs/solutions/github-actions/debugging)). **The proof inventory is strong.** Their customer page names Zed, DuckDB, Framer, Warp, Ramp, ElevenLabs, Vanta, Mapbox and Ghostty, with quantified case studies attached ([customers](https://namespace.so/customers)). They raised $23 million across Seed and Series A led by NEA in March 2026 ([Series A post](https://namespace.so/blog/series-a)), and their [changelog](https://namespace.so/changelog) carries 226 dated entries with the newest on August 7, 2026. ## [Where WarpBuild Is Ahead](#where-warpbuild-is-ahead)Copy Anchor Link ### [Bring your own cloud](#bring-your-own-cloud)Copy Anchor Link WarpBuild BYOC runs on AWS, GCP and Azure, with the runners living in your account and your VPC ([BYOC docs](/docs/ci/byoc)). Terraform support covers BYOC on AWS ([Terraform docs](/docs/ci/api-keys/terraform)). Namespace offers no equivalent. All compute runs on their fleet, and their `/docs/federation` section covers workload identity federation with AWS, GCP, OpenID and GitHub Actions rather than running compute in your account ([federation docs](https://namespace.so/docs/federation)). A BYOC or VPC deployment option was verified absent on 2026-08-13 across their [pricing page](https://namespace.so/pricing), their [GitHub Actions solution page](https://namespace.so/docs/solutions/github-actions) and their docs index. A Terraform provider or module was verified absent on the same date. This is the dimension that decides the evaluation for teams with a data boundary, a committed cloud spend agreement or a security review that asks where the build machine sits. ### [Concurrency](#concurrency)Copy Anchor Link Generally available WarpBuild Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts dynamically. macOS capacity and beta runners have separate constraints. Namespace publishes vCPU denominated caps per plan: 32 vCPU Linux and 12 vCPU macOS on Developer, 64 and 24 on Team, 160 and 48 on Business, and Custom on Enterprise, alongside maximum instance durations of 3 hours, 5 hours and up to 24 hours ([pricing](https://namespace.so/pricing)). Their runner configuration docs ship queueing controls that exist because of those caps, including a `job.priority` setting whose documentation states that priority only affects scheduling while running into concurrency limits ([runner configuration](https://namespace.so/docs/reference/github-actions/runner-configuration)). A 32 vCPU Linux ceiling is four concurrent 8 vCPU jobs. Teams with wide test matrices should price the plan that clears their peak fan-out before comparing per minute costs. ### [Data residency](#data-residency)Copy Anchor Link WarpBuild names US and EU regions, and BYOC runners run in whichever region of your own cloud account you configure ([BYOC docs](/docs/ci/byoc)). Namespace states that workspace data may be processed and stored across their global infrastructure by default, that setting a regional preference means contacting their support team, and that region exclusive data residency requires an enterprise plan. Scheduling decisions, operational logs and workflow metadata are replicated globally in every case ([data residency docs](https://namespace.so/docs/workspaces/data-residency), page dated July 4, 2025). No region is named on that page. A blog post describes a presence on the US East Coast, the US West Coast and in Europe, and their trust center lists a Switzerland data center subprocessor. ### [Pricing structure](#pricing-structure)Copy Anchor Link WarpBuild pricing is purely usage based. There is no base subscription fee, no platform fee and no seat fee, and every runner label carries a published per minute rate on the [pricing page](/pricing). Namespace bills in compute units. One unit is 1 vCPU and 2 GB of RAM for a minute times a platform multiplier, published as 1 for Linux, 2 for Windows, 10 for macOS and 7 for Linux on Apple Silicon, with an additional 1.25x unit multiplier on 64 vCPU high CPU instances. Non-standard shapes are billed as `max(vCPU count, GB RAM divided by 2)` ([pricing](https://namespace.so/pricing), [machine shapes](https://namespace.so/docs/architecture/compute/machine-shapes)). Their invoice line item list carries twelve separate meters, and storage is billed in four different units across GB-hr, GB-day, GB-month and GB ([billing and limits](https://namespace.so/docs/workspaces/billing-and-limits)). Both vendors charge no seat fee. Namespace states that plainly in their billing docs. The difference is the arithmetic a buyer has to do: one per minute rate per label against a unit multiplier, a shape formula and a dozen meters. ### [Free credits](#free-credits)Copy Anchor Link WarpBuild signup includes $10 in free credits, usable on any runner type. Namespace offers a 30-day free trial of the Developer plan with no credit card required. A permanent free tier or a recurring free monthly allowance was verified absent on their [pricing page](https://namespace.so/pricing) on 2026-08-13. ### [SSO and access management](#sso-and-access-management)Copy Anchor Link WarpBuild supports SAML 2.0 and OIDC single sign-on with Okta, Entra ID, Google Workspace, Auth0, JumpCloud, PingOne and other compliant providers, and the identity provider connection is configured self-serve through a guided wizard ([SSO docs](/docs/sso)). SSO is a flat $250 per month, whatever the user count, published on the [pricing page](/pricing). A 30 person organization and a 3,000 person organization pay the same $250. Namespace gates SAML single sign-on to enterprise customers and publishes no price for it. Their pricing matrix marks Enterprise SSO as available on the Enterprise plan only, and their access docs describe SAML as available for enterprise customers with no figure attached ([pricing](https://namespace.so/pricing), [workspace access docs](https://namespace.so/docs/workspaces/access)). They do ship SCIM directory sync, enforced SSO and custom session durations on that tier. ### [macOS sizes, SDK currency and price against GitHub-hosted runners](#macos-sizes-sdk-currency-and-price-against-github-hosted-runners)Copy Anchor Link The `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` images ship Xcode 27.0 with the macOS 27 SDKs and the iOS 27.0, tvOS 27.0, watchOS 27.0 and visionOS 27.0 simulator runtimes ([runner catalog](/docs/ci/cloud-runners)). A team shipping an app against the current Apple SDKs, or running simulator tests on the current iOS, tvOS, watchOS and visionOS runtimes, compiles on macOS 26 here today. GitHub's upstream macOS 27 runner image is in beta, and a dedicated WarpBuild macOS 27 image follows once that image is released. WarpBuild macOS runners come in multiple sizes and configurations per chip: 6 vCPU with 14 GB of RAM and 12 vCPU with 28 GB, at $0.08 and $0.16 per minute ([runner catalog](/docs/ci/cloud-runners)). The price comparison runs against GitHub-hosted macOS runners. `warp-macos-latest-arm64-6x` at 6 vCPU and 14 GB costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14 GB. The arithmetic: 0.102 minus 0.08 is 0.022 lower per minute, and 0.022 divided by 0.102 is 22 percent lower list price, on a shape carrying one more vCPU and the same memory. GitHub rate from [actions-minute-multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. Namespace publishes more host macOS versions and more macOS machine shapes, cited above and unchanged by anything in this section. No price comparison against Namespace appears here; their rates are on their own [pricing page](https://namespace.so/pricing). ### [Support](#support)Copy Anchor Link WarpBuild makes Slack support channels available on demand. Namespace lists email support starting on the Team plan and dedicated private Slack channels starting on the Business plan, with an enterprise support add-on above that ([support page](https://namespace.so/support), [pricing](https://namespace.so/pricing)). ### [Egress charges from your own cloud](#egress-charges-from-your-own-cloud)Copy Anchor Link On the enterprise tier, runners pull large images and artifacts from ECR, S3 and similar stores with zero egress cost to you. Zero egress applies whether your runners live in your cloud or ours. Namespace advertises zero egress fees on their own network ([December 2025 pricing post](https://namespace.so/blog/github-pricing-dec16-2025)). These are two different bills. Their claim covers traffic leaving their network. The WarpBuild claim covers the egress charges your own cloud raises when a deploy job pulls gigabytes out of your registry or bucket. Nothing about charges from a customer's own cloud appears on the Namespace [pricing page](https://namespace.so/pricing) or [billing docs](https://namespace.so/docs/workspaces/billing-and-limits), verified on 2026-08-13. ### [Shipping cadence and how you follow it](#shipping-cadence-and-how-you-follow-it)Copy Anchor Link WarpBuild publishes a monthly changelog with an entry for every month from February 2024 to the present, with an [RSS feed](https://www.warpbuild.com/docs/rss.xml) you can subscribe to from a reader or a Slack channel ([changelog](/docs/ci/changelog)). Namespace ships constantly and documents it. Their [changelog](https://namespace.so/changelog) had 226 dated entries with the newest on August 7, 2026 when this page was checked. It carries no RSS or Atom feed, verified on 2026-08-13, so following it means visiting the page. Their [blog](https://namespace.so/blog) went 13 months without a post between November 7, 2024 and December 16, 2025, and has published roughly weekly to biweekly since February 2026\. The gap is history; the current cadence is strong. ### [Product surface WarpBuild has and Namespace does not publish](#product-surface-warpbuild-has-and-namespace-does-not-publish)Copy Anchor Link Snapshot runners capture a runner VM mid-workflow so later jobs boot from that state ([snapshot runners](/docs/ci/features/snapshot-runners)). The [Action Debugger](/docs/ci/tools/action-debugger) is an open source action that pauses a workflow and opens an SSH session on the runner. [CI observability](/docs/ci/features/observability) correlates OpenTelemetry system metrics with GitHub Actions job logs, and the hosted [MCP server](/docs/ci/mcp) exposes its supported tools to agents. A Namespace MCP server was verified absent on 2026-08-13 across their docs index and integrations page. Their breakpoints and SSH access cover the interactive debugging ground; the MCP surface and snapshot runners have no published equivalent. ## [When Namespace Is the Better Choice](#when-namespace-is-the-better-choice)Copy Anchor Link This section is editorial. It is the recommendation a WarpBuild engineer would give in a call. **Pick Namespace if a specific macOS host or shape decides your build.** If you need the Golden Gate host image, a beta Xcode channel, a 12 vCPU and 56 GB macOS shape, or VNC into a macOS runner for a UI test, their published catalog covers ground WarpBuild's does not ([macOS docs](https://namespace.so/docs/architecture/compute/macos)). Teams whose builds turn on the Xcode SDKs and the simulator runtimes rather than the host version have those on WarpBuild today, as the macOS section above sets out. **Pick Namespace if you want Linux on Apple Silicon.** It has no equivalent here or anywhere else in this category ([machine shapes](https://namespace.so/docs/architecture/compute/machine-shapes)). **Pick Namespace if their cache surfaces map onto your build system.** A managed Bazel remote cache, a Turborepo cache and a Gradle cache as named products with documented setup will save a platform team real time compared with wiring the equivalents yourself ([caching docs](https://namespace.so/docs/solutions/github-actions/caching)). **Pick Namespace if you want cloud development environments from the same vendor.** Devboxes are a separate product line with their own sizes and their own meter ([devbox docs](https://namespace.so/docs/devbox)). WarpBuild sells GitHub Actions infrastructure only. Pick WarpBuild when the runners have to sit inside your own cloud account, when a hard concurrency cap would queue your jobs, when a named US or EU region is a requirement rather than a preference, or when you want one per minute rate per runner label instead of a multiplier model. ## [Feature Comparison](#feature-comparison)Copy Anchor Link __WarpBuild compared with Namespace__ | Dimension | WarpBuild | Namespace | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Platform coverage | Linux x64, Linux ARM64, macOS, and Windows runners, plus remote Docker builders. | Linux AMD64, Linux ARM64, Windows and macOS. Windows is amd64 only and reached general availability on 2026-08-05; it is excluded from the Developer plan. macOS is arm64 only. Linux on Apple Silicon is early access and gated to the Business and Enterprise plans. GPU runners and Windows ARM verified absent on 2026-08-13.[Source, checked 2026-08-13](https://namespace.so/docs/solutions/github-actions) | | macOS depth | macOS 14, 15, and 26 images on M4 Pro hardware in 6 vCPU and 12 vCPU sizes. The macOS 26 image ships Xcode 27.0 and the macOS 27 SDKs with iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 simulator runtimes while GitHub's upstream macOS 27 runner image is in beta. A dedicated macOS 27 image follows once that image is released. | 4 macOS versions (Sonoma, Sequoia, Tahoe, Golden Gate) and 6 macOS machine shapes up to 12 vCPU and 56GB on bare metal Apple Silicon, 3 of them selectable by runner label. Xcode is picked with image selectors including a beta channel, and macOS runners support VNC remote display.[Source, checked 2026-08-13](https://namespace.so/docs/architecture/compute/macos) | | Bring your own cloud | BYOC runs on AWS, GCP, and Azure. Runner minutes are $0.002 and add-ons are included. | verified absent on 2026-08-13 | | Data residency | US and EU regions. | No region names published. Workspace data may be processed across their global infrastructure by default, a regional preference is set by contacting support, and region exclusive residency requires an enterprise plan. Scheduling decisions, operational logs and workflow metadata are replicated globally.[Source, checked 2026-08-13](https://namespace.so/docs/workspaces/data-residency) | | Egress cost handling | On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you. Zero egress applies whether your runners live in your cloud or ours. | Advertises zero egress fees on their own network, which is a different bill from egress charges raised by a customer's own cloud. Egress is otherwise a security product for them: ALLOW, BLOCK and ADVISORY policies on Linux and macOS, with dedicated egress pools for enterprise customers.[Source, checked 2026-08-13](https://namespace.so/blog/github-pricing-dec16-2025) | | Concurrency | Generally available Linux and Windows runners do not have plan-level concurrency caps. Beta features may have limits, and macOS uses a per-organization quota arranged with support. | Published per plan caps denominated in vCPU: Developer 32 vCPU Linux and 12 vCPU macOS, Team 64 and 24, Business 160 and 48, Enterprise custom. Maximum instance duration runs 3 hours, 5 hours, and up to 24 hours by plan. A job.priority label affects scheduling while running into those limits.[Source, checked 2026-08-13](https://namespace.so/pricing) | | Pricing model | Usage based, billed per minute. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits. | Four plans, three of them with a monthly fee plus overage and Developer pay as you go. Compute is billed in units of 1 vCPU and 2GB for a minute times a platform multiplier (Linux 1, Windows 2, macOS 10, Linux on Apple Silicon 7), with a 1.25x unit multiplier on 64 vCPU instances. Twelve invoice meters and four storage billing units. No seat fee. Free tier is a 30-day trial rather than a permanent allowance.[Source, checked 2026-08-13](https://namespace.so/pricing) | | SSO | SAML 2.0 and OIDC single sign-on for enterprise teams. | SAML single sign-on with SCIM directory sync, enforced SSO and custom session durations, available to enterprise customers only. No SSO price is published on the pricing page or the access docs; the only path is the Enterprise contact flow.[Source, checked 2026-08-13](https://namespace.so/docs/workspaces/access) | | Support | Slack support channels are available on demand. | Email support from the Team plan up, dedicated private Slack channels from the Business plan up, and an enterprise support add-on above that. Documentation and a community Discord are open to everyone. No SLA numbers published.[Source, checked 2026-08-13](https://namespace.so/support) | | Observability tooling | CI observability with OpenTelemetry metrics correlated to GitHub Actions job logs, plus the Action Debugger for an SSH session into a running job. | Breakpoints that pause a workflow for interactive inspection, SSH into a running instance, container and build logs, per-core CPU, memory, disk and network metrics with automatic OOM detection, an insights dashboard, and a Prometheus remote-write sink on enterprise. No MCP server, verified absent on 2026-08-13.[Source, checked 2026-08-13](https://namespace.so/docs/solutions/github-actions/debugging) | | API and Terraform | WarpBuild publishes an API for CI configuration and automation. A Terraform provider covers BYOC on AWS. | gRPC API published at buf.build/namespace/cloud with SDKs generated for over 15 languages, plus an nsc CLI with more than 100 documented commands. No REST API documented. Terraform provider or module verified absent on 2026-08-13.[Source, checked 2026-08-13](https://namespace.so/docs/reference/api-sdk) | | Compliance | SOC 2 Type 2, with evidence at trust.warpbuild.com. | SOC 2 Type II with a public trust center, a published subprocessor list and a July 2025 penetration test. ISO 27001 and HIPAA verified absent on 2026-08-13\. DPA published at namespace.so/dpa.[Source, checked 2026-08-13](https://trust.namespace.so) | | Shipping cadence | Runner image and platform changes are published monthly in the public docs changelog. | Public changelog with 226 dated entries, newest August 7, 2026\. No RSS or Atom feed on the changelog page, verified absent on 2026-08-13; the only feed they publish is for macOS base images. Their blog went 13 months without a post between November 7, 2024 and December 16, 2025, and has run roughly weekly to biweekly since February 2026.[Source, checked 2026-08-13](https://namespace.so/changelog) | | Proof | Named customer case studies with before and after numbers, plus public repositories running warp- labels in their GitHub Actions workflows. | Named logos including Zed, DuckDB, Framer, Warp, Ramp, ElevenLabs, Vanta, Mapbox and Ghostty, with quantified case studies attached. Claims 1,000+ companies on the pricing and customers pages. $23M across Seed and Series A led by NEA, announced March 23, 2026.[Source, checked 2026-08-13](https://namespace.so/customers) | Every Namespace cell links to the page where the fact is stated and carries the date it was checked. Cells marked verified absent record a search that found nothing on that date rather than an assumption. This page is re-verified every 60 days, and every factual correction is dated on the [comparison changelog](/compare/changelog). ## [Migration Path](#migration-path)Copy Anchor Link Namespace and WarpBuild are both label driven, so a single job moves in one line. Copy Text ``` jobs: build: - runs-on: nscloud-ubuntu-24.04-amd64-8x16-with-cache + runs-on: warp-ubuntu-latest-x64-8x ``` Beyond the label, the work is removing the Namespace specific actions a real migration accumulates: `namespacelabs/nscloud-checkout-action` reverts to `actions/checkout@v4`, and `namespacelabs/nscloud-cache-action` steps become [WarpBuild cache](/docs/ci/features/caching) with `WarpBuilds/cache@v1` or one of the cache enabled [setup actions](/docs/ci/features/caching/setup-actions). Cache volumes do not transfer between vendors, so the first runs on WarpBuild fill a cold cache. Rollback is the same diff in reverse, which makes a trial cheap: point one workflow at a `warp-` label, leave the rest on Namespace, and compare. The full label map, the cache equivalence table, the compatibility notes and the parallel run billing details are on [the migration page](/migrate/namespace-to-warpbuild). Named case studies with before and after numbers are published on the [customers page](/customers). SonarQube runs tens of thousands of GitHub Actions jobs per day on WarpBuild BYOC runners in its own AWS account, across Windows and Linux. Adaptive reports a 60 percent reduction in manual engineering intervention per feature after making WarpBuild snapshot runners the default environment for its AI code agents ([Adaptive case study](/customers/adaptive)). Braintrust, LanceDB, Comcast and Sky are on the [homepage logo wall](https://www.warpbuild.com/), and the compliance posture is published at [trust.warpbuild.com](https://trust.warpbuild.com/). ## FAQ ### Does Namespace offer bring your own cloud runners? No. Every Namespace job runs on the Namespace fleet. Their federation docs cover workload identity federation with AWS, GCP and OpenID rather than running compute in your account, and no BYOC option appeared on their pricing page, their GitHub Actions solution page or their docs index (namespace.so/docs/federation and namespace.so/pricing, checked 2026-08-13). WarpBuild runs managed runners inside your own AWS, GCP or Azure account. ### Does Namespace cap concurrency? Yes, in vCPU per plan. Their pricing page publishes 32 vCPU Linux and 12 vCPU macOS on Developer, 64 and 24 on Team, 160 and 48 on Business, and Custom on Enterprise, with maximum instance durations of 3 hours, 5 hours and up to 24 hours (namespace.so/pricing, checked 2026-08-13). Generally available WarpBuild Linux and Windows runners do not have plan-level concurrency caps. ### How do the two macOS offerings differ? Namespace publishes the deeper host catalog: four macOS versions and six macOS machine shapes up to 12 vCPU and 56 GB on bare metal Apple Silicon, plus Xcode selectors including a beta channel and VNC remote display (namespace.so/docs/architecture/compute/macos, checked 2026-08-13). WarpBuild ships macOS 14, 15 and 26 in two sizes per chip, and the macOS 26 image carries Xcode 27.0 with the macOS 27 SDKs and the iOS 27.0, tvOS 27.0, watchOS 27.0 and visionOS 27.0 simulator runtimes, so a project on the current Apple SDKs builds today at $0.08 and $0.16 per minute (warpbuild.com/docs/ci/cloud-runners, checked 2026-08-13). GitHub's upstream macOS 27 runner image is in beta, and a dedicated WarpBuild macOS 27 image follows once that image is released. ### Can I name the region my data stays in on Namespace? Not from the dashboard. Their data residency page says workspace data may be processed across their global infrastructure by default, that a regional preference is set by contacting support, and that region exclusive residency requires an enterprise plan, with metadata replicated globally either way (namespace.so/docs/workspaces/data-residency, checked 2026-08-13). WarpBuild publishes US and EU regions. ### What does switching from Namespace to WarpBuild actually change? The runs-on label on every job, plus any Namespace specific actions in your workflows such as nscloud-checkout-action and nscloud-cache-action. The label change is reversible in the same pull request. The full inventory is on the migration page at /migrate/namespace-to-warpbuild. --- # Customers - WarpBuild | WarpBuild URL: https://www.warpbuild.com/customers Description: See how engineering teams at Adaptive, Braintrust, SonarSource, and Samaya AI use WarpBuild to ship faster with high-performance CI/CD runners. //Customers ## Trusted by engineering teams shipping at scale From construction tech to financial AI, teams rely on WarpBuild for fast, reliable CI/CD infrastructure. [Adaptive![Adaptive logo](/_next/image?url=%2Fimages%2Fcustomers%2Fadaptive.png&w=3840&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7)Adaptive builds financial infrastructure for the construction industry, modernizing how payments and financing flow between general contractors, subcontractors, and suppliers.$26.4M raised700+ buildersUses Snapshots](/customers/adaptive)[Braintrust![Braintrust logo](/images/customers/braintrust.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7)Braintrust is the enterprise AI platform for building, evaluating, and shipping production-grade LLM applications. Teams use Braintrust to test, iterate, and monitor AI products at scale.Enterprise AI platformMulti-arch CIUses ARM64 & x86 Runners](/customers/braintrust)[SonarQube![SonarQube logo](/images/customers/sonarqube.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7)Sonar, the makers of SonarQube, is a global leader in AI code verification and governance, trusted by over 7 million developers globally. The company's verification offering, SonarQube, is a multilayered, zero trust platform built for the AI era, delivering comprehensive analysis from the first line of code written to the last merge.7M+ developers#1 on G2Uses BYOC on AWS](/customers/sonarqube)[Samaya AI![Samaya AI logo](/images/customers/samaya-ai.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7)Samaya AI provides an AI-powered knowledge platform for financial services, helping institutions extract insights from vast document collections with enterprise-grade security.10K+ seatsTIME100 AIUses Cloud Runners & Docker Builders](/customers/samaya-ai) ## Ship faster with WarpBuild. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # Adaptive - WarpBuild Customer | WarpBuild URL: https://www.warpbuild.com/customers/adaptive Description: Adaptive builds financial infrastructure for the construction industry, modernizing how payments and financing flow between general contractors, subcontractors, and suppliers. ![Adaptive logo](/_next/image?url=%2Fimages%2Fcustomers%2Fadaptive.png&w=3840&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) Construction Tech # Adaptive AI agents ship full features autonomously Adaptive builds financial infrastructure for the construction industry, modernizing how payments and financing flow between general contractors, subcontractors, and suppliers. $26.4M raised700+ builders [Visit Adaptive ](https://www.adaptive.build) ## The Challenge Before WarpBuild, Adaptive's engineering team had to manually configure CI environments every time an AI code agent needed to build and test. Installing packages, loading test fixtures, injecting secrets - the setup overhead killed the promise of autonomous coding. Agents could write code, but couldn't validate it end-to-end without extensive human scaffolding. ## How Adaptive uses WarpBuild Adaptive is modernizing construction finance with software that streamlines payments and financing across the industry. With over $26.4M in funding, Adaptive uses WarpBuild Snapshots as the default environment for their AI code agents. Each CI environment comes pre-loaded with all tests, packages, integrations, and secrets - so agents can build end-to-end features with minimal manual intervention. This approach has fundamentally changed how their engineering team ships, letting code agents take features from start to finish autonomously. WarpBuild product used ## Snapshots Disk snapshots that save and restore workflow state for incremental builds. [Learn more ](/products/ci/cloud-runners) ## Key Results * ✓Snapshots as pre-configured default environments for AI code agents * ✓All tests, packages, integrations, and secrets pre-loaded in each CI environment * ✓Automated end-to-end feature building with minimal manual intervention * ✓60%+ reduction in manual engineering intervention per feature [← All Customers](/customers) ## Ship faster with WarpBuild. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # Braintrust - WarpBuild Customer | WarpBuild URL: https://www.warpbuild.com/customers/braintrust Description: Braintrust is the enterprise AI platform for building, evaluating, and shipping production-grade LLM applications. Teams use Braintrust to test, iterate, and monitor AI products at scale. ![Braintrust logo](/images/customers/braintrust.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) AI/ML # Braintrust Native multi-arch CI without self-hosted overhead Braintrust is the enterprise AI platform for building, evaluating, and shipping production-grade LLM applications. Teams use Braintrust to test, iterate, and monitor AI products at scale. Enterprise AI platformMulti-arch CI [Visit Braintrust ](https://www.braintrust.dev) ## The Challenge Braintrust's AI evaluation platform must ship across ARM64 and x86 targets - but GitHub-hosted runners only offer x86\. Running multi-arch builds meant maintaining custom self-hosted infrastructure, dealing with inconsistent performance across architectures, and slowing down the release cycle for enterprise customers. ## How Braintrust uses WarpBuild Braintrust provides the enterprise AI evaluation and deployment platform that teams depend on to build and ship reliable LLM-powered products. Their CI pipelines run across multiple architectures to guarantee compatibility for every deployment target. WarpBuild's ARM64 and x86 runners deliver the multi-architecture coverage Braintrust needs with best-in-class performance, powering fast cross-platform testing and confident releases. WarpBuild product used ## ARM64 & x86 Runners High-performance runners across ARM64 and x86 architectures for cross-platform CI. [Learn more ](/products/ci/arm64-runners) ## Key Results * ✓Cross-platform CI across ARM64 and x86 architectures * ✓Fast, reliable multi-architecture testing for AI workloads * ✓Consistent performance across all deployment targets * ✓Eliminated self-hosted runner maintenance overhead * ✓Consistent sub-minute provisioning across both architectures [← All Customers](/customers) ## Ship faster with WarpBuild. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # Samaya AI - WarpBuild Customer | WarpBuild URL: https://www.warpbuild.com/customers/samaya-ai Description: Samaya AI provides an AI-powered knowledge platform for financial services, helping institutions extract insights from vast document collections with enterprise-grade security. ![Samaya AI logo](/images/customers/samaya-ai.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) Financial AI # Samaya AI Multi-architecture Docker builds at CI scale Samaya AI provides an AI-powered knowledge platform for financial services, helping institutions extract insights from vast document collections with enterprise-grade security. 10K+ seatsTIME100 AI [Visit Samaya AI ](https://www.samaya.ai) ## The Challenge Samaya AI's platform processes heavy Docker-based workflows - tens of builds per pull request across ARM64 and x64\. Standard Docker builds were a major bottleneck, with each PR triggering long build queues that slowed code review cycles and blocked deployments for their financial services clients. ## How Samaya AI uses WarpBuild Samaya AI builds AI-powered knowledge infrastructure for the financial services industry, serving 10,000+ seats across major institutions and recognized on the TIME100 AI list. Their platform runs ARM64 and x64 workloads alongside heavy Remote Docker Builder usage - tens of builds per PR. WarpBuild Docker Builders provide persistent layer reuse across those container-heavy workflows. WarpBuild product used ## Cloud Runners & Docker Builders High-performance ARM64 and x64 runners paired with Remote Docker Builders for fast container builds. [Learn more ](/products/ci/docker-builders) ## Key Results * ✓Multi-architecture CI across ARM64 and x64 runners * ✓Tens of Docker builds per PR with Remote Docker Builders * ✓Persistent Docker layer reuse across repeated builds * ✓Remote builders for container-heavy pull request workflows [← All Customers](/customers) ## Ship faster with WarpBuild. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # SonarQube - WarpBuild Customer | WarpBuild URL: https://www.warpbuild.com/customers/sonarqube Description: Sonar, the makers of SonarQube, is a global leader in AI code verification and governance, trusted by over 7 million developers globally. The company's verification offering, SonarQube, is a multilayered, zero trust platform built for the AI era, delivering comprehensive analysis from the first line of code written to the last merge. ![SonarQube logo](/images/customers/sonarqube.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) Dev Tools # SonarQube Tens of thousands of CI jobs daily, fully automated Sonar, the makers of SonarQube, is a global leader in AI code verification and governance, trusted by over 7 million developers globally. The company's verification offering, SonarQube, is a multilayered, zero trust platform built for the AI era, delivering comprehensive analysis from the first line of code written to the last merge. 7M+ developers#1 on G2 [Visit SonarQube ](https://www.sonarsource.com) ## The Challenge SonarQube's CI infrastructure runs at massive scale - tens of thousands of jobs per day across both Windows and Linux. Managing that volume with self-hosted runners meant constant maintenance, manual image updates, and limited visibility into fleet health. They needed a provider that could handle enterprise scale while giving API-level control over infrastructure. ## How SonarQube uses WarpBuild SonarQube is ranked #1 on G2 for static code analysis and a Gartner Magic Quadrant Leader. They run bring your own cloud (BYOC) instances on AWS across both Windows and Linux, processing tens of thousands of CI jobs per day. Sonar has built custom automation around the WarpBuild API for automatic image updates, runner configuration changes, and programmatic infrastructure management - all done without manual intervention. WarpBuild product used ## BYOC on AWS Bring Your Own Cloud runners deployed in Sonar's AWS account for maximum control and security. [Learn more ](/products/ci/byoc/aws) ## Key Results * ✓Multi-OS support with BYOC instances running both Windows and Linux on AWS * ✓Tens of thousands of CI jobs processed per day at scale * ✓Custom images with pre-installed dependencies for fast, reproducible builds * ✓Runner pools for instant job starts with zero cold-boot latency * ✓Multiple instance types per runner group for automatic fallback when AWS capacity is constrained * ✓Multiple AWS account targets for runners across environments * ✓Dashboards for visibility into job and queue metrics * ✓Customizable AWS infrastructure setup for maximum security within organization constraints * ✓API-driven automation for image updates, runner configuration, and infrastructure management * ✓Maintenance-free runner image updates and configuration [← All Customers](/customers) ## Ship faster with WarpBuild. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # Enterprise | WarpBuild URL: https://www.warpbuild.com/enterprise Description: Enterprise resources: US and EU data residency, zero egress, SSO, and CI security review guidance. # Enterprise Data residency, zero egress, SSO, and security review resources for GitHub Actions at scale. * [Buying GitHub Actions Runners Through AWS MarketplaceSubscribe to WarpBuild GitHub Actions runners on AWS Marketplace, then have an organization admin connect the subscription. Usage-based rates are unchanged.](/enterprise/aws-marketplace-billing) * [A Security Review Checklist for GitHub Actions RunnersA runner vendor security review needs five documented answers: job isolation, storage lifetime, secret handling, compliance attestation, and network boundaries.](/enterprise/ci-security-review) * [EU Data Residency for GitHub Actions RunnersWarpBuild places GitHub Actions runners in the EU two ways: a hosted EU region on the enterprise tier, or BYOC runners in your own AWS or GCP account.](/enterprise/data-residency-eu) * [US Data Residency for GitHub Actions RunnersWarpBuild runs GitHub Actions runners in US regions. Pin build data to a US region on enterprise plans, or run BYOC runners in your own US account.](/enterprise/data-residency-us) * [Runners for GitHub Enterprise Server and CloudWarpBuild supports GitHub Enterprise Server and GitHub Enterprise Cloud with data residency on the enterprise plan, through a GitHub App you register yourself.](/enterprise/github-enterprise-runners) * [SSO for GitHub Actions Runner AccessWarpBuild supports SAML 2.0 and OIDC single sign-on for GitHub Actions runner access on enterprise plans, at a flat $250 per month whatever the user count.](/enterprise/sso-github-actions) * [Zero Egress Costs for GitHub Actions RunnersOn the WarpBuild enterprise tier, egress costs from your cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores.](/enterprise/zero-egress) --- # Buying GitHub Actions Runners Through AWS Marketplace | WarpBuild URL: https://www.warpbuild.com/enterprise/aws-marketplace-billing Description: Subscribe to WarpBuild GitHub Actions runners on AWS Marketplace, then have an organization admin connect the subscription. Usage-based rates are unchanged. # Buying GitHub Actions Runners Through AWS Marketplace Subscribe to WarpBuild GitHub Actions runners on AWS Marketplace, then have an organization admin connect the subscription. Usage-based rates are unchanged. Last verified: 2026-08-13 You can buy WarpBuild GitHub Actions runners through AWS Marketplace by subscribing to the WarpBuild CI listing and then connecting that subscription to a WarpBuild organization, a step only an organization admin can complete. Runner usage then lands on your AWS invoice instead of a separate card charge, and the rates are the same usage-based rates published on the [WarpBuild pricing page](/pricing). Three documented limitations apply to this path, and they are worth reading before you subscribe rather than after. ## [Answer](#answer)Copy Anchor Link AWS Marketplace billing changes where the invoice arrives. It does not change the product, the runner labels, the per-minute rates, or the workflow files. The three limitations documented for this path are stated up front: * **No free credits.** AWS Marketplace billing does not support free credits. The direct signup path includes $10 free credits; a marketplace subscription bills usage from the first minute. * **No Helios.** Helios cannot be used with AWS Marketplace billing. * **Daily billing at 12:15 AM UTC.** Organization usage is billed daily, at 12:15 AM UTC for the previous day, so the costs shown on AWS Marketplace might not match the costs on the WarpBuild billing dashboard at any given moment. All three come from the [AWS Marketplace billing documentation](/docs/ci/aws-marketplace-billing). This path suits teams with committed AWS spend that want GitHub Actions runner usage consolidated onto the same invoice as the rest of their infrastructure. Procurement approves one vendor relationship instead of two, and finance reconciles one line rather than chasing a separate subscription. AWS Marketplace purchases arrive on the same AWS invoice as your account spend, so ask your AWS account team whether this listing counts toward your specific commitment before you plan around it. What stays the same is the pricing structure. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. You are billed for the minutes your jobs run, and the rate depends on the runner label the job asked for. ## [How It Works](#how-it-works)Copy Anchor Link The purchase runs in two halves. The first half happens on AWS, the second inside WarpBuild, and the subscription only starts metering once both halves are connected. 1. Find the WarpBuild CI product on [AWS Marketplace](https://aws.amazon.com/marketplace) and open **View purchase options**. 2. Review the offer and click **Subscribe** at the bottom of the page. 3. While the subscription is being created, click **Set up your account** at the top of the page. This step is also available after the subscription finishes creating, where the same callout appears in the marketplace console. 4. You are redirected to a page that asks which WarpBuild organization to connect the subscription to. You must be an admin of that organization. If you are not logged in, you are prompted to log in first. If the AWS account is already connected, you see a message saying that this AWS account is already linked to a WarpBuild organization. 5. The subscription activates shortly after it is connected. Until then, the WarpBuild billing dashboard shows the subscription as inactive; once activation completes, the dashboard shows it as active. 6. Start running jobs on WarpBuild runners. Step 4 is the one that stalls purchases. The person who holds AWS Marketplace purchasing rights in a large organization is often in procurement or platform finance, while WarpBuild organization admin rights sit with the platform engineering team. Line up both people before you start, or the subscription sits created and unconnected while access is sorted out. Nothing in your workflow files changes. The `runs-on` labels that worked before the subscription work after it, and jobs are billed at the same per-minute rate against the marketplace subscription instead of the direct account. Copy Text ``` name: build-and-test on: pull_request: push: branches: [main] jobs: lint: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run lint test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/4 strategy: matrix: shard: [1, 2, 3, 4] build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run build ``` Teams that also run runners inside their own cloud account keep that option. BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS, so the runner infrastructure in your account can be described in the same modules as the rest of it. The BYOC comparison is covered in [hosted runners compared with runners in your own cloud](/guides/hosted-vs-byoc-runners). ## [Limitations](#limitations)Copy Anchor Link The three documented limitations deserve more than a bullet each, because each one changes a decision. **Free credits do not apply.** AWS Marketplace billing does not support free credits. Signup on the direct path includes $10 free credits, which is enough to run a real pipeline for a few hundred build minutes and confirm the runner labels and image contents behave. The practical sequence is to evaluate on a direct account, then subscribe through the marketplace once the technical decision is made. Moving after the evaluation costs nothing extra, because there is no base subscription fee, no platform fee, and no seat fee to duplicate during the overlap. **Helios is unavailable.** You cannot use Helios with AWS Marketplace billing. If your organization already runs Helios, or plans to, keep that product on direct billing and treat the marketplace subscription as covering GitHub Actions runner usage only. **Billing runs daily, one day behind.** Organization usage is billed daily, at 12:15 AM UTC for the previous day. The consequence is a timing gap: the costs on AWS Marketplace might not match the actual costs on the WarpBuild billing dashboard, because the dashboard reflects usage as it is recorded while the marketplace reflects what has been metered through the last daily run. That gap is a reconciliation problem with a repeatable procedure. Here is how to close it. Open the [Reports page](/docs/ci/features/reports) in the WarpBuild dashboard and go to the Billing section, CI tab. Each row is a single job execution with repository, job name, runner label, stack, execution time, billed time, and the cost breakdown between runner and snapshot. Set the date range to a full calendar month in UTC, export the CSV, and sum the cost column. Compare that total against the marketplace charges for the same month. Three rules make the comparison land: * Reconcile whole months in UTC, not partial weeks. A month boundary absorbs the one-day metering lag; a Tuesday-to-Tuesday window does not. * Compare the CI tab, the Docker Builder tab, and the Cache tab separately before you compare totals. Runner minutes, Docker Builder sessions, and cache storage and operations are metered on different units, and a mismatch usually sits in exactly one of them. * Keep the CSV exports. The Reports page supports date range selection, sorting, filtering, search, and CSV export on every tab, and a saved export is the artifact your finance team can check against the invoice months later. For splitting those same rows across teams and repositories, see [attribute GitHub Actions costs to teams and repositories](/guides/github-actions-cost-attribution). ## [Cost](#cost)Copy Anchor Link Marketplace billing meters the same rate card as direct billing. Every number below comes from the published rate card or from GitHub's published list prices, with the arithmetic shown and a checked-on date attached. | WarpBuild runner | Shape | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | Lower list price | | -------------------------- | --------------- | -------------------- | ----------------------------------- | ----------------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | ubuntu-latest, private repositories | $0.006 | 33 percent | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064 | 32-core Linux larger runner | $0.082 | 22 percent | WarpBuild rates come from the [pricing page](/pricing). GitHub list prices were checked on 2026-08-13 against the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), and the shapes against the [GitHub-hosted runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). GitHub gives public repositories a larger free shape; the 2 vCPU comparison above is the private-repository shape that paying teams run. Now the worked model for a mid-sized fleet, using the labels in the workflow file above. Assume 6,000 jobs a month: 600 lint jobs at 3 minutes, 2,400 test shards at 5 minutes, and 3,000 build jobs at 8 minutes. | Job type | Runner label | Minutes per month | WarpBuild cost | Same minutes on the GitHub-hosted equivalent | | ----------- | ------------------------- | ----------------- | -------------- | -------------------------------------------- | | Lint | warp-ubuntu-latest-x64-2x | 1,800 | $7.20 | $10.80 | | Test shards | warp-ubuntu-latest-x64-4x | 12,000 | $96.00 | $144.00 | | Build | warp-ubuntu-latest-x64-8x | 24,000 | $384.00 | $528.00 | | **Total** | **37,800** | **$487.20** | **$682.80** | | The arithmetic: 1,800 minutes at $0.004 is $7.20, 12,000 minutes at $0.008 is $96.00, and 24,000 minutes at $0.016 is $384.00, for $487.20 a month and $5,846.40 a year. The same minute counts on the GitHub-hosted equivalents are $10.80, $144.00, and $528.00, for $682.80 a month and $8,193.60 a year. On a marketplace subscription, the $487.20 lands on your AWS invoice rather than a separate charge, and the $5,846.40 annual figure is the number to take to your AWS account team when you ask about commitment treatment. The [pricing page](/pricing) carries the full rate card, including macOS, Windows, remote Docker builders, cache, and snapshot line items that this model leaves out. Use those rates with measured usage rather than projecting from the example alone. Two account-level line items to plan for. SSO is available for a flat $250 per month, whatever the user count. Confirm with the WarpBuild team which of your line items your marketplace subscription meters before you build the invoice forecast around them. If the reason you are reading this page is an invoice that grew faster than your merge volume, the drivers are broken down in [why is my GitHub Actions bill so high](/answers/why-is-my-github-actions-bill-so-high). Moving the same spend onto an AWS invoice changes who reviews it, so it is worth knowing which jobs generate it first. ## [FAQ](#faq)Copy Anchor Link ### [Do I still get the $10 free credits on AWS Marketplace billing?](#do-i-still-get-the-10-free-credits-on-aws-marketplace-billing)Copy Anchor Link No. AWS Marketplace billing does not support free credits. Signup on the direct path includes $10 free credits, so run the evaluation on a direct account first and move to the marketplace subscription once you have decided. ### [Who can connect an AWS Marketplace subscription to a WarpBuild organization?](#who-can-connect-an-aws-marketplace-subscription-to-a-warpbuild-organization)Copy Anchor Link Only an admin of the WarpBuild organization. If you are not logged in you are prompted to log in first, and if the AWS account is already connected you see a message saying that this AWS account is already linked to a WarpBuild organization. ### [Why do AWS Marketplace charges differ from the WarpBuild billing dashboard?](#why-do-aws-marketplace-charges-differ-from-the-warpbuild-billing-dashboard)Copy Anchor Link WarpBuild bills organization usage daily, at 12:15 AM UTC for the previous day, so the figures on AWS Marketplace might not match the costs on the WarpBuild billing dashboard on any given day. Reconcile over full calendar months. ### [Can I use Helios with AWS Marketplace billing?](#can-i-use-helios-with-aws-marketplace-billing)Copy Anchor Link No. Helios cannot be used with AWS Marketplace billing. Organizations that need Helios stay on direct billing for that product. --- # A Security Review Checklist for GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/enterprise/ci-security-review Description: A runner vendor security review needs five documented answers: job isolation, storage lifetime, secret handling, compliance attestation, and network boundaries. # A Security Review Checklist for GitHub Actions Runners A runner vendor security review needs five documented answers: job isolation, storage lifetime, secret handling, compliance attestation, and network boundaries. Last verified: 2026-08-13 A GitHub Actions runner vendor clears a security review when five questions have documented answers: how jobs are isolated from each other, how long build storage lives, whether the vendor can read your secrets, what compliance attestation backs the claims, and where runner traffic goes. WarpBuild publishes all five answers in its documentation, and this page collects them in the order reviewers usually ask. Below is the question list with the documented answer next to each item, the isolation and storage model in detail, the compliance attestation and how to request it, and the BYOC configuration for teams whose policy requires runners inside their own cloud account. ## [Answer](#answer)Copy Anchor Link Security reviews stall when the answers live in a sales thread rather than in documentation a reviewer can open without asking anyone. Every answer in the table below points at a page you can send to a reviewer directly. | Reviewer question | Documented answer | Where it is documented | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | | How are jobs isolated from each other? | Each runner runs in its own virtual machine. The virtual machines are created on demand and destroyed after each build, and they are never reused. | [Runner security documentation](/docs/ci/security) | | How long does build storage live? | Each runner has its own encrypted storage volume, created on demand and destroyed after each build. When caching is enabled, the cache is encrypted and stored where only your runner can reach it. | [Runner security documentation](/docs/ci/security) | | Can the vendor read our secrets? | WarpBuild does not access or store any build secrets. Secrets stay in your source code repository and are only accessible to your runner environment. | [Runner security documentation](/docs/ci/security) | | What compliance attestation exists? | SOC 2 Type 2 with three Trust Services Criteria: Security, Availability, and Confidentiality. | [trust.warpbuild.com](https://trust.warpbuild.com/) | | Where does runner traffic go? | Runners reach the WarpBuild control plane, GitHub, and the registries your build pulls from. Control plane calls to GitHub leave from three static egress IPs you can allowlist. | [BYOC security hardening](/docs/ci/byoc/aws/security) | | Can runners live in our own cloud account? | Yes. BYOC runs on AWS, GCP, and Azure, and the permissions a runner instance carries are the ones you attach yourself. | [BYOC security hardening](/docs/ci/byoc/aws/security) | Two deployment shapes sit behind those answers, and a reviewer should know which one is under review before reading further. **Hosted runners.** WarpBuild operates the fleet. You change one `runs-on` label per job and the runner comes up on WarpBuild infrastructure. The isolation, storage, and secret-handling answers in the table apply here, and the compliance evidence is the SOC 2 Type 2 report. **BYOC runners.** WarpBuild creates and manages runner instances inside your AWS, GCP, or Azure account. The instances, the network they sit in, and the object storage bucket used for caches and telemetry belong to you. The evidence a reviewer wants comes out of your own cloud console and audit log rather than from a vendor attestation. US and EU are the regions WarpBuild names for hosted runners. Placement detail for teams with a written residency clause is covered on the [US data residency page](/enterprise/data-residency-us). ## [Isolation and Data Handling](#isolation-and-data-handling)Copy Anchor Link The isolation model is a per-job virtual machine with a lifetime equal to the job. Each runner runs in its own virtual machine. The virtual machines are created on demand and destroyed after each build, so builds are isolated from one another and no data is left behind. The virtual machines are ephemeral and are not reused. That single fact answers most of the multi-tenancy section of a review: there is no long-lived host that runs your job today and someone else's job tomorrow, and no warm pool of machines carrying state from a previous run. Storage follows the same lifetime. Each runner has its own encrypted storage volume, created on demand and destroyed after each build. The source checkout, downloaded dependencies, compiled output, and test artifacts all live on that volume and go away when the virtual machine goes away. When caching is enabled to speed up builds, the cache is encrypted and stored in a location that is only accessible to your runner. Secrets never enter the WarpBuild side of the boundary. WarpBuild does not access or store any build secrets. Secrets are stored in your source code repository and are only accessible to your runner environment, which means the secret-handling row on a vendor questionnaire is answered by GitHub's secret storage plus the ephemeral runner, with no additional vault or credential broker introduced by the runner vendor. Reviewers usually want the runner inventory too, since the labels a workflow can reference define what the review covers. The table below lists representative labels with the shape and the published per-minute rate from the [pricing page](/pricing), checked on 2026-08-13. | Runner label | OS | vCPU | RAM | Storage | USD per minute | | --------------------------- | ------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.064 | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.012 | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14 GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28 GB | 270GB SSD | $0.16 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 | | warp-windows-latest-x64-32x | Windows Server 2022 | 32 | 128 GB | 256GB SSD | $0.128 | Every runner in that table gets the same treatment: its own virtual machine, its own encrypted volume, both destroyed when the job ends. The storage column is the size of the ephemeral disk, not a persistent volume that survives the run. A reviewer who wants to see the change under review is usually satisfied by the diff itself, because adopting these runners is a label swap inside the workflow file that is already in your repository and already goes through code review: Copy Text ``` name: ci on: pull_request: push: branches: [main] jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test env: API_TOKEN: ${{ secrets.API_TOKEN }} arm-tests: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - run: ./scripts/test-arm64.sh windows-integration: runs-on: warp-windows-latest-x64-8x steps: - uses: actions/checkout@v4 - run: ./scripts/integration.ps1 ``` The `secrets.API_TOKEN` reference in that job behaves exactly as it does on GitHub-hosted runners. GitHub injects the value into the job environment, the runner uses it, and the virtual machine holding it is destroyed at the end of the job. ## [Compliance](#compliance)Copy Anchor Link WarpBuild is SOC 2 Type 2 certified with three Trust Services Criteria: Security, Availability, and Confidentiality. The controls required for that certification are implemented, and the report plus the supporting security documentation are requested through [trust.warpbuild.com](https://trust.warpbuild.com/). Questionnaires, data processing agreements, and follow-up questions from a review go to [support@warpbuild.com](mailto:support@warpbuild.com). Two practical notes for the person assembling the evidence package. First, an attestation covers the vendor's control environment. It does not describe your workflows, your repository permissions, or the third-party actions your jobs run. A complete review pairs the SOC 2 Type 2 report with your own controls on the GitHub side: who can edit workflow files, which actions are allowed, and how repository secrets are scoped. Second, obligations that arrive from a customer contract or from a GDPR commitment usually turn into three narrow questions for the runner layer: where the machine runs, where the storage sits, and what evidence proves both. Hosted runners answer those through the SOC 2 Type 2 report and the region the fleet runs in. BYOC answers them from artifacts your own account already produces, because the instances and the bucket show up in your inventory, your audit log, and your tagging and cost reports. The rest of the procurement surface is short. SSO is available for a flat $250 per month, whatever the user count, and the current rates are on the [pricing page](/pricing). ## [BYOC and Network Controls](#byoc-and-network-controls)Copy Anchor Link For reviewers whose policy requires runners inside the company's own account, BYOC is the configuration to evaluate. BYOC runs on AWS, GCP, and Azure. Setup is three steps: connect the cloud account, which creates the IAM role or service account WarpBuild uses; create a stack, which fixes the region, network, and object storage bucket; and create a custom runner, which fixes instance types, disks, and IP configuration. The security model documented for AWS has two properties a reviewer cares about. **Ephemeral instances.** Each GitHub Actions job runs on a distinct, freshly provisioned EC2 instance that is terminated after the job completes. There is no shared state and no cross-contamination between jobs. **User-controlled permissions.** The permissions available to runner instances are exactly what you choose to provide through security groups and IAM roles or instance profiles. WarpBuild does not inject additional permissions into runner workloads. The stack provisions networking infrastructure and an S3 bucket used for cache data, and all IAM permissions for runner workloads are managed independently by you through [instance profiles](/docs/ci/byoc/aws/instance-profile). Outbound access is the other half of the network question. The documented destinations a BYOC runner needs are short, which makes an egress allowlist practical: | Destination | Purpose | Required | | -------------------------------------------------------- | ---------------------------------------------------------- | ---------------------- | | api.warpbuild.com | WarpBuild control plane: runner registration and lifecycle | Yes | | api.github.com, github.com | GitHub API and Git operations | Yes | | WarpBuild AWS S3 (HTTPS) | Runner telemetry | Yes | | Package registries and external services your builds use | Dependency downloads and image pulls | Depends on your builds | Inbound access is simpler still. GitHub Actions runners do not need to accept inbound connections at all, so the hardening guidance is to remove every inbound rule from the runner security group and to avoid attaching any group that allows inbound traffic from `0.0.0.0/0` or from the whole VPC range. Lateral movement between runners is handled with Network ACLs on the subnets, since runners have no reason to talk to each other. On the control plane side, calls that WarpBuild makes to GitHub leave from three static egress IPs: `3.13.16.127`, `3.141.157.169`, and `3.151.226.250`. Add them to your GitHub IP allow list, or to the inbound rules of a GitHub Enterprise Server instance, when your organization restricts that traffic. Least-privilege IAM is where most of the remaining review effort goes. An instance profile is configured per runner in the WarpBuild dashboard, so permissions can be scoped per workload type rather than granted to the whole fleet. A job that only needs to push images gets a policy shaped like this: Copy Text ``` { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["ecr:GetAuthorizationToken"], "Resource": "*" }, { "Effect": "Allow", "Action": [ "ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:PutImage", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart", "ecr:CompleteLayerUpload" ], "Resource": "arn:aws:ecr:::repository/" } ] } ``` The remaining hardening steps from the [BYOC security hardening guide](/docs/ci/byoc/aws/security) are worth copying into a review checklist as they are: deploy the stack in a dedicated VPC or a dedicated AWS account so the blast radius and the audit boundary are both small; require IMDSv2 on every runner to protect instance credentials against SSRF; scope S3 bucket lifecycle policies so cache data expires; and tag stack resources for cost attribution and access control. Terraform support exists for BYOC on AWS, which turns the placement and the permissions into reviewable code rather than a console click, and it gives a reviewer a diff to sign off on when the configuration changes later. A short evaluation path usually beats a long questionnaire. Sign up, use the $10 free credits to run one real pipeline on hosted runners, and confirm the labels and images fit your builds while the security review runs in parallel. If the review lands on runners inside your own account, move the same workflow to a BYOC runner in [AWS](/byoc/aws) and let the evidence come from your own console. The narrower questions about source code handling are answered on [do GitHub Actions runners store my source code](/answers/do-github-actions-runners-store-my-source-code), and the general version of this topic is on [are GitHub Actions runners secure](/answers/are-github-actions-runners-secure). ## [FAQ](#faq)Copy Anchor Link ### [Does WarpBuild have access to our build secrets?](#does-warpbuild-have-access-to-our-build-secrets)Copy Anchor Link No. WarpBuild does not access or store any build secrets. Secrets stay in your source code repository and are reachable only from your runner environment while the job runs. ### [How long does build storage live on a WarpBuild runner?](#how-long-does-build-storage-live-on-a-warpbuild-runner)Copy Anchor Link For the length of the build. Each runner has its own encrypted storage volume, created on demand and destroyed after each build, and the virtual machine around it is destroyed at the same time. ### [What compliance attestation can WarpBuild provide to a reviewer?](#what-compliance-attestation-can-warpbuild-provide-to-a-reviewer)Copy Anchor Link SOC 2 Type 2 with three Trust Services Criteria: Security, Availability, and Confidentiality. Request the report and the supporting security documentation at [trust.warpbuild.com](https://trust.warpbuild.com/). ### [Can runners stay inside our own cloud account?](#can-runners-stay-inside-our-own-cloud-account)Copy Anchor Link Yes. BYOC runs on AWS, GCP, and Azure. Each job runs on a freshly provisioned instance in your account that is terminated when the job finishes, and the permissions it carries are the ones you attach through your own IAM roles and security groups. --- # EU Data Residency for GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/enterprise/data-residency-eu Description: WarpBuild places GitHub Actions runners in the EU two ways: a hosted EU region on the enterprise tier, or BYOC runners in your own AWS or GCP account. # EU Data Residency for GitHub Actions Runners WarpBuild places GitHub Actions runners in the EU two ways: a hosted EU region on the enterprise tier, or BYOC runners in your own AWS or GCP account. Last verified: 2026-08-13 WarpBuild runs GitHub Actions runners in the EU through two paths: WarpBuild-hosted cloud runners deployed on region-specific infrastructure in the EU on the enterprise tier, and BYOC runners that boot inside an EU region of your own AWS or GCP account. Both paths keep the data a build touches inside the region you pick, while the WarpBuild control plane keeps doing the orchestration work of queueing jobs, starting and destroying runner VMs, and metering usage. This page states which runner platforms map onto each path, what stays in region and what the control plane holds, what an EU workload costs per month, and which evidence a residency review can collect. Runner labels and rates come from the WarpBuild runner catalog and [pricing page](/pricing), checked on 2026-08-13. ## [Answer](#answer)Copy Anchor Link A residency question about GitHub Actions usually arrives as one sentence from a security reviewer: the build machines that check out our source code have to sit in the EU. Answering it means naming the region, naming what is stored there, and naming who else holds a copy. WarpBuild names two regions publicly, US and EU. Any statement narrower than that (a country, a specific cloud region code) belongs in a conversation with support rather than on a public page, because the answer depends on which path you take and which cloud account you bring. Here are the paths, with the honest per-cloud state of each. | Path | Where runner VMs execute | Where build data lands | How to enable | | -------------------------- | -------------------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | | WarpBuild-hosted EU region | WarpBuild infrastructure in the EU | Caches, artifacts, and logs stay in that region | Enterprise tier. Ask support to deploy your org on region-specific infrastructure | | BYOC on AWS | EC2 instances in an EU region of your AWS account | An S3 bucket in the same region as the stack | Connect the account, create a stack, create custom runners. Terraform support exists for BYOC on AWS | | BYOC on GCP | Compute Engine instances in an EU region of your project | A Cloud Storage bucket in the same region as the stack | Connect the project, create a stack, create custom runners | | BYOC on Azure | East US only at present | Storage account in the stack region | Contact [support@warpbuild.com](mailto:support@warpbuild.com) for regions beyond East US | BYOC is the strongest residency answer available, because the runner VM, the disk under it, and the object storage bucket that holds caches and artifacts are all resources in your own cloud account, created in a region you chose, visible in your own console and your own bill. BYOC runs on AWS, GCP, and Azure, and the AWS path is the most complete one: see [running GitHub Actions runners in your own AWS account](/byoc/aws) for the account connection, the stack, and the Terraform module. The Azure caveat is real and worth stating plainly: the WarpBuild documentation records that BYOC on Azure is currently enabled for East US only, so an Azure-only estate has no EU BYOC path today without talking to support. See [BYOC on Azure and its current region limits](/byoc/azure) for that constraint in context. If you cannot run runners in your own cloud account, the hosted EU region on the enterprise tier is the other answer, and it needs no infrastructure work from your side. ## [Regions and Availability](#regions-and-availability)Copy Anchor Link The catalog records two regions for the hosted fleet, US and EU. Placement differs by platform, and one platform carries a documented exception. | Platform | Labels in the catalog | Sizes | EU placement | | ----------- | ------------------------------------------------------------ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | Linux x64 | 15 labels across Ubuntu 22.04, 24.04, and 26.04 | 2, 4, 8, 16, and 32 vCPU | Hosted EU region on the enterprise tier, or BYOC on AWS or GCP | | Linux ARM64 | 10 labels across Ubuntu 24.04 and 26.04 | 2, 4, 8, 16, and 32 vCPU | Hosted EU region on the enterprise tier, or BYOC on AWS or GCP | | Windows | 12 labels across Windows Server 2022 and Windows Server 2025 | 4, 8, 16, and 32 vCPU | Hosted EU region on the enterprise tier, or BYOC on AWS. BYOC Windows is offered on AWS and Azure, and Azure is East US only today | | macOS | 5 labels across macOS 14, macOS 15, and macOS 26 | 6 and 12 vCPU | The cloud runners documentation states that region-specific infrastructure does not apply to macOS instances | The macOS exception matters for iOS and Mac teams. Xcode jobs keep running on the shared macOS fleet, which means a residency scope that includes mobile builds needs a decision about those jobs specifically. Most reviews handle this by scoping residency to the repositories that carry regulated data and leaving public or sample-app builds on the shared fleet. Bring that split to [support@warpbuild.com](mailto:support@warpbuild.com) before signing anything. ### [Workflow labels do not change when the region does](#workflow-labels-do-not-change-when-the-region-does)Copy Anchor Link Region placement happens at the account and stack level, so the workflow file keeps the same `warp-` labels. A three-platform matrix looks the same before and after an EU move. .github/workflows/ci.yml Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: unit-tests: strategy: fail-fast: false matrix: include: - platform: linux-x64 runner: warp-ubuntu-latest-x64-8x - platform: linux-arm64 runner: warp-ubuntu-latest-arm64-8x - platform: windows runner: warp-windows-latest-x64-8x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test ``` BYOC changes the label, because a custom runner registers under the runner ID shown on the custom runners page, always prefixed with `warp-custom-`. Name the stack after its purpose so the workflow file reads as documentation of where the job runs. .github/workflows/release.yml Copy Text ``` name: release on: push: tags: ["v*"] jobs: build: runs-on: warp-custom-eu-release-16x steps: - uses: actions/checkout@v4 - name: Build release artifacts run: make release - name: Push image to the in-region registry run: | docker build -t "$REGISTRY/app:${GITHUB_REF_NAME}" . docker push "$REGISTRY/app:${GITHUB_REF_NAME}" env: REGISTRY: ${{ secrets.CONTAINER_REGISTRY }} ``` A stack pins the region, the VPC, and the object storage bucket that holds cache, container image layer cache, logs, and workflow artifacts. The stack name, the storage location, and the region cannot be changed after creation, so pick the region during the review rather than after it. The [BYOC setup documentation](/docs/ci/byoc) walks the three steps: connect the cloud account, create the stack, create the custom runner. ### [Cost model for an EU workload](#cost-model-for-an-eu-workload)Copy Anchor Link Take a team running 300,000 Linux minutes and 40,000 Windows minutes a month, with 200 GB of cache in storage. Rates below are WarpBuild list prices from the pricing page, checked on 2026-08-13. | Line item | Rate | Volume | Monthly | | -------------------------- | ------------------ | --------------- | ------- | | warp-ubuntu-latest-x64-4x | $0.008 per minute | 300,000 minutes | $2,400 | | warp-windows-latest-x64-4x | $0.016 per minute | 40,000 minutes | $640 | | Cache storage | $0.20 per GB-month | 200 GB | $40 | | Base subscription fee | $0 | $0 | | | Platform fee | $0 | $0 | | | Seat fee | $0 | $0 | | | Total | $3,080 | | | The same volumes on GitHub-hosted larger runners come to $4,480 per month at GitHub list prices: 300,000 minutes on the 4-core Linux larger runner at $0.012 per minute is $3,600, and 40,000 minutes on the 4-core Windows larger runner at $0.022 per minute is $880 ([GitHub billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). Per runner, warp-ubuntu-latest-x64-4x at $0.008 per minute against $0.012 is 33 percent lower list price, and warp-windows-latest-x64-4x at $0.016 per minute against $0.022 is 27 percent lower list price. On the BYOC path the WarpBuild fee is $0.002 per minute for both Linux and Windows runners, so the same 340,000 minutes carry $680 in WarpBuild fees, and the EC2 or Compute Engine time lands on your own cloud bill at whatever rates your account already has. BYOC pricing includes the add-ons. Choose between hosted and BYOC placement from the residency requirements first, then estimate cost from measured job duration and the applicable runner or cloud rates. Full rates by runner type are on the [WarpBuild pricing page](/pricing). ## [What Stays in Region](#what-stays-in-region)Copy Anchor Link A residency review needs the data classes named one at a time. Here is the split. **Stays in the region you pick.** The source checkout on the runner disk. The dependency and build cache. Workflow artifacts written during the job. Job logs collected by the runner. Container image layer cache. On BYOC, all of this lives in the object storage bucket that belongs to your stack, in your account, in the stack region. On the hosted EU region, the enterprise deployment keeps the data you choose inside that region. **Handled by the control plane.** Scheduling and runner lifecycle. The control plane sees which job is queued, which runner label it asked for, when a VM starts, and when it terminates. It records usage counters for billing, plus the configuration you set in the dashboard: runner definitions, stack settings, org and repository connections. This is the orchestration metadata that makes a managed runner fleet work, and it is what the enterprise conversation should scope explicitly. **Held by GitHub.** GitHub stores your repository, the workflow definitions, the secrets, and the log stream the runner sends back. A runner is a machine that polls GitHub and streams results to GitHub, so a residency posture for GitHub Actions is always two decisions: where the runner executes, and where your GitHub plan puts the repository. Teams on GitHub Enterprise data residency pair that with in-region runners to close the loop. **Never stored by WarpBuild.** Build secrets. Secrets live in your source code repository and reach only the runner environment. Each runner runs in its own virtual machine, created on demand and destroyed after the build, and each runner gets its own encrypted storage volume that is destroyed with it. Caches are encrypted and stored in a location accessible only to your runner. The [security documentation](/docs/ci/security) states these controls and lists the control plane egress IPs, which is the list to hand your network team if a GitHub IP allow list or a GitHub Enterprise Server instance restricts inbound traffic. Egress charges from your cloud during deploy-heavy jobs are handled separately on the enterprise tier; see [zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress). If your workloads sit in the US instead, the same structure applies with the region flipped: see [US data residency for GitHub Actions runners](/enterprise/data-residency-us). ## [Compliance](#compliance)Copy Anchor Link WarpBuild holds SOC 2 Type 2, with [trust.warpbuild.com](https://trust.warpbuild.com/) as the linked evidence. The attestation covers three Trust Services Criteria: Security, Availability, and Confidentiality. Request the report and the supporting security documentation through the trust center, or write to [support@warpbuild.com](mailto:support@warpbuild.com) to work through a specific control question. Data residency and GDPR are separate questions that arrive together. Your Article 28 review asks where personal data in a build (commit authors in git history, email addresses in test fixtures, customer records in seed data) is processed and stored. Answering it needs a named region for the runner and its storage, a named holder for each remaining data class, and a signed agreement covering the processing. The first two are what this page gives you. Bring the third to [support@warpbuild.com](mailto:support@warpbuild.com), because the terms depend on which path you take. A practical checklist for the review: * Name the path. Hosted EU region on the enterprise tier, or BYOC in your own EU account on AWS or GCP. * Name the platforms in scope. Linux x64, Linux ARM64, and Windows map onto EU placement; macOS jobs need a separate decision, since region-specific infrastructure does not apply to macOS instances. * Pin the stack region before creation. The stack name, storage location, and region are fixed once the stack exists. * Collect the SOC 2 Type 2 report from the trust center. * Get the control plane egress IPs into your allow list if GitHub access is restricted. * Capture the configuration as code where you can. Terraform support exists for BYOC on AWS, which turns the residency configuration into a reviewable pull request. Two operational items usually close out the process. SSO is available for a flat $250 per month, whatever the user count, listed with the rest of the rates on the [pricing page](/pricing). The [customers page](/customers) carries the case studies behind those names. ## [FAQ](#faq)Copy Anchor Link **Can WarpBuild run GitHub Actions runners in the EU?** Yes. Two paths exist. On the enterprise tier, WarpBuild-hosted cloud runners can be deployed on region-specific infrastructure in the EU. With BYOC, runners start in an EU region of your own AWS or GCP account, and the cache bucket sits in that same region. US and EU are the regions WarpBuild names publicly. **Which runner platforms can I place in the EU?** Linux x64, Linux ARM64, and Windows labels are the ones that map onto EU placement. The cloud runners documentation states that region-specific infrastructure does not apply to macOS instances, so macOS jobs stay on the shared macOS fleet. **Does BYOC give me EU data residency on Azure?** Not today. The WarpBuild documentation states that BYOC on Azure is currently enabled for East US only, and points teams that need other regions to [support@warpbuild.com](mailto:support@warpbuild.com). For an EU BYOC footprint right now, use AWS or GCP. **What compliance evidence can I hand my auditor?** WarpBuild holds SOC 2 Type 2, with trust.warpbuild.com as the linked evidence, covering Security, Availability, and Confidentiality. Request the report and the supporting security documentation through the trust center. --- # US Data Residency for GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/enterprise/data-residency-us Description: WarpBuild runs GitHub Actions runners in US regions. Pin build data to a US region on enterprise plans, or run BYOC runners in your own US account. # US Data Residency for GitHub Actions Runners WarpBuild runs GitHub Actions runners in US regions. Pin build data to a US region on enterprise plans, or run BYOC runners in your own US account. Last verified: 2026-08-13 WarpBuild gives GitHub Actions two ways to keep build data inside a US region: hosted runners on region-specific infrastructure, available on enterprise plans, and BYOC runners that launch inside your own US cloud account. US and EU are the regions WarpBuild names for hosted runners, and BYOC moves the placement decision into infrastructure you already own and already audit. This page covers what runs where, which runner labels are available, what the control plane still handles outside the region, and what the in-region minutes cost against GitHub's published list prices. ## [Answer](#answer)Copy Anchor Link Data residency for a runner fleet is a question about four things: the machine that checks out your source, the disk that machine writes to, the store that holds caches and build artifacts between jobs, and the logs that record what happened. A residency commitment is only meaningful when all four have a known location. WarpBuild answers that in two configurations. **Hosted runners on region-specific infrastructure.** WarpBuild operates the runner fleet. On enterprise plans, cloud runners can be deployed on region-specific infrastructure, and any data you choose remains in that specific region. This is the lower-operations path: you keep the one-line `runs-on` change and add a region constraint underneath it. The [cloud runners documentation](/docs/ci/cloud-runners) carries the same callout, and the option is enabled through support rather than through a self-serve toggle. **BYOC runners in your own cloud account.** WarpBuild creates and manages runner instances inside your AWS, GCP, or Azure account. BYOC runs on AWS, GCP, and Azure. The instances, the VPC, and the object storage bucket that holds caches, container image layer caches, logs, and other workflow artifacts all live in the region of your account where you created the WarpBuild stack. This is the strongest residency answer because the placement is enforced by your own cloud account boundary, and the evidence for an auditor comes from your own cloud console rather than from a vendor attestation. Both configurations run the same runner images and the same `warp-` labels. The choice is about placement and operations; the workflow syntax is identical either way. | Configuration | Where the runner VM runs | Where caches, artifacts, and logs live | Who bills the compute | | ----------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------- | | GitHub-hosted runners | GitHub infrastructure, placement chosen by GitHub | GitHub storage | GitHub, per minute | | WarpBuild hosted runners | WarpBuild infrastructure, US or EU | WarpBuild storage in that region | WarpBuild, per minute | | WarpBuild hosted runners on region-specific infrastructure (enterprise) | The region you choose | The region you choose | WarpBuild, per minute | | WarpBuild BYOC runners | Your cloud account, in your stack region | Your stack object storage bucket, same region | Your cloud provider, plus $0.002 per minute to WarpBuild | One thing sits outside the region in every row: the control plane that talks to GitHub. That boundary is described in full under [what stays in region](#what-stays-in-region), because it is the part procurement reviews ask about and the part a diagram usually gets wrong. ## [Regions and Availability](#regions-and-availability)Copy Anchor Link The catalog is the same set of labels regardless of which residency path you take, with one exception worth stating early: region-specific infrastructure does not apply to macOS instances, so a US residency commitment that has to cover Xcode builds needs a separate conversation with [support@warpbuild.com](mailto:support@warpbuild.com). | Platform | Label family | Sizes | Rate range per minute | Residency notes | | ----------- | ---------------------------------------------------------------------- | -------------------------- | --------------------- | ---------------------------------------------------------------- | | Linux x64 | warp-ubuntu-latest-x64-\*, plus 22.04, 24.04, and 26.04 label variants | 2 to 32 vCPU, 8 to 128 GB | $0.004 to $0.064 | Hosted in US or EU; available on BYOC across AWS, GCP, and Azure | | Linux ARM64 | warp-ubuntu-latest-arm64-\*, plus 24.04 and 26.04 label variants | 2 to 32 vCPU, 8 to 128 GB | $0.003 to $0.048 | Hosted in US or EU; available on BYOC | | macOS | warp-macos-26-arm64-\*, warp-macos-15-arm64-\*, warp-macos-14-arm64-6x | 6 or 12 vCPU, 22 or 28 GB | $0.08 to $0.16 | Region-specific infrastructure does not apply to macOS instances | | Windows | warp-windows-latest-x64-\*, plus 2025 and 2025 VS 2026 label variants | 4 to 32 vCPU, 16 to 128 GB | $0.016 to $0.128 | Hosted in US or EU; available on BYOC through AWS and Azure | The per-size rates below come from the runner catalog. Storage on every runner is ephemeral and is deleted when the runner terminates, which matters for residency because nothing persists on the machine after the job. | Runner label | OS | vCPU | RAM | Storage | USD per minute | | ---------------------------- | ------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.064 | | warp-ubuntu-latest-arm64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.003 | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.006 | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.012 | | warp-ubuntu-latest-arm64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.024 | | warp-ubuntu-latest-arm64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.048 | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14 GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28 GB | 270GB SSD | $0.16 | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16 GB | 256GB SSD | $0.016 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64 GB | 256GB SSD | $0.064 | | warp-windows-latest-x64-32x | Windows Server 2022 | 32 | 128 GB | 256GB SSD | $0.128 | A workflow that runs across all four platforms uses the same labels whether or not a residency constraint is attached to the account: Copy Text ``` name: ci on: pull_request: push: branches: [main] jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test arm-image: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - run: docker build --platform linux/arm64 -t app:arm64 . windows-integration: runs-on: warp-windows-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/integration.ps1 ios-build: runs-on: warp-macos-26-arm64-6x steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build ``` ### [BYOC placement mechanics](#byoc-placement-mechanics)Copy Anchor Link BYOC setup is three steps: connect the cloud account, which creates the IAM role or service account WarpBuild uses; create a stack, which is the region, VPC, subnets, and object storage bucket the runners use; and create a custom runner, which fixes the instance types, disks, and IP configuration. The stack name, the object storage location, and the region cannot be changed after creation, so the residency decision is made once, at stack creation, and is visible in your own account from then on. The full flow is in the [BYOC documentation](/docs/ci/byoc). Two details matter for a US-only commitment. The import flow, which places runners inside a VPC you already operate, is supported only on AWS, so teams that need runners inside an existing audited network usually start there. Terraform support exists for BYOC on AWS, which is how the placement becomes reviewable code rather than a console click. ### [What in-region minutes cost](#what-in-region-minutes-cost)Copy Anchor Link Residency conversations stall on cost more often than on architecture, so here is a worked model for a mid-sized fleet. The GitHub prices come from GitHub's [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the shapes from GitHub's [hosted runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), both checked on 2026-08-13\. WarpBuild rates come from the runner catalog above. Assume a month of 60,000 Linux minutes on a 4 vCPU shape, 8,000 Windows minutes on a 4 vCPU shape, and 3,000 macOS minutes. | Workload | Minutes | WarpBuild label and rate | WarpBuild cost | GitHub-hosted equivalent and rate | GitHub cost | | --------------------- | ------- | --------------------------------------------------- | -------------- | ------------------------------------------------------------------ | ----------- | | Linux 4 vCPU, 16 GB | 60,000 | warp-ubuntu-latest-x64-4x at $0.008 | $480.00 | 4-core Linux larger runner at $0.012 | $720.00 | | Windows 4 vCPU, 16 GB | 8,000 | warp-windows-latest-x64-4x at $0.016 | $128.00 | 4-core Windows larger runner at $0.022 | $176.00 | | macOS ARM64 | 3,000 | warp-macos-latest-arm64-6x (6 vCPU, 14 GB) at $0.08 | $240.00 | Largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB) at $0.102 | $306.00 | | Total | 71,000 | $848.00 | $1,202.00 | | | The monthly totals are $848.00 on WarpBuild against $1,202.00 on the GitHub-hosted equivalents, a difference of $354.00 per month or $4,248 per year at these volumes. The macOS row compares the nearest ARM64 shape GitHub publishes; the WarpBuild runner in that row carries one more vCPU and the same memory. The BYOC line works differently. BYOC Linux and Windows runners carry a $0.002 per minute WarpBuild fee, and the compute itself lands on your own cloud bill at whatever rates your account already has, including any committed-use or savings-plan discounts you have negotiated. The same 60,000 Linux minutes cost $120.00 in WarpBuild fees, with the instance hours billed to your account. Add-on line items are included in the BYOC fee. Choose between hosted and BYOC placement from the residency requirements first. For a cost estimate, combine measured job duration with the applicable cloud rates and WarpBuild fees on the [pricing page](/pricing). For hosted runners, the add-ons bill separately and at published rates: cache storage at $0.20 per GB-month, cache write or restore operations at $0.0001 each, snapshot restore at $0.04 per job, snapshot storage at $0.025 per snapshot-hour, and networking through Tailscale at $0. ## [What Stays in Region](#what-stays-in-region)Copy Anchor Link Split the system into two halves: the data plane, which is the runner and the storage it touches, and the control plane, which is the coordination layer that talks to GitHub. Residency applies to the first half. The second half handles registration and lifecycle and never becomes the resting place for your build data. **Stays in the region you chose.** The runner VM and its encrypted, ephemeral disk. The source checkout, dependency downloads, compiled outputs, and test artifacts written during the job. The cache entries that jobs write and restore. Container image layer caches for Docker builds. Workflow artifacts and logs written to the stack object storage bucket on BYOC, which is created in the stack region and cannot be moved afterward. **Handled by the control plane.** Registering runners with GitHub, managing runner lifecycle, and making GitHub API calls on your behalf. Those requests leave from three static egress IPs, documented as `3.13.16.127`, `3.141.157.169`, and `3.151.226.250`, which is the list to add if your organization uses a GitHub IP allow list or restricts inbound traffic to a GitHub Enterprise Server instance. The control plane also holds the job metadata behind the dashboard: repository, job name, runner label, stack, execution time, billed time, and cost per job execution, which is what the [Reports page](/docs/ci/features/reports) renders. **Held by GitHub.** Your repository contents, workflow run logs as GitHub stores them, encrypted secrets, and anything uploaded with `actions/upload-artifact` sit in GitHub's own systems under GitHub's terms. WarpBuild does not access or store build secrets; secrets live in your repository and are only accessible to your runner environment. A complete residency posture pairs in-region runners with whatever residency GitHub itself offers for the account, which is a GitHub product decision rather than a runner-layer one. The isolation model underneath all of this is described in the [runner security documentation](/docs/ci/security). Each runner runs in its own virtual machine, created on demand and destroyed after each build. Each runner gets its own encrypted storage volume, also created on demand and destroyed after the build. When caching is enabled, the cache is encrypted and stored in a location that is only accessible to your runner. Nothing is reused across builds, so there is no long-lived disk in any region holding a previous customer's or a previous job's data. On BYOC the whole data plane collapses into your account. A workflow points at the custom runner by its runner ID, which carries the `warp-custom-` prefix: Copy Text ``` name: release on: push: tags: ['v*'] jobs: build: runs-on: warp-custom-us-linux-8x steps: - uses: actions/checkout@v4 - name: Build release image run: docker build -t "$REGISTRY/app:${GITHUB_REF_NAME}" . env: REGISTRY: ${{ vars.ECR_REGISTRY }} - name: Push to ECR run: docker push "$REGISTRY/app:${GITHUB_REF_NAME}" env: REGISTRY: ${{ vars.ECR_REGISTRY }} ``` That job runs on an EC2 instance in your VPC, pulls from and pushes to a registry in the same account, and writes its cache to a bucket you own. The network path never leaves your account boundary, which is the property most US residency clauses are actually asking for. Egress costs on the enterprise tier are covered separately on [zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress). One placement caveat repeats here because it decides architectures: region-specific infrastructure does not apply to macOS instances, and BYOC pricing covers Linux and Windows runners. Teams with a strict US-only rule and an iOS pipeline should raise that with support before committing to a design. ## [Compliance](#compliance)Copy Anchor Link WarpBuild is SOC 2 Type 2, with [trust.warpbuild.com](https://trust.warpbuild.com/) as the linked evidence. The attestation covers three Trust Services Criteria: Security, Availability, and Confidentiality. Security documentation and the report itself are requested through the trust center, and [support@warpbuild.com](mailto:support@warpbuild.com) handles questionnaire and DPA review. Regulatory frameworks belong to your compliance program. The runner layer supplies inputs to it. If your obligations come from a customer contract that names US processing, from a HIPAA program, or from GDPR commitments that constrain where processing happens, the useful question to ask a runner vendor is narrow: where does the machine run, where does the storage sit, and what evidence proves it. BYOC answers all three from artifacts you already produce for auditors, because the instances and buckets appear in your own account inventory, your own CloudTrail or equivalent audit log, and your own tagging and cost reports. Region-specific infrastructure on enterprise plans answers them through WarpBuild's own controls plus the SOC 2 Type 2 report. The rest of the enterprise procurement surface is straightforward. SSO is available for a flat $250 per month, whatever the user count; see the [pricing page](/pricing) for the rest of the rates. For a US residency evaluation, the practical sequence is short: start on hosted runners with $10 free credits from signup, run one real pipeline to confirm the labels and images fit, then decide between region-specific infrastructure and BYOC based on whether the evidence needs to come from your cloud account or from a vendor report. The [EU residency page](/enterprise/data-residency-eu) covers the same ground for European placement, and the BYOC setup guides for [AWS](/byoc/aws), [GCP](/byoc/gcp), and [Azure](/byoc/azure) cover the account connection in detail. ## [FAQ](#faq)Copy Anchor Link ### [Which regions does WarpBuild name for hosted runners?](#which-regions-does-warpbuild-name-for-hosted-runners)Copy Anchor Link US and EU. On enterprise plans, cloud runners can be deployed on region-specific infrastructure so the data you choose remains in that region. Region-specific infrastructure does not apply to macOS instances. ### [What is the strongest US residency answer WarpBuild offers?](#what-is-the-strongest-us-residency-answer-warpbuild-offers)Copy Anchor Link BYOC. The runner instances and the stack object storage bucket live in your own AWS, GCP, or Azure account, in the region you select when you create the stack, and the region cannot be changed afterward. ### [Does the WarpBuild control plane sit inside my region?](#does-the-warpbuild-control-plane-sit-inside-my-region)Copy Anchor Link No. The control plane registers runners with GitHub, manages their lifecycle, and calls the GitHub API from three static egress IPs. Job data stays on the runner and in your in-region storage. ### [Can WarpBuild support a US-only audit requirement?](#can-warpbuild-support-a-us-only-audit-requirement)Copy Anchor Link WarpBuild is SOC 2 Type 2 with three Trust Services Criteria, and the report is available at [trust.warpbuild.com](https://trust.warpbuild.com/). Pair it with BYOC placement evidence from your own cloud account. --- # Runners for GitHub Enterprise Server and Cloud | WarpBuild URL: https://www.warpbuild.com/enterprise/github-enterprise-runners Description: WarpBuild supports GitHub Enterprise Server and GitHub Enterprise Cloud with data residency on the enterprise plan, through a GitHub App you register yourself. # Runners for GitHub Enterprise Server and Cloud WarpBuild supports GitHub Enterprise Server and GitHub Enterprise Cloud with data residency on the enterprise plan, through a GitHub App you register yourself. Last verified: 2026-08-13 WarpBuild supports GitHub Enterprise Server and GitHub Enterprise Cloud with data residency on the enterprise plan, through a GitHub App that you register on your own enterprise and then connect to WarpBuild. The app is registered once at the enterprise level and shared across the organizations inside that enterprise, so the second organization to connect runs the install step and nothing else. This page covers which GitHub deployments need the flow, how the shared app model works, what to gather before you start, the setup steps in the order the dialog asks for them, and how to rotate credentials later without reinstalling the app. ## [Answer](#answer)Copy Anchor Link Three GitHub deployment shapes show up in enterprise runner conversations, and they take different paths. | Your GitHub deployment | Host example | Enterprise app flow | Plan | | ------------------------------------------- | --------------- | ------------------- | ------------------------------------------ | | GitHub Enterprise Server, self-hosted | github.acme.com | Required | Enterprise plan | | GitHub Enterprise Cloud with data residency | acme.ghe.com | Required | Enterprise plan | | Enterprise account on github.com | github.com | Optional | The default install flow works on any plan | For the first two rows, WarpBuild cannot use the shared default app, because that app lives on `github.com` and your enterprise does not. You register a GitHub App on your own enterprise instead. WarpBuild supplies the manifest, so GitHub prefills the app name, webhook URL, callback URL, permissions, and events when you confirm the creation page. Your enterprise owns the resulting app, its credentials, and its installation scope. For the third row, the enterprise app flow stays available for teams who want their own app rather than the shared one, and the [quick start](/docs/ci/quick-start) path remains the shorter option. Two things stay the same across all three rows. The runner labels do not change: a job that runs on `warp-ubuntu-latest-x64-4x` today keeps the same `runs-on` value after the enterprise connection. The runner images do not change either, so the preinstalled toolchain your jobs expect behaves the same way on an enterprise-connected organization as it does on a `github.com` organization. GitHub Enterprise support is an enterprise plan feature and the setup runs alongside a call with the WarpBuild team, which is where the plan, the host details, and any network constraints get sorted out in one pass. [Schedule a call](https://cal.com/suryao/start) to start that process, and read the [GitHub Enterprise App documentation](/docs/ci/ghe) for the screen-by-screen version of the flow below. ## [How It Works](#how-it-works)Copy Anchor Link ### [The app is registered once per enterprise](#the-app-is-registered-once-per-enterprise)Copy Anchor Link The app is created at the enterprise level and shared across every organization in that enterprise. The sequence looks like this. 1. The first organization from your enterprise, say `acme`, opens the setup dialog, enters the enterprise slug and host, creates the app on your enterprise, and installs it on that organization. 2. A later organization from the same `acme` enterprise enters the same enterprise slug and host. WarpBuild detects the existing app and shows an existing-app notice in the dialog. No second app is created. 3. That organization runs the install step against its own repositories and lands back in WarpBuild with the integration marked Connected. The app creation and credential steps happen once per enterprise. Every organization after the first is an install, which takes a minute and needs admin rights on the target organization rather than enterprise owner access. This matters for planning because the two roles are usually different people. An enterprise owner is needed once, at the beginning. Organization admins handle everything after that, and they do not need to re-enter credentials or generate new keys. ### [What the app is used for](#what-the-app-is-used-for)Copy Anchor Link The WarpBuild control plane uses the app to register runners with GitHub, manage runner lifecycle, and make GitHub API calls on your behalf. Those outbound requests leave from three static IP addresses, which is why an enterprise with an IP allow list has to add them before the connection works. The permissions and events on the app come from the manifest. Leave them as created. Editing them after the fact can cause job runs to fail in ways that are hard to trace back to the change, because the failure shows up as a runner that never registers rather than as a permissions error. The isolation model underneath is the same one every WarpBuild runner uses, and it is documented on the [runner security page](/docs/ci/security). Each runner runs in its own virtual machine, created on demand and destroyed after each build. Each runner gets its own encrypted storage volume with the same lifetime. WarpBuild does not access or store build secrets; secrets stay in your repository and are reachable only from the runner environment while the job runs. ### [What the connected organizations can run](#what-the-connected-organizations-can-run)Copy Anchor Link Once an organization is connected, it gets the full runner catalog. Rates below come from the [WarpBuild pricing page](/pricing), checked on 2026-08-13. | Runner label | OS | vCPU | RAM | Storage | USD per minute | | ---------------------------- | ------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.064 | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.006 | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.012 | | warp-ubuntu-latest-arm64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.024 | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14 GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28 GB | 270GB SSD | $0.16 | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16 GB | 256GB SSD | $0.016 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64 GB | 256GB SSD | $0.064 | Placement options are unchanged by the enterprise connection. US and EU are the regions WarpBuild names for hosted runners. BYOC runs on AWS, GCP, and Azure, so an enterprise that needs runner instances inside its own cloud account can point a GHES organization at BYOC runners the same way it would on `github.com`. Terraform support exists for BYOC on AWS, which keeps the runner definitions in the same review path as the rest of your infrastructure code. ## [Prerequisites](#prerequisites)Copy Anchor Link Gather five things before opening the setup dialog. 1. **Enterprise owner access** to your GHE instance. Registering an enterprise-level app requires it, and no lower role can substitute. 2. **Your enterprise slug**, which is the path segment in `https:///enterprises/`. 3. **Your enterprise host**, for example `acme.ghe.com` for GitHub Enterprise Cloud with data residency or `github.acme.com` for GitHub Enterprise Server. 4. **A WarpBuild account** on the enterprise plan. 5. **Allowlisted control plane egress IPs**, if your enterprise uses a GitHub IP allow list or restricts inbound traffic to a GitHub Enterprise Server instance. The control plane egress IPs are static and documented: Copy Text ``` 3.13.16.127 3.141.157.169 3.151.226.250 ``` Add these to your [GitHub IP allow list](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/managing-allowed-ip-addresses-for-your-organization) or to the inbound rules in front of your GHES instance. You add them; WarpBuild has no way to edit your allow list. Teams that skip this step usually see the setup link fail to generate or the app create successfully and then never register a runner, because the control plane request to your host is dropped at the network edge rather than rejected by GitHub. Two more items are worth confirming in the same sitting. WarpBuild has to be able to reach your enterprise host over the network, which is the common blocker for GHES instances behind a corporate VPN with no public ingress. And the organization you plan to install against needs an admin who can approve the installation and pick the repositories it covers. ## [Setup](#setup)Copy Anchor Link The dialog lives in the WarpBuild dashboard under Settings, then Account. Under Products, then Runners, click the chevron next to Setup Runners and choose Setup for GHE. The [GitHub Enterprise App documentation](/docs/ci/ghe) carries the same steps with screenshots. 1. **Enter your enterprise details.** Fill in the enterprise slug and the host. The host auto-fills as `.ghe.com`, so edit it for GHES, for example to `github.acme.com`. If no app exists for the host, the form expands with credential fields. If one exists already, the dialog shows the existing-app notice and you skip ahead to the install step. 2. **Create the GitHub App on your enterprise.** Click the link in the dialog. GitHub opens the app creation page on your enterprise with the manifest applied, covering the name, webhook URL, callback URL, permissions, and events. Confirm to create it. GitHub then shows the new app's settings page. Keep that tab open. 3. **Copy the credentials back into WarpBuild.** Six values move from the GitHub tab to the WarpBuild form. 4. **Install the app on your organization.** Click Setup runners. WarpBuild saves the app and redirects you to the GHE install flow, where you pick the organization, choose repositories, and confirm. 5. **Point a workflow at a runner label.** You land back in WarpBuild with the integration marked Connected, and the Configure Runners button deep-links to the app's installation settings on your enterprise host for adjusting repository access later. The six credentials in step 3, and where each one comes from: | Field | Where to find it on GitHub | | ------------------ | --------------------------------------------------------------- | | App ID | Top of the app settings page | | Slug | URL slug of the app page, for example warpbuild-acme | | Client ID | App settings page | | Client secret | Generate one on the app page, paste it into WarpBuild | | Webhook secret | Set one on the app page, paste the same value into WarpBuild | | Private key (.pem) | Generate a key on the app page, then upload the downloaded file | After the install, workflows in the connected organization use the standard labels: Copy Text ``` name: enterprise-ci on: pull_request: push: branches: [main] jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test arm-packages: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - run: make package ARCH=arm64 windows-integration: runs-on: warp-windows-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/integration.ps1 ios-build: runs-on: warp-macos-26-arm64-6x steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build ``` ### [Rotating credentials](#rotating-credentials)Copy Anchor Link Three of the six values rotate on a schedule in most enterprises: the client secret, the webhook secret, and the private key. All three rotate without reinstalling the app. On the Products page, click Edit GHE app next to the Runners integration. Generate fresh credentials on the GitHub App settings page, using the Edit GitHub App link in the dialog to jump straight there. Paste the new values into the dialog and click Update app. Fields left blank keep their existing value, so a private key rotation does not force you to re-enter the client secret. The app installation on each organization survives the rotation, and repository access stays as configured. Leave the permissions and events alone during any rotation. The manifest set them, and the app depends on them staying as created. ### [When something fails](#when-something-fails)Copy Anchor Link Three failures cover most cases. A message about the setup link failing to generate points at a wrong slug or host, or at a host WarpBuild cannot reach. An app that creates but fails to install is usually an org-level app where an enterprise-level one is needed, or an account without admin rights on the target organization. Missing webhook deliveries mean the webhook secret in WarpBuild differs from the one on the app, which the Edit GHE app dialog fixes in one step. ### [Cost and rollout](#cost-and-rollout)Copy Anchor Link Start with one organization and one real pipeline. Per-minute rates for every size are on the [pricing page](/pricing). Two enterprise line items are worth knowing during the same conversation. SSO is available for a flat $250 per month, whatever the user count, which keeps the number stable as more organizations from the enterprise connect. For adjacent enterprise questions, [SSO for GitHub Actions runner access](/enterprise/sso-github-actions) covers identity, [US data residency for GitHub Actions runners](/enterprise/data-residency-us) covers placement, and [the security review checklist](/enterprise/ci-security-review) collects the answers a reviewer asks for. Teams that want runners inside their own account can start with [running GitHub Actions runners in your own AWS account](/byoc/aws). ## [FAQ](#faq)Copy Anchor Link ### [Which GitHub Enterprise deployments does WarpBuild support?](#which-github-enterprise-deployments-does-warpbuild-support)Copy Anchor Link GitHub Enterprise Server (self-hosted, for example `github.acme.com`) and GitHub Enterprise Cloud with data residency (for example `acme.ghe.com`), on the enterprise plan, through a GitHub App you register on your own enterprise. For enterprise accounts on `github.com` the same flow is available and optional. ### [Do we register a separate GitHub App for each organization?](#do-we-register-a-separate-github-app-for-each-organization)Copy Anchor Link No. The app is registered once at the enterprise level and shared across the organizations in that enterprise. A second organization enters the same enterprise slug and host, WarpBuild detects the existing app, and that organization runs the install step only. ### [What do we need before starting the setup?](#what-do-we-need-before-starting-the-setup)Copy Anchor Link Enterprise owner access to the GHE instance, your enterprise slug, your enterprise host, a WarpBuild account, and, if your enterprise uses an IP allow list, the three documented control plane egress IPs added to that list. ### [Can we rotate the app credentials later?](#can-we-rotate-the-app-credentials-later)Copy Anchor Link Yes. The client secret, webhook secret, and private key rotate through the Edit GHE app dialog without reinstalling the app, and fields left blank keep their existing value. Leave the app permissions and events as the manifest created them. --- # SSO for GitHub Actions Runner Access | WarpBuild URL: https://www.warpbuild.com/enterprise/sso-github-actions Description: WarpBuild supports SAML 2.0 and OIDC single sign-on for GitHub Actions runner access on enterprise plans, at a flat $250 per month whatever the user count. # SSO for GitHub Actions Runner Access WarpBuild supports SAML 2.0 and OIDC single sign-on for GitHub Actions runner access on enterprise plans, at a flat $250 per month whatever the user count. Last verified: 2026-08-13 ## [Answer](#answer)Copy Anchor Link WarpBuild supports single sign-on through SAML 2.0 and OIDC for a flat $250 per month whatever the user count. Setup is self-serve end to end: an organization admin configures the connection from Settings, verifies the company domain over DNS, and your team then signs in by entering your tenant name on the WarpBuild login page. Billing starts after the first domain is verified. SSO governs human access to the WarpBuild account, which is the dashboard where runners, BYOC stacks, usage, and billing live. Job execution is untouched. A workflow that runs on `warp-ubuntu-latest-x64-4x` before the rollout runs on the same label after it, because the runner registers with GitHub through the WarpBuild GitHub App rather than through your identity provider. Programmatic access continues to use API keys issued inside the WarpBuild account. Stating that split early saves a round of questions, because two different reviewers are asking. The identity team wants the protocol, the attribute mapping, and where the session comes from. The platform team wants to know what happens to `runs-on` and whether pipelines break during the cutover. The answer for the second team is that nothing in the workflow file changes. | Surface | How access works once SSO is live | | --------------------------- | ---------------------------------------------------------------------------------- | | WarpBuild dashboard sign-in | Your identity provider over SAML 2.0 or OIDC, reached by entering your tenant name | | Workflow job execution | Unchanged. Jobs keep running on the same warp- labels | | Programmatic access | API keys issued and managed inside the WarpBuild account | The workflow below is what a pipeline looks like on both sides of an SSO rollout. The labels are the same for every team in the organization regardless of how those people sign in to the dashboard. Copy Text ``` name: ci on: pull_request: push: branches: [main] jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test arm-image: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - run: docker build --platform linux/arm64 -t app:arm64 . windows-integration: runs-on: warp-windows-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/integration.ps1 ``` ## [Providers and Protocols](#providers-and-protocols)Copy Anchor Link WarpBuild supports enterprise single sign-on via SAML 2.0 and OIDC (OpenID Connect). The [SSO documentation](/docs/sso) names the providers below, and any other SAML 2.0 or OIDC-compliant provider connects the same way. | Identity provider | How the SSO documentation lists it | | -------------------------------------- | ---------------------------------------------------------------------------------------------- | | Okta | Named under both SAML 2.0 and OIDC | | Microsoft Entra ID (formerly Azure AD) | Named under both SAML 2.0 and OIDC | | Google Workspace | Named under SAML 2.0 | | JumpCloud | Named under SAML 2.0 | | Auth0 | Named under OIDC | | Keycloak | Named under OIDC | | Microsoft AD FS | Named as a supported provider; connect over whichever of the two protocols your tenant exposes | | OneLogin | Named as a supported provider; connect over whichever of the two protocols your tenant exposes | | PingOne | Named as a supported provider; connect over whichever of the two protocols your tenant exposes | | Rippling | Named as a supported provider; connect over whichever of the two protocols your tenant exposes | Pick the protocol your identity team already operates. Teams standardized on SAML 2.0 application catalogs usually stay on SAML. Teams that run everything as OAuth 2.0 clients usually pick OIDC, which skips the certificate handling. On the SAML path, you create a SAML application in your identity provider and register two service-provider values that the setup wizard displays. Both have different names in different products, which is where most of the setup friction comes from. | Field the wizard shows | Also called in your identity provider | | ---------------------- | ------------------------------------- | | ACS URL | Single Sign-On URL, Reply URL | | Entity ID | SP Entity ID, Audience URI | Then configure the identity provider to emit four assertion attributes. The attribute names are case-sensitive, so an assertion that emits `givenname` where the mapping expects `givenName` fails to populate the field. | SAML assertion attribute | WarpBuild field | | ------------------------ | --------------- | | NameID (subject) | id | | email | email | | givenName | firstName | | surname | lastName | On the OIDC path, you create an OIDC or OAuth 2.0 web application in your identity provider and register the redirect URI shown in the wizard as an allowed redirect. You then supply the issuer URL, the client ID, and the client secret. WarpBuild auto-discovers the endpoints from `{issuer}/.well-known/openid-configuration`, so there are no per-endpoint fields to fill in. Set the application's client authentication method to Client Secret Basic; other client authentication methods are the most common cause of a connection that validates in the identity provider and fails at sign-in. ## [Pricing](#pricing)Copy Anchor Link SSO is available for a flat $250 per month, whatever the user count. The fee is one number on the invoice, and it stays the same number as the organization grows. Everything else stays usage based. The current rates are on the [WarpBuild pricing page](/pricing), and the per-minute rates below come from the runner catalog. | Runner label | Platform | vCPU | RAM | USD per minute | | --------------------------- | ----------- | ---- | ----- | -------------- | | warp-ubuntu-latest-x64-2x | Linux x64 | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | Linux x64 | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | Linux x64 | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-arm64-4x | Linux ARM64 | 4 | 16 GB | $0.006 | | warp-macos-latest-arm64-6x | macOS | 6 | 14 GB | $0.08 | | warp-windows-latest-x64-4x | Windows | 4 | 16 GB | $0.016 | The practical question in a budget review is what the SSO line does to the bill as headcount rises. Here is the arithmetic for three organization sizes, all running on `warp-ubuntu-latest-x64-4x` at $0.008 per minute. WarpBuild rates checked on 2026-08-13. | People signing in | Linux minutes per month | Runner spend | SSO fee | Monthly total | | ----------------- | ----------------------- | ------------ | ------- | ------------- | | 25 | 60,000 | $480.00 | $250.00 | $730.00 | | 120 | 400,000 | $3,200.00 | $250.00 | $3,450.00 | | 600 | 1,200,000 | $9,600.00 | $250.00 | $9,850.00 | The SSO column holds at $250.00 across all three rows. Runner spend moves with minutes consumed, and the identity line does not move with headcount at all, which is the part worth carrying into a finance conversation before an org-wide rollout. Two more line items belong in the same budget. Cache storage bills at $0.20 per GB-month and cache write or restore operations at $0.0001 each. BYOC Linux and Windows runners carry a $0.002 per minute WarpBuild fee, with the compute billed to your own cloud account. ## [Setup](#setup)Copy Anchor Link An organization admin runs all four steps from **Settings → SSO**. 1. **Choose a tenant name.** Two to forty lowercase letters, numbers, or hyphens. This is what your team enters on the WarpBuild login page, and it cannot be changed later. 2. **Configure your identity provider.** Choose SAML 2.0 or OIDC. On SAML, create the application, register the ACS URL and Entity ID from the wizard, emit the four assertion attributes above, then tell WarpBuild how to reach the identity provider using a metadata URL, pasted metadata XML, or manual entry of the SSO URL and X.509 signing certificate. On OIDC, create the web application, register the redirect URI, and enter the issuer URL, client ID, and client secret with client authentication set to Client Secret Basic. 3. **Verify your domain.** Add your company domain and publish the TXT record the wizard shows at your DNS host. WarpBuild checks for it automatically. Sign-in stays blocked until at least one domain is verified, and billing starts once it passes. 4. **Enforce SSO.** Optional. Enforcing requires every member to sign in through your identity provider. You sign in through SSO yourself first, which proves the connection works before the organization is locked to it. After that, your team signs in by entering your tenant name on the WarpBuild login page, which routes the browser to your identity provider. The full walkthrough with screenshots of each wizard screen is in the [SSO documentation](/docs/sso). Sequencing that works well in practice: start the domain verification early, since DNS propagation is the one step you do not control and it can take a few hours; do the identity provider configuration in a single 30-minute session with the admin present; and verify with two accounts before enforcing. The metadata URL method is the one to prefer where your identity provider offers it, because certificate rotation on the identity provider side then flows through without a second configuration pass. Two things worth settling before a wider migration. First, members whose email sits outside your verified domains, such as contractors on personal addresses, are not covered by SSO; they join by invitation and sign in with Google or GitHub, and an organization still carrying such members cannot enforce SSO until they are covered by a verified domain or removed. Second, if your organization runs GitHub Enterprise Cloud with data residency or GitHub Enterprise Server, sort the enterprise app registration out in the same pass; that flow is covered on [WarpBuild runners on GitHub Enterprise](/enterprise/github-enterprise-runners). The rest of the material a security review asks for sits next to this page. Runner isolation, encrypted ephemeral storage, and the secrets boundary are described in the [runner security documentation](/docs/ci/security), and each runner runs in its own virtual machine that is created on demand and destroyed after the build. WarpBuild is SOC 2 Type 2, with [trust.warpbuild.com](https://trust.warpbuild.com/) as the linked evidence. For the questionnaire itself, [answering a GitHub Actions security review](/enterprise/ci-security-review) walks through the questions that come up most, and [are GitHub Actions runners secure?](/answers/are-github-actions-runners-secure) covers the shorter version. Placement questions usually arrive in the same thread as identity questions. US and EU are the regions WarpBuild names for hosted runners, and [US data residency for GitHub Actions runners](/enterprise/data-residency-us) covers what runs where. BYOC runs on AWS, GCP, and Azure for teams that want the runner instances inside their own account, and Terraform support exists for BYOC on AWS, so the placement is reviewable as code. ## [FAQ](#faq)Copy Anchor Link ### [Which identity providers does WarpBuild SSO work with?](#which-identity-providers-does-warpbuild-sso-work-with)Copy Anchor Link Okta, Microsoft Entra ID (formerly Azure AD), Google Workspace, Auth0, Microsoft AD FS, OneLogin, PingOne, JumpCloud, Rippling, and any SAML 2.0 or OIDC-compliant provider. ### [How much does SSO cost on WarpBuild?](#how-much-does-sso-cost-on-warpbuild)Copy Anchor Link SSO is a flat $250 per month, whatever the user count. ### [How do I turn SSO on for my organization?](#how-do-i-turn-sso-on-for-my-organization)Copy Anchor Link An organization admin turns it on from Settings, with no provisioning request. You choose a tenant name, configure your identity provider, and verify your company domain over DNS. Billing starts after the first domain is verified. ### [Which SAML attributes does WarpBuild expect in the assertion?](#which-saml-attributes-does-warpbuild-expect-in-the-assertion)Copy Anchor Link `NameID` maps to id, `email` maps to email, `givenName` maps to firstName, and `surname` maps to lastName. The attribute names are case-sensitive. --- # Zero Egress Costs for GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/enterprise/zero-egress Description: On the WarpBuild enterprise tier, egress costs from your cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores. # Zero Egress Costs for GitHub Actions Runners On the WarpBuild enterprise tier, egress costs from your cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores. Last verified: 2026-08-13 Zero egress for GitHub Actions runners means your cloud provider stops charging you for the gigabytes your workflow jobs pull out of it. On the WarpBuild enterprise tier, egress costs from your cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores during deployments, and that applies on BYOC runners and on WarpBuild-hosted runners. This page leads with the arithmetic. Every number below comes from a published list price with a link and a checked-on date, so you can swap in your own artifact sizes and deploy counts and get a figure your finance team can check. ## [Overview](#overview)Copy Anchor Link Deploy jobs are the heavy readers in a GitHub Actions pipeline. A release job pulls a container image from ECR, pulls a signed release archive or a set of migration bundles from S3, and hands them to a cluster or a fleet. Machine learning pipelines pull model weights. Mobile and desktop pipelines pull signing bundles and prebuilt frameworks. Integration suites pull database fixtures. All of that traffic is read out of stores you own, on a schedule set by how often your team merges. A single deploy job that pulls a 2.5 GB image and 1.5 GB of release artifacts moves 4 GB. That looks small until you multiply it by the number of services and the number of merges. Ten services deploying four times a day is 40 deploy jobs, which is 160 GB a day of reads out of ECR and S3\. Your cloud provider bills that volume at its published data transfer rates. Teams usually find this line the same way: someone opens the cloud bill during a cost review, filters by usage type, and finds a data transfer number that nobody owns. The runner-minutes line is easy to attribute because it sits on the GitHub Actions bill. The transfer line sits on the cloud bill, mixed in with production traffic, and it grows every time a service image gets bigger or a team adds a deploy stage. On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you. Zero egress applies whether your runners live in your cloud or ours. Deploy-heavy workflows that pull gigabytes from ECR stop generating egress charges on the WarpBuild enterprise tier. Two boundaries are worth stating plainly before the model. The first boundary is the tier. This outcome is an enterprise-tier property. Self-serve accounts on WarpBuild cloud runners get the per-minute rates on the [WarpBuild pricing page](/pricing), and the transfer charges from their own cloud stay on their own cloud bill. The second boundary is macOS. The region-specific infrastructure callout in the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners) states that it is not applicable to macOS instances. Linux x64, Linux ARM64, and Windows jobs are the workloads this covers, and those are the platforms that run the container-heavy deploy work anyway. WarpBuild publishes the outcome and its scope here. The mechanism is not documented publicly, so this page describes what you stop paying rather than how the result is produced. For the general breakdown of where these charges come from in a GitHub Actions pipeline, read [where GitHub Actions egress charges come from](/guides/github-actions-egress-costs). ## [Cost Model](#cost-model)Copy Anchor Link The model below is built entirely from public list prices. State your own assumptions in the same shape and the arithmetic transfers directly. Assumptions: * Artifact volume pulled per deploy job: 2.5 GB container image from ECR plus 1.5 GB of release artifacts from S3, so 4 GB per deploy on the medium profile. * Deploy frequency: merge-to-deploy, counted per service, on 22 business days per month. * Per-GB rate: AWS lists data transfer out to the internet from US regions at $0.09 per GB for the first tier of monthly volume, with 100 GB per month free aggregated across services and regions. Source: [AWS EC2 On-Demand pricing, data transfer section](https://aws.amazon.com/ec2/pricing/on-demand/), checked on 2026-08-13. * The 100 GB monthly free allowance is applied once per month per account. If other workloads in the account already consume it, the billable figure below is an underestimate. * Volume above the first tier steps down through AWS's published tiers, and rates differ by region. Confirm the tier and the region rate your own account lands in before quoting these numbers internally. * Google Cloud and Azure publish their own per-GB transfer rates on their pricing pages. Substitute the rate for the provider and region you run in. | Fleet profile | Deploy jobs per day | GB pulled per deploy | GB per month | Billable GB after the free allowance | Cost per month at $0.09 per GB | Cost per year | | --------------------------------- | ------------------- | -------------------- | ------------ | ------------------------------------ | ------------------------------ | ------------- | | Two services, one deploy pipeline | 10 | 2 | 440 | 340 | $30.60 | $367.20 | | Ten services, merge to deploy | 40 | 4 | 3,520 | 3,420 | $307.80 | $3,693.60 | | Thirty services, merge to deploy | 100 | 4 | 8,800 | 8,700 | $783.00 | $9,396.00 | Working the medium profile step by step: 40 deploy jobs a day across 22 business days is 880 deploy jobs a month. At 4 GB per job that is 3,520 GB. Subtracting the 100 GB monthly free allowance leaves 3,420 billable GB. At $0.09 per GB that is $307.80 a month and $3,693.60 a year, for one pipeline pattern in one account. Now put the runner minutes next to it, so the two lines are visible together. Assume the same pipeline runs a 12 minute build job on an 8 vCPU runner and a 6 minute deploy job on a 4 vCPU runner. | Runner label | Shape | WarpBuild per minute | | --------------------------- | -------------- | -------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | Rates come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [WarpBuild pricing page](/pricing). For a same-shape anchor against the hosted baseline, `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13 against the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The runner-minutes arithmetic for the medium profile: * Build job: 12 minutes on `warp-ubuntu-latest-x64-8x` at $0.016 per minute is $0.192. * Deploy job: 6 minutes on `warp-ubuntu-latest-x64-4x` at $0.008 per minute is $0.048. * Per merge: $0.24\. Across 880 merges a month: $211.20. Put the two lines side by side and the shape of the problem is clear. On the medium profile the transfer line is $307.80 a month against $211.20 a month of WarpBuild runner minutes. The charge that nobody owns is larger than the charge everybody reviews. For teams running BYOC, the runner-minutes line changes shape again. BYOC runners cost $0.002 per minute in WarpBuild fees on Linux and on Windows, with the compute billed by your own cloud account, and add-ons are included at no extra charge. The same 18 minutes of pipeline work is $0.036 in WarpBuild fees per merge, or $31.68 a month across 880 merges, plus whatever your own instances cost. | Line item | Rate | Clouds | | -------------------- | ----------------- | --------------- | | BYOC Linux runners | $0.002 per minute | AWS, GCP, Azure | | BYOC Windows runners | $0.002 per minute | AWS, Azure | | BYOC add-ons | Included | AWS, GCP, Azure | Two things this model deliberately leaves out. It excludes cache storage and cache operations, which are billed separately on the [WarpBuild pricing page](/pricing). It also excludes the transfer your production services generate, since only the GitHub Actions share of the transfer line is in scope here. If the transfer line is the second item on your list rather than the first, the runner-minutes and storage levers are covered in [reduce GitHub Actions costs across runners and storage](/guides/reduce-github-actions-costs). ## [What Changes on Enterprise](#what-changes-on-enterprise)Copy Anchor Link On the enterprise tier, egress costs from your cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores during deployments. This applies on BYOC and on WarpBuild-hosted runners. On the medium profile above, that is the $307.80 monthly transfer line going away while the runner-minutes line stays exactly where it was. The exclusion stated in the documentation stands: this does not apply to macOS instances. Almost nothing else about the account changes, which is the point. Pricing structure stays the same. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits, and enterprise accounts start from the same self-serve path. The runner labels and per-minute rates stay the same as the table above. Deployment shape stays your choice. BYOC runs on AWS, GCP, and Azure. Terraform support exists for BYOC on AWS, so enterprise fleets can be defined in the same repositories as the rest of your infrastructure. Access and support stay negotiable in the usual places. SSO is available for a flat $250 per month, whatever the user count, and the fee is listed on the [WarpBuild pricing page](/pricing). Teams that qualify on the enterprise tier usually have a second requirement in the same review, which is where the data stays. That is a separate page: [US data residency for GitHub Actions runners](/enterprise/data-residency-us). Two calls to action apply to this page, and they are different paths. If you want the runners today, sign up at [app.warpbuild.com](https://app.warpbuild.com) and start with $10 free credits. Change the runner label in a workflow and the rest of your GitHub Actions setup keeps working. If you want the enterprise-tier outcome on this page, take the qualification path. Bring three months of data transfer line items from your cloud bill and the deploy-job counts from your busiest repositories to a [qualification call with the WarpBuild team](https://cal.com/suryao/start), or write to [support@warpbuild.com](mailto:support@warpbuild.com). The conversation is short when you arrive with the numbers, because the model above is the same model used on the call. ## [Configuration](#configuration)Copy Anchor Link Nothing in the workflow file is special. The labels below are the standard WarpBuild runner labels, and they are the labels a qualified enterprise account keeps using. A build-and-deploy workflow of the shape the cost model describes: Copy Text ``` name: deploy on: push: branches: [main] jobs: build-image: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/github-actions-build aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - name: Build and push the service image env: REGISTRY: ${{ steps.ecr.outputs.registry }} run: | docker build -t "$REGISTRY/checkout-api:$GITHUB_SHA" . docker push "$REGISTRY/checkout-api:$GITHUB_SHA" deploy: needs: build-image runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - name: Pull the release image env: REGISTRY: ${{ steps.ecr.outputs.registry }} run: docker pull "$REGISTRY/checkout-api:$GITHUB_SHA" - name: Pull release artifacts from S3 run: aws s3 sync "s3://acme-releases/checkout-api/$GITHUB_SHA" ./release - name: Roll out run: ./release/rollout.sh ``` The `deploy` job in that file is the one moving 4 GB per run. The two pulls, the ECR image and the S3 sync, are the volume the cost model counts. ARM64 deploy jobs use the matching label set, at the ARM64 rates in the table: Copy Text ``` jobs: deploy-arm: runs-on: warp-ubuntu-latest-arm64-4x steps: - uses: actions/checkout@v4 - run: ./release/rollout.sh ``` On BYOC, the same job runs on a custom runner you define in your own account. Custom runners are referenced by their full Runner ID, which carries the `warp-custom-` prefix, as described in the [BYOC documentation](/docs/ci/byoc): Copy Text ``` jobs: deploy: runs-on: warp-custom-deploy-use1 steps: - uses: actions/checkout@v4 - name: Pull release artifacts from S3 run: aws s3 sync "s3://acme-releases/checkout-api/$GITHUB_SHA" ./release - name: Roll out run: ./release/rollout.sh ``` Getting to the enterprise outcome takes four steps. 1. Run the workflows on WarpBuild runners first. Change `runs-on` to a `warp-` label, merge, and confirm the jobs behave. Set up an AWS account connection and a stack first if you are going the BYOC route, following the [BYOC setup guide](/docs/ci/byoc) or the [AWS BYOC walkthrough](/byoc/aws). 2. Measure the current transfer line. In AWS Cost Explorer, filter by the data transfer usage types on the accounts that hold your registries and artifact buckets, and group by usage type over the last three full months. Cost allocation tags on the registry and bucket resources make the GitHub Actions share separable from production traffic. 3. Take the qualification path. Enterprise enablement runs through a conversation with the WarpBuild team, so bring the transfer numbers and the deploy-job counts from step 2. 4. Re-measure after two weeks on the enterprise tier, using the same Cost Explorer filter and the same grouping. Compare the transfer line to the baseline from step 2 and keep both screenshots with your cost review notes. ## [FAQ](#faq)Copy Anchor Link ### [Does zero egress apply to macOS runners?](#does-zero-egress-apply-to-macos-runners)Copy Anchor Link No. The region-specific infrastructure callout in the WarpBuild cloud runners documentation states that it is not applicable to macOS instances. Linux x64, Linux ARM64, and Windows deploy jobs are the workloads this covers. ### [Does this apply to BYOC runners or to WarpBuild-hosted runners?](#does-this-apply-to-byoc-runners-or-to-warpbuild-hosted-runners)Copy Anchor Link Both. On the enterprise tier, zero egress applies whether your runners live in your cloud or ours. BYOC runs on AWS, GCP, and Azure, and the WarpBuild-hosted fleet carries the same runner labels either way. ### [Which line items on my cloud bill does this affect?](#which-line-items-on-my-cloud-bill-does-this-affect)Copy Anchor Link The data transfer charges your provider raises when GitHub Actions jobs pull images and artifacts out of stores such as ECR and S3 during deployments. Compute, storage, and request charges in your account are unchanged. ### [Do I have to change my workflow files?](#do-i-have-to-change-my-workflow-files)Copy Anchor Link No. The runs-on labels stay the same, the steps stay the same, and runner time stays billed per minute. Enterprise qualification happens outside the workflow file. --- # CI Glossary | WarpBuild URL: https://www.warpbuild.com/glossary Description: A glossary of GitHub Actions and CI infrastructure terms, with concrete workflow examples. # CI Glossary Definitions for GitHub Actions and CI infrastructure vocabulary, each with a concrete example. * [Actions Runner ControllerActions Runner Controller is the Kubernetes controller that registers self-hosted GitHub Actions runners as pods and scales the fleet with the job queue.](/glossary/actions-runner-controller) * [Artifact CacheAn artifact cache stores dependencies or build outputs under a key so a later run restores them instead of rebuilding. How keys, hits and eviction work.](/glossary/artifact-cache) * [Artifact RetentionArtifact retention is how long a platform keeps an uploaded build artifact before deleting it. How GitHub Actions sets the window and what expiry breaks.](/glossary/artifact-retention) * [Audit LogAn audit log is an append only record of who changed what and when. What an entry carries, how a GitHub Actions team queries one, and how to export it.](/glossary/audit-log) * [Autoscaling RunnerAn autoscaling runner is a self-hosted GitHub Actions runner created in response to queued jobs and released when demand falls. How the pattern works.](/glossary/autoscaling-runner) * [Base ImageA base image is the image a Dockerfile FROM instruction names, supplying the filesystem a build starts from. How the reference resolves and invalidates cache.](/glossary/base-image) * [Build Duration PercentileA build duration percentile is the time below which a stated share of builds finish, such as p50 or p95\. Why averages hide the slow tail developers feel.](/glossary/build-duration-percentile) * [Build MatrixA build matrix expands one job definition into many jobs, one per combination of the values you list. How matrix keys, include, exclude and fail-fast behave.](/glossary/build-matrix) * [BuildKitBuildKit is the Docker build engine that resolves a Dockerfile into a graph of build steps, runs independent steps concurrently, and manages the layer cache.](/glossary/buildkit) * [Docker BuildxDocker Buildx is the Docker CLI plugin that drives BuildKit: it selects the builder instance a build runs on and targets several platforms in one build.](/glossary/buildx) * [BYOC (Bring Your Own Cloud)BYOC, or bring your own cloud, is a deployment model where a vendor's control plane operates resources that run inside the customer's own cloud account.](/glossary/byoc) * [Cache EvictionCache eviction is the removal of cache entries to hold a store under a size ceiling or an age limit. How least recently used order plays out in GitHub Actions.](/glossary/cache-eviction) * [Cache Hit RateCache hit rate is the share of cache lookups that return a usable entry. How to pick the numerator and the denominator, and how to count it in GitHub Actions.](/glossary/cache-hit-rate) * [Cache KeyA cache key is the string that names one cache entry. How GitHub Actions builds a key from the runner platform and a lockfile hash, and how a restore matches.](/glossary/cache-key) * [Cache ScopeCache scope is the boundary that decides which GitHub Actions runs may read a cache entry: the branch that wrote it, plus the default branch.](/glossary/cache-scope) * [Code SigningCode signing attaches a cryptographic signature to a binary so an operating system can check its origin and its integrity before it agrees to run it.](/glossary/code-signing) * [Cold StartA cold start is the time between a GitHub Actions job being queued and its first useful step, spent on runner allocation, image boot, and setup.](/glossary/cold-start) * [Compiler CacheA compiler cache stores object files from earlier compilations, keyed by source content and compiler flags, and returns them when the same inputs recur.](/glossary/compiler-cache) * [Composite ActionA composite action bundles several GitHub Actions workflow steps into one reusable action defined by a single action.yml file. Syntax, limits, example.](/glossary/composite-action) * [Concurrency GroupA concurrency group is a named lane in GitHub Actions that runs one workflow run or job at a time, and can cancel the run already in progress.](/glossary/concurrency-group) * [Concurrency LimitA concurrency limit is a ceiling on how many GitHub Actions jobs run at the same time, set by a workflow concurrency key, the account plan, or runner capacity.](/glossary/concurrency-limit) * [Container RegistryA container registry stores and serves container images and their manifests over the registry API, addressing every layer and config blob by its digest.](/glossary/container-registry) * [Control PlaneA control plane is the component that decides what should exist and issues the instructions that create it, while the workload runs elsewhere on the data plane.](/glossary/control-plane) * [Cross CompilationCross compilation is compiling on one architecture to produce a binary that runs on another. Target triples, sysroots, and a GitHub Actions example.](/glossary/cross-compilation) * [Data PlaneA data plane is the set of components that carry the actual workload, while the control plane beside it decides what should exist and schedules work onto them.](/glossary/data-plane) * [Data ResidencyData residency is the requirement that data is stored and processed inside a defined geography. What it means for a GitHub Actions build, and how to prove it.](/glossary/data-residency) * [Deployment EnvironmentA deployment environment is a named target in GitHub Actions that carries its own secrets, variables, and protection rules such as required reviewers.](/glossary/deployment-environment) * [Docker BakeDocker Bake is the Buildx command that reads build targets from an HCL, JSON, or Compose file, so several images build from one declarative definition.](/glossary/docker-bake) * [Docker Layer CacheA Docker layer cache is the store of previously built image layers that a build reuses when an instruction and the inputs it reads are unchanged.](/glossary/docker-layer-cache) * [Egress CostsEgress costs are the per-gigabyte charges a cloud provider applies to data leaving its network or crossing its zones and regions. Where they come from.](/glossary/egress-costs) * [Ephemeral RunnerAn ephemeral runner is a GitHub Actions runner that takes exactly one job and is destroyed afterwards, so no state from that job reaches the next job.](/glossary/ephemeral-runner) * [Ephemeral StorageEphemeral storage is disk tied to the lifetime of one instance, container, or pod, erased when that instance goes away. What it means for a GitHub Actions job.](/glossary/ephemeral-storage) * [Fail FastFail-fast is the matrix strategy setting that cancels the remaining matrix jobs when one leg fails. What it defaults to, and when to switch it off.](/glossary/fail-fast) * [Flaky TestA flaky test passes and fails on the same commit. The mechanisms behind flakiness, how to measure a flake rate, and how to confirm one in GitHub Actions.](/glossary/flaky-test) * [GitHub Actions RunnerA GitHub Actions runner is the machine that executes one workflow job, picked by the labels in runs-on. How runners register, match jobs, and bill.](/glossary/github-actions-runner) * [GitHub AppA GitHub App is an installable integration with its own identity and scoped permissions that acts on a repository without using a human account.](/glossary/github-app) * [GITHUB\_TOKENGITHUB\_TOKEN is the GitHub App installation token created at the start of every workflow job, scoped to one repository and expiring when the job ends.](/glossary/github-token) * [AWS GravitonAWS Graviton is the ARM64 processor family AWS designs for its own instances, so anything a service ships to a Graviton instance has to be built for arm64.](/glossary/graviton) * [IAM Instance ProfileAn IAM instance profile is a container for one IAM role that an EC2 instance assumes, so code on the machine gets rotating AWS credentials with no stored keys.](/glossary/iam-instance-profile) * [Image DigestAn image digest is the SHA-256 hash of an image manifest, so it names one exact image. How a digest differs from a tag, and how to pin one in a workflow.](/glossary/image-digest) * [Incremental BuildAn incremental build reuses outputs from the previous build and recompiles only what changed. How the record works, and why a clean job rebuilds everything.](/glossary/incremental-build) * [Inline CacheInline cache is Docker build cache metadata embedded in the image you push, so pulling that image also supplies the cache hints for the next build.](/glossary/inline-cache) * [Instance Metadata ServiceThe instance metadata service is the link-local endpoint a cloud instance queries for its own metadata and role credentials. How IMDSv1 and IMDSv2 differ.](/glossary/instance-metadata-service) * [Instance TypeAn instance type is a named cloud machine shape that fixes vCPU count, memory, storage, and network capability. How the name encodes each of them.](/glossary/instance-type) * [Job SummaryA GitHub Actions job summary is a markdown panel a job writes to its own run page by appending to the GITHUB\_STEP\_SUMMARY file. Rules, limits, and an example.](/glossary/job-summary) * [Job TimeoutA job timeout is the maximum number of minutes a GitHub Actions job may run before the platform cancels it, set with timeout-minutes on a job or a step.](/glossary/job-timeout) * [KVMKVM is the Linux kernel virtualization interface, exposed as the device /dev/kvm, that lets a process run a guest with hardware acceleration on the host CPU.](/glossary/kvm) * [Local SSDA local SSD is storage physically attached to the host running an instance. Why it beats network attached volumes for build scratch space, and why it is wiped.](/glossary/local-ssd) * [MCP ServerAn MCP server implements the Model Context Protocol and exposes a typed set of tools and data sources that an AI assistant can call over JSON-RPC 2.0.](/glossary/mcp-server) * [MonorepoA monorepo is one version control repository holding several projects that share tooling and one change history, and what that shape means for GitHub Actions.](/glossary/monorepo) * [Multi Arch ImageA multi arch image is one image reference that resolves through a manifest list to a per architecture image, so a single tag serves x64 and ARM64 clients.](/glossary/multi-arch-image) * [Multi Stage BuildA multi stage Docker build is a Dockerfile with several FROM stages, where a later stage copies only the artifacts it needs out of an earlier one.](/glossary/multi-stage-build) * [NAT GatewayA NAT gateway is a managed device that lets instances in a private subnet start outbound connections to the internet while accepting none from it.](/glossary/nat-gateway) * [Nested VirtualizationNested virtualization runs a virtual machine inside a machine that is already virtualized. It needs the host to expose hardware virtualization to the guest.](/glossary/nested-virtualization) * [OIDC Token ExchangeOIDC token exchange is the pattern where a workflow presents a short lived identity token to a cloud provider and receives temporary credentials back.](/glossary/oidc-token-exchange) * [OpenTelemetryOpenTelemetry is an open standard and a set of libraries for emitting traces, metrics, and logs in one vendor-neutral format. Signals, OTLP, and an example.](/glossary/opentelemetry) * [Personal Access TokenA personal access token is a credential tied to one user account that authenticates API and Git requests as that person, under that person's permissions.](/glossary/personal-access-token) * [Provenance AttestationA build provenance attestation is a signed statement describing how an artifact was built: the source commit, the builder, and the build parameters.](/glossary/provenance-attestation) * [Provisioning ProfileA provisioning profile is an Apple signed file that binds an app identifier, the certificates allowed to sign it, and a device list or a distribution method.](/glossary/provisioning-profile) * [QEMU EmulationQEMU emulation runs binaries built for another processor architecture by translating their instructions, which is how an x64 machine builds an ARM64 image.](/glossary/qemu-emulation) * [Queue TimeQueue time is the wait between a GitHub Actions job entering the queue and a runner starting it, measured apart from how long the job then takes to run.](/glossary/queue-time) * [Registry CacheA registry cache stores Docker build layers as a separate image in a container registry, so any builder able to pull from that registry reuses them.](/glossary/registry-cache) * [Remote Build CacheA remote build cache stores build outputs on a shared store, keyed by the inputs that produced them, so a target built on one machine is reused elsewhere.](/glossary/remote-build-cache) * [Remote ExecutionRemote execution is a build mode where the build tool dispatches each action to a pool of worker machines instead of running it on the local machine.](/glossary/remote-execution) * [Reusable WorkflowA reusable workflow is a GitHub Actions workflow that declares on: workflow\_call, so other workflows call it as a job and share one pipeline definition.](/glossary/reusable-workflow) * [Runner GroupA runner group is a GitHub-side container around a set of runners that controls which repositories and which workflows are allowed to run jobs on them.](/glossary/runner-group) * [Runner ImageA runner image is the prepared disk image a CI runner boots from, carrying the operating system and the preinstalled toolchains a job expects to find.](/glossary/runner-image) * [Runner LabelA runner label is the string in a GitHub Actions runs-on key that GitHub matches against registered runners to decide which machine picks up the job.](/glossary/runner-label) * [Runner PoolA runner pool is the set of machines that accept GitHub Actions jobs for a given label, plus the rules that create and retire them. How depth sets queue time.](/glossary/runner-pool) * [Runner Registration TokenA runner registration token is the short lived credential a self-hosted runner presents to GitHub to join a repository, organization, or enterprise pool.](/glossary/runner-registration-token) * [Runner UtilizationRunner utilization is the share of a runner's CPU, memory, disk, and network that a job actually uses while it holds the machine, and how to measure it.](/glossary/runner-utilization) * [runs-onruns-on is the GitHub Actions job key that selects which runner picks the job up, by a single label, a list of labels, or a runner group object.](/glossary/runs-on) * [SBOM (Software Bill of Materials)An SBOM is a machine-readable inventory of the components and versions inside a build output. What the formats record, and how a workflow generates one.](/glossary/sbom) * [Security GroupA security group is a stateful firewall attached to a cloud instance, holding allow rules for inbound and outbound traffic. How runner rules are written.](/glossary/security-group) * [Self-Hosted RunnerA self-hosted runner is a machine you operate that registers with GitHub and runs workflow jobs. How registration, labels, and the ownership split work.](/glossary/self-hosted-runner) * [Simulator RuntimeA simulator runtime is the downloadable platform image a simulator boots, versioned apart from the tools that drive it. How to pin one in a test job.](/glossary/simulator-runtime) * [Single Sign OnSingle sign-on is an arrangement where one identity provider authenticates a user once and asserts that identity to the other applications the user opens.](/glossary/single-sign-on) * [SOC 2 Type 2SOC 2 Type 2 is an independent audit report on whether a service organization's controls operated effectively across a period, and how a reviewer reads one.](/glossary/soc-2-type-2) * [Spot InstanceA spot instance is spare cloud capacity sold at a discount to on-demand rates, on the condition that the provider can reclaim the machine on short notice.](/glossary/spot-instance) * [Standby DiskA standby disk is a storage volume prepared ahead of demand so a new machine can attach it and start work without downloading a machine image first.](/glossary/standby-disk) * [SubnetA subnet is a range of IP addresses inside a cloud network, bound to one zone and to a route table that decides whether machines in it reach the internet.](/glossary/subnet) * [Task GraphA task graph is the dependency graph a build tool computes over its tasks. How the graph fixes execution order, what runs in parallel, and what is skipped.](/glossary/task-graph) * [Test ShardingTest sharding splits a test suite into disjoint slices that run on separate machines at the same time, then merges the shard results into one verdict.](/glossary/test-sharding) * [vCPUA vCPU is the virtual processor a cloud instance exposes to its guest operating system, usually one hardware thread of a core. Why build tools count them.](/glossary/vcpu) * [VM SnapshotA VM snapshot is a saved copy of a virtual machine's disk at a point in time. What it captures, what it drops at boot, and how a job boots from one.](/glossary/vm-snapshot) * [VPC (Virtual Private Cloud)A VPC is an isolated virtual network inside a cloud account, with its own address range, subnets, and routing rules. What it holds and why builds run in one.](/glossary/vpc) * [Warm CacheA warm cache is a cache that already holds the entries a run needs, so the run restores them and skips rebuilding them. What keeps a cache warm.](/glossary/warm-cache) * [Warm PoolA warm pool is a set of machines kept booted and ready ahead of demand, so a queued GitHub Actions job attaches to one and skips the cold boot.](/glossary/warm-pool) * [Workflow Dispatchworkflow\_dispatch is the GitHub Actions trigger that starts a workflow run on demand from the Actions tab, the GitHub CLI, or the REST API, with typed inputs.](/glossary/workflow-dispatch) * [Workflow EventA workflow event is the repository activity that starts a GitHub Actions run: push, pull\_request, schedule, or workflow\_dispatch. How each one is declared.](/glossary/workflow-event) * [Workflow PermissionsWorkflow permissions are the per scope grants on the token a GitHub Actions run uses, declared with a permissions key at workflow level or job level.](/glossary/workflow-permissions) --- # Actions Runner Controller | WarpBuild URL: https://www.warpbuild.com/glossary/actions-runner-controller Description: Actions Runner Controller is the Kubernetes controller that registers self-hosted GitHub Actions runners as pods and scales the fleet with the job queue. # Actions Runner Controller Actions Runner Controller is the Kubernetes controller that registers self-hosted GitHub Actions runners as pods and scales the fleet with the job queue. Actions Runner Controller (ARC) is a Kubernetes controller, published by GitHub at [github.com/actions/actions-runner-controller](https://github.com/actions/actions-runner-controller) under the Apache License 2.0, that registers self-hosted GitHub Actions runners as pods in a cluster you operate and scales their count with the job queue. A workflow reaches those runners the ordinary way, by naming the runner scale set in `runs-on`, and ARC creates one ephemeral runner pod for each assigned job. The word controller is doing the work in that name. ARC supplies no machines of its own. It reconciles Kubernetes objects against the queue GitHub reports, and every node those pods land on, every image they boot from, and every upgrade of the controller itself belongs to whoever owns the cluster. ## [Definition](#definition)Copy Anchor Link ARC installs from two Helm charts. The `gha-runner-scale-set-controller` chart deploys the controller manager once per cluster. The `gha-runner-scale-set` chart is installed once per runner scale set, and a scale set is the unit a workflow targets. GitHub's documentation describes the project as one it [adopted to release as a new GitHub product](https://docs.github.com/en/actions/concepts/runners/support-for-arc), with support covering the latest Autoscaling Runner Sets version. A running deployment has three moving parts. Component behavior below is from [GitHub's ARC concept documentation](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller), and the resource kinds live in the `actions.github.com/v1alpha1` API group, both checked on 2026-08-13. | Piece | Kubernetes shape | What it does | | ------------------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | Controller manager | Deployment from the gha-runner-scale-set-controller chart | Watches the custom resources in the actions.github.com group and reconciles the desired runner count | | Listener | One pod per scale set, backed by AutoscalingListener | Holds an outbound HTTPS long poll to the GitHub Actions Service and stays idle until a Job Available message arrives | | Runner pods | EphemeralRunnerSet and EphemeralRunner | One pod per assigned job, registered with a just-in-time configuration and removed when the job ends | Two properties follow from that shape. The cluster needs no inbound network path, because the listener dials out and waits. And each job starts on a pod that was created for it, so nothing survives from the previous job on the same runner. ### [How a job reaches a pod](#how-a-job-reaches-a-pod)Copy Anchor Link Routing is a label match, the same rule any self-hosted runner follows. The Helm installation name of a scale set becomes the label it registers with, so a release installed as `arc-runner-set` answers `runs-on: arc-runner-set`. The `scaleSetLabels` chart key, rendered into the custom resource as `runnerScaleSetLabels`, adds further labels for jobs that want to select on several at once. ### [What the chart exposes](#what-the-chart-exposes)Copy Anchor Link The scale set chart takes `githubConfigUrl` for the repository, organization, or enterprise scope, and `githubConfigSecret` for the GitHub App or personal access token that authenticates the listener. `minRunners` and `maxRunners` bound the fleet. `containerMode.type` decides how container jobs and service containers work, with `dind` running a Docker daemon beside the runner and `kubernetes` using the runner container hooks and a work volume claim. The runner pod template is a full PodSpec, so image, resources, security context, volumes, and node selection are all yours to set. ### [Two generations](#two-generations)Copy Anchor Link Older material describes a different ARC. The community generation used the `actions.summerwind.net` API group, `RunnerDeployment` plus `HorizontalRunnerAutoscaler`, and an inbound webhook server for queue-driven scaling. The current generation uses autoscaling runner sets, the `actions.github.com` group, and the outbound listener described above, with no webhook to expose. Chart keys and troubleshooting advice do not transfer between the two, so the first thing to check about any ARC instruction is which generation it was written for. ## [Example](#example)Copy Anchor Link The prerequisites are a Kubernetes cluster and Helm 3\. Installing the controller and one scale set is two Helm releases: Copy Text ``` helm install arc \ --namespace arc-systems --create-namespace \ oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller helm install arc-runner-set \ --namespace arc-runners --create-namespace \ --set githubConfigUrl="https://github.com/my-org" \ --set githubConfigSecret.github_token="$GITHUB_PAT" \ --set minRunners=0 \ --set maxRunners=20 \ oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set ``` The second release is named `arc-runner-set`, and that name is the label a workflow asks for: Copy Text ``` name: build on: push: branches: [main] jobs: package: runs-on: arc-runner-set steps: - uses: actions/checkout@v4 - run: make package ``` When that push lands, GitHub queues the `package` job and sends a Job Available message down the listener's open connection. The controller creates an `EphemeralRunner`, Kubernetes schedules a pod on a node with room for it, the runner agent inside registers with a just-in-time configuration, claims the job, runs `actions/checkout` and `make package`, and the pod is deleted. With `minRunners` at zero the scale set holds no pods between jobs, so the next push pays a pod start before the first step runs. ### [What the cluster then owns](#what-the-cluster-then-owns)Copy Anchor Link Those two Helm commands leave four standing jobs with the team that ran them. **Autoscaling.** `minRunners` and `maxRunners` are static Helm values, so a change in scale bounds is a redeploy. GitHub's [scale set documentation](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets) states that ARC does not support scheduled maximum and minimum configurations and suggests a cron job or another scheduling solution to update the configuration on a schedule. Warm capacity is bought by raising `minRunners`, and those idle pods hold cluster nodes around the clock. **Images.** The shipped runner image carries the minimum packages needed for the runner binaries, so a toolchain of your own means an image of your own, kept structurally valid: the agent under `/home/runner/`, the container named `runner`, and the container hooks present when Kubernetes mode is used. Rebuilds are on a clock, because GitHub documents that a self-hosted runner which goes 30 days without a software update stops being queued jobs. **Storage.** Kubernetes mode needs a work volume claim served by a dynamic provisioner, and the volume is per runner pod. In `dind` mode the Docker layers a build writes live inside the pod and disappear with it, so a layer cache that survives across jobs is a separate service somebody sets up and runs. **Upgrades.** GitHub documents that Helm cannot upgrade or delete CRDs, so upgrading ARC is a five step procedure: uninstall every scale set release, wait for cleanup, uninstall the controller, remove the `actions.github.com` CRDs when they changed, then reinstall. The documented way to keep runners available through that window is a second Kubernetes cluster in another region. The `gha-runner-scale-set` chart shipped five releases in the twelve months to 2026-08-13, the latest being 0.14.2 on 2026-05-22 ([releases](https://github.com/actions/actions-runner-controller/releases), read on 2026-08-13). ## [Related Terms](#related-terms)Copy Anchor Link * [Self-hosted runners and what registering one commits you to](/glossary/self-hosted-runner): the runner category ARC creates, and the ownership that comes with it. * [Autoscaling runners and how queue depth drives capacity](/glossary/autoscaling-runner): the general pattern ARC implements with a listener and ephemeral pods. * [Scaling self-hosted runners without owning the cluster](/guides/github-actions-self-hosted-runner-scaling): the same scaling problem approached without a controller to operate. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner labels available and how a workflow selects them. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What does Actions Runner Controller actually do? It runs inside a Kubernetes cluster, watches custom resources in the actions.github.com API group, and keeps a set of self-hosted GitHub Actions runners registered as pods. A listener pod per runner scale set holds an outbound long poll to the GitHub Actions Service, and each assigned job gets one ephemeral runner pod that is deleted when the job finishes. ### How does a workflow target an ARC runner scale set? By the name the scale set was installed under. The Helm installation name becomes the label the scale set registers with, so a release installed as arc-runner-set is reached by runs-on: arc-runner-set. Setting runnerScaleSetLabels adds further labels, and a job then names them the way it names any other self-hosted runner labels. ### How does ARC decide how many runners to run? The listener creates one ephemeral runner per assigned job, bounded by the minRunners and maxRunners values in the scale set chart. Both are static Helm values, so changing them is a redeploy, and GitHub's documentation states that ARC does not support scheduled maximum and minimum configurations and suggests a cron job to update the configuration on a schedule. --- # Artifact Cache | WarpBuild URL: https://www.warpbuild.com/glossary/artifact-cache Description: An artifact cache stores dependencies or build outputs under a key so a later run restores them instead of rebuilding. How keys, hits and eviction work. # Artifact Cache An artifact cache stores dependencies or build outputs under a key so a later run restores them instead of rebuilding. How keys, hits and eviction work. ## [Definition](#definition)Copy Anchor Link An artifact cache is a store of dependencies or build outputs, written under a key, so that a later run can restore those files instead of producing them again. A run computes the key, asks the store for a matching entry, and then either restores the files it gets back (a hit) or does the work itself and writes a new entry (a miss). The name covers two kinds of content that behave the same way. The first is dependencies downloaded from a package registry, such as an npm store, a Go module cache, a Maven repository, or a Cargo registry. The second is outputs a build tool produced from source, such as compiled objects, a Rust `target` directory, or a Gradle build cache. Both are expensive to create and cheap to copy, which is the property that makes caching worthwhile. ### [The four parts of a cache entry](#the-four-parts-of-a-cache-entry)Copy Anchor Link Every artifact cache, in any tool, is described by the same four things. **Paths.** The files and directories that get packed into the entry. Only files named by path can be cached, so state that lives somewhere else, such as an installed system package or a running daemon, is out of reach of a cache step. **The key.** A string computed by the run that identifies the content. A good key changes exactly when the content should change. The usual construction is a hash of the lockfile that determines the dependency set, prefixed by the platform, so that a dependency bump produces a new key and everything else reuses the old one. **The store.** The service that holds entries and answers lookups by key. It sits outside the machine running the job, which is why a fresh machine can still start warm. **Retention.** The rule that decides when an entry disappears. Retention is what separates a cache from durable storage, and it is the part teams forget until a key that used to hit starts missing. ### [Hits, misses, and partial restores](#hits-misses-and-partial-restores)Copy Anchor Link A lookup has three outcomes. An exact hit means an entry existed under the primary key, and the restored files are the ones that key describes. A miss means nothing matched, so the job does the full work and usually saves a new entry at the end. A partial restore sits in between: no entry matched the primary key, so the store fell back to the newest entry whose key starts with a prefix the job supplied, and the job restores something close to what it wanted and reconciles the difference. That middle outcome is the one that causes confusion. A partial restore still leaves useful files on disk, so the job does less work than a cold run, and the install step still has real work to do. A run with an exact hit is often called warm and a run with nothing to restore is called cold. ### [A cache entry and a build artifact are different objects](#a-cache-entry-and-a-build-artifact-are-different-objects)Copy Anchor Link Both words get used loosely, and the difference is about guarantees. A cache entry is a copy of something the build can recreate, so the store is allowed to throw it away. An artifact is an output that is kept on purpose for a stated period, so that a person can download it or a later job can consume it. The table below states the difference in terms of the rules GitHub Actions applies. The cache numbers come from [GitHub's caching documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows) and the artifact numbers from the [GitHub Actions limits reference](https://docs.github.com/en/actions/reference/limits), both checked on 2026-08-13. | Property | Cache entry | Build artifact | | --------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | Purpose | Skip work a later run would repeat | Keep an output for people or for later jobs | | Addressed by | A key the workflow computes | A name given at upload time | | Default lifetime | Deleted after 7 days without use | Retained 90 days | | Configurable lifetime | Expiration policy set by repository or organization admins | 1 to 90 days on public repositories, 1 to 400 days on private and internal repositories | | Removed early when | The repository passes its size cap, and least recently used entries are evicted first | An admin or a workflow deletes it | | Guaranteed present | No | Yes, until the retention period ends | | Effect of absence | The job rebuilds and takes longer | The download step fails | The practical rule follows from the last row. A build has to succeed with an empty cache store, every time, on every branch. Once a pipeline depends on an entry being present, the cache has quietly become storage, and the next eviction becomes an outage. Anything a later job truly requires belongs in an artifact upload with a retention period set on purpose. ### [Scope is part of the identity](#scope-is-part-of-the-identity)Copy Anchor Link A key alone does not identify an entry. In GitHub Actions the lookup also carries a version, which hashes the compression tool used and the exact list of cached paths, and a branch scope: a run can read entries created on its own branch and on the branch it was created from, and sibling branches stay invisible to each other. Two jobs can therefore use the same key string and still see different entries. Platform belongs in the key for the same reason, because a dependency tree installed on Linux is rarely usable on macOS. ## [Example](#example)Copy Anchor Link Here is the shape almost every Node repository uses. The primary key pins the exact lockfile, and `restore-keys` supplies one prefix to fall back to. Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Restore npm cache id: npm-cache uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - run: npm ci - run: npm test ``` Follow three runs through that step. **Run 1, a new repository.** `hashFiles` produces a digest of `package-lock.json`, so the key resolves to something like `Linux-npm-9f2c...`. Nothing matches the key, and nothing matches the prefix `Linux-npm-`, so the step restores nothing and reports a miss. `npm ci` downloads every package. At the end of the job the action packs `~/.npm` and saves it under the primary key. **Run 2, no dependency change.** The lockfile is byte for byte the same, so the key is the same string, and the store returns that entry. This is an exact hit: `npm ci` runs against a populated download cache and skips the network for every package. No save happens at the end, because an entry already exists under that key. **Run 3, after a dependency bump.** One package moved, so the lockfile changed and the key becomes `Linux-npm-4a71...`. No entry matches. The prefix `Linux-npm-` does match the entry saved in run 1, and the store returns that one, which is a partial restore. Most packages are already on disk, `npm ci` fetches only the difference, and the job saves a new entry under the new key. The old entry stays until retention removes it. The distinction between run 2 and run 3 shows up in one output. `cache-hit` is `true` only for the exact match in run 2\. After the partial restore in run 3 it is `false`, which is correct, because the restored files describe an older lockfile. A step guarded with `if: steps.npm-cache.outputs.cache-hit != 'true'` therefore still runs after a fallback, and that is the behavior you want: skipping the install on any restore is how stale dependencies reach a build. | Restore attempt | Key state | What the store returns | cache-hit | Save at end of job | | ------------------------- | --------------------------------------------- | ---------------------------- | --------- | ----------------------------------------- | | Run 1, empty store | No exact match, no prefix match | Nothing | false | Writes an entry under the primary key | | Run 2, lockfile unchanged | Exact match on the primary key | That entry | true | Skipped, the key already exists | | Run 3, lockfile changed | No exact match, prefix matches an older entry | The most recent prefix match | false | Writes an entry under the new primary key | The cache step is independent of which machine runs the job. Changing the `runs-on` label moves the same steps onto a different fleet, and the key, the fallback, and the three outcomes above behave the same way: Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Restore npm cache id: npm-cache uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - run: npm ci - run: npm test ``` One boundary is worth stating, because it surprises people who move a job across platforms. An entry saved by a job on `warp-macos-15-arm64-6x` will never restore on `warp-ubuntu-latest-x64-4x`. The entry version hashes the compression tool and the cached paths, and `runner.os` in the key differs as well, so the two platforms address separate entries by construction. Cache inputs, outputs, key scoping, and the requirements for running a cache step inside a container are documented on the [caching documentation page](/docs/ci/features/caching). Two habits keep this pattern healthy over time. Keep the number of distinct prefixes small, because every prefix is a separate family of entries competing for the same retention budget. And seed the shared entry from the default branch, since branch scoping lets pull request branches read entries created on the branch they came from, which avoids one near identical copy per active branch. ## [Related Terms](#related-terms)Copy Anchor Link * [Warm cache](/glossary/warm-cache): what makes a run warm, and how a warm run differs from a cold one. * [The GitHub Actions cache size limit](/guides/github-actions-cache-size-limit): how the repository size cap and least recently used eviction decide which entries survive. * [How long GitHub Actions caches last](/answers/how-long-do-github-actions-caches-last): the disuse window, what resets it, and how to check an entry's last access. * [Remote build cache](/glossary/remote-build-cache): the shared store variant that a build tool queries directly rather than through a workflow step. * [Caching documentation](/docs/ci/features/caching): cache action inputs and outputs, key scoping, and container requirements. * [Setup actions with caching built in](/docs/ci/features/caching/setup-actions): language toolchain actions that manage cache keys and paths from a single input. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is an artifact cache? An artifact cache is a store of dependencies or build outputs written under a key, so that a later run can restore those files instead of producing them again. A run computes the key, asks the store for a matching entry, and either restores the files or does the work and writes a new entry. ### What is the difference between a cache and an artifact? A cache entry is an optimization that the store may remove at any time, so a build has to succeed when the store is empty. An artifact is an output kept for a stated period so that people or later jobs can download it. In GitHub Actions a cache entry is deleted after 7 days of disuse and can be evicted earlier when a repository passes its size cap, while an artifact is retained for 90 days by default. ### What is the difference between an exact cache hit and a partial restore? An exact hit means an entry existed under the primary key and the restored files match what the key describes. A partial restore means no entry matched the primary key, so the store fell back to the newest entry matching a restore-keys prefix. The cache-hit output is true only on an exact hit, which is why install steps should run after a fallback restore. ### Why did a cache key that hit yesterday miss today? Either the key changed or the entry is gone. Keys built from a lockfile hash change whenever a dependency moves, and entries disappear when they age past the disuse window or when the store evicts them to stay under a size cap. Cache scope also matters, because an entry saved on one branch, operating system, or set of paths is a different entry from the one a job on another branch or platform asks for. --- # Artifact Retention | WarpBuild URL: https://www.warpbuild.com/glossary/artifact-retention Description: Artifact retention is how long a platform keeps an uploaded build artifact before deleting it. How GitHub Actions sets the window and what expiry breaks. # Artifact Retention Artifact retention is how long a platform keeps an uploaded build artifact before deleting it. How GitHub Actions sets the window and what expiry breaks. Artifact retention is the period a CI platform keeps uploaded build artifacts before deleting them, set per repository or per upload. On GitHub Actions that window defaults to 90 days, and when it closes the artifact is deleted and the download link in the run summary stops resolving. Retention is a policy about files a job has already produced. The window governs how long the output of a run stays fetchable once the run is over. ## [Definition](#definition)Copy Anchor Link An artifact is a file or directory that a job uploads so that a person or a later job can fetch it: a compiled binary, a test report, a coverage file, a packaged installer, a crash log. Retention is the deletion policy attached to that upload. Three values decide what happens to any single artifact: 1. A platform default, applied when nobody chooses anything. 2. A ceiling set by the account, organization, or repository owner, which no upload may exceed. 3. A per-upload override chosen by the workflow author, valid anywhere inside that ceiling. The per-upload value wins when it sits inside the ceiling. A value above the ceiling is capped at the maximum and the run logs a warning, so the override can shorten a window and never extend one. ### [The numbers on GitHub Actions](#the-numbers-on-github-actions)Copy Anchor Link GitHub applies one setting to both workflow artifacts and workflow logs, and the allowed range depends on repository visibility. Values below are from the [GitHub Actions limits reference](https://docs.github.com/en/actions/reference/limits) and the [repository Actions settings documentation](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository), checked on 2026-08-13. | Scope | Default window | Allowed range | | ------------------------------------------------- | ------------------------------- | ---------------------------------- | | Public repository artifacts and logs | 90 days | 1 to 90 days | | Private or internal repository artifacts and logs | 90 days | 1 to 400 days | | Per upload, via retention-days | inherits the repository setting | 1 day up to the repository maximum | Two properties of that setting catch teams out. The first is inheritance: an enterprise limit constrains its organizations, an organization limit constrains its repositories, and a repository admin can only choose a value at or below whatever sits above. The second is that a change applies to objects uploaded afterwards. Lowering a repository from 90 days to 7 does not delete the artifacts already sitting in storage, so a cleanup pass has to remove those explicitly. ### [When the clock starts](#when-the-clock-starts)Copy Anchor Link The retention period for an artifact counts from the upload, rather than from the end of the workflow run. Logs are kept from the completion of the run. A long run that uploads a bundle in its first job and finishes an hour later therefore has an artifact that expires slightly before its own logs do. ### [Retention against eviction](#retention-against-eviction)Copy Anchor Link Retention deletes on a schedule that is known the moment the file lands. A cache entry leaves a store by eviction instead, which is driven by how full the store is and how recently the entry was read, so no date can be read off a policy in advance. Artifacts expire. Caches get evicted. The [cache eviction glossary entry](/glossary/cache-eviction) covers the second mechanism. ### [What a retention window costs](#what-a-retention-window-costs)Copy Anchor Link Artifacts occupy storage for exactly as long as retention keeps them, so the steady-state footprint is a function of upload rate, artifact size, and window length: Copy Text ``` steady state bytes = uploads per day x artifact size x retention days ``` Take a repository that merges 40 times a month and uploads a 2 GB installer on each merge. At the 90 day default that is roughly 1.33 uploads per day held for 90 days, or about 240 GB live at any moment. Setting `retention-days: 7` on the same upload holds about 9.3 copies, or roughly 18.7 GB. The build is identical in both cases, and the deletion date is the only variable that moved. ## [Example](#example)Copy Anchor Link This workflow uploads two artifacts from one job and gives them different windows. The bundle is a rebuildable output that only matters until the next merge, and the test report is the record someone may want to read months later. Copy Text ``` name: build on: push: branches: [main] jobs: package: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: make dist - name: Upload distributable bundle uses: actions/upload-artifact@v4 with: name: dist-bundle path: dist/ retention-days: 7 - name: Upload test report uses: actions/upload-artifact@v4 with: name: junit-report path: reports/junit.xml ``` The `retention-days` input is documented on [actions/upload-artifact](https://github.com/actions/upload-artifact), checked on 2026-08-13\. The second step omits it, so `junit-report` inherits the repository setting. Here is what a reader of that run sees over time, assuming the repository is left at the 90 day default. | Day after the run | dist-bundle | junit-report | | ----------------- | ---------------------------------------------- | --------------------------------------- | | 0 | Listed in the run summary with a download link | Listed with a download link | | 6 | Still downloadable | Still downloadable | | 8 | Deleted, no longer listed in the run summary | Still downloadable | | 91 | Deleted | Deleted, run summary lists no artifacts | The run page itself survives. Logs, job results, and commit metadata stay put, and the artifacts section of the summary is the part that empties out. The same transition is visible through the REST API. Each entry returned by the [artifacts endpoint](https://docs.github.com/en/rest/actions/artifacts) carries `created_at`, `expires_at`, and a boolean `expired` field, checked on 2026-08-13\. Once `expired` is true the archive download no longer resolves, and any link that was pasted into a pull request comment, a chat message, or a ticket points at nothing. Anything that needs to outlive the window belongs somewhere durable: a release asset, a package registry, or an object store the team controls. Deleting early is the other direction of the same lever. Removing an artifact from the run page or through the API frees its storage immediately rather than waiting for the window, which is the usual fix when one oversized upload is dominating a storage bill. ## [Related Terms](#related-terms)Copy Anchor Link * [How long GitHub Actions artifacts are kept](/answers/how-long-are-github-actions-artifacts-kept): the default window, the per-repository maximums, and how to check what a given repository is set to. * [Cache eviction](/glossary/cache-eviction): why a cache entry disappears without an expiry date attached to it. * [Large artifact uploads on GitHub Actions](/guides/large-artifact-uploads-github-actions): upload and download behavior when artifacts run to gigabytes. * [WarpBuild caching documentation](/docs/ci/features/caching): the cache action interface and how cache entries are stored on WarpBuild runners. * [WarpBuild pricing](/pricing): per minute runner rates by runner type. ## FAQ ### What is artifact retention? Artifact retention is the period a CI platform keeps uploaded build artifacts before deleting them. The window is set per repository and can be narrowed per upload. On GitHub Actions the default is 90 days, and when the window closes the artifact is deleted and its download link stops resolving. ### Does artifact retention mean the same thing as cache retention? No. Retention is a scheduled deletion with a date known at upload time. A cache entry is removed by eviction instead, which is driven by how full the store is and how recently the entry was read, so the removal date cannot be read off a policy. ### Can a workflow set a retention period longer than the repository setting? No. A retention-days value above the repository or organization maximum is capped at that maximum and the run logs a warning. The per-upload input can only shorten the window, never extend it past the ceiling the repository owner configured. --- # Audit Log | WarpBuild URL: https://www.warpbuild.com/glossary/audit-log Description: An audit log is an append only record of who changed what and when. What an entry carries, how a GitHub Actions team queries one, and how to export it. # Audit Log An audit log is an append only record of who changed what and when. What an entry carries, how a GitHub Actions team queries one, and how to export it. An audit log is an append only record of who changed what and when, kept so that a change can be reviewed after the fact. Each entry names an actor, an action, a target, and a timestamp, and entries are added rather than edited, so the sequence read a year later matches the sequence written at the time. For a team running GitHub Actions, the audit log is where configuration questions land: which account added a runner, who moved a repository into a runner group, and when a permission was widened. Job output lives elsewhere. WarpBuild keeps its own [audit log](/docs/ci/features/audit-logs) of the changes made in a WarpBuild organization, in the dashboard or through the API. ## [Definition](#definition)Copy Anchor Link An audit log is defined by four properties, and a record missing any one of them answers a reviewer's question only partly. | Property | What it means | Why a reviewer needs it | | --------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------- | | Append only | Entries are added; existing entries are never rewritten or deleted in place | A record that can be edited proves nothing about the past | | Attributed | Every entry names the identity that acted: a user account, an application, or a machine identity | "Someone changed the setting" closes no review | | Timestamped | Every entry carries a fixed time, normally in UTC | Ordering and windows such as "during Q2" depend on it | | Retained and readable | Entries survive for a defined period and can be read by people other than the actor | Self-reported history is not independent | Application logs, metrics, and traces exist to explain what a running system is doing right now, and they are routinely sampled, rotated, and dropped. An audit log serves the opposite time horizon: it is read months later by someone reconstructing a decision, often during an access review or an incident postmortem. ### [What an entry carries](#what-an-entry-carries)Copy Anchor Link GitHub exposes organization audit events through the REST endpoint `GET /orgs/{org}/audit-log`, documented in the [GitHub REST API reference for organization audit logs](https://docs.github.com/en/rest/orgs/orgs#get-the-audit-log-for-an-organization), checked on 2026-08-13\. Each entry in the response is a flat JSON object with a stable set of fields. | Field | Content | | -------------- | ----------------------------------------------------------------------------- | | action | The event name in category.verb form, such as self\_hosted\_runner.created | | actor | The account that performed the action | | actor\_ip | The source address, present when the organization has enabled IP display | | created\_at | Event time as a Unix timestamp in milliseconds | | org | The organization the event belongs to | | repo | The repository the event targeted, when the action has one | | user | The account the action was performed on, for membership and permission events | | \_document\_id | A unique id per entry, useful for deduplicating repeated exports | Two parameters shape the result set. `include` selects the event families to return and accepts `web`, `git`, or `all`. `phrase` carries a search string built from qualifiers, the same syntax the web interface uses. ### [Qualifiers that answer a review question](#qualifiers-that-answer-a-review-question)Copy Anchor Link | Qualifier | Example | Matches | | --------- | ------------------------------ | -------------------------------------------------------------------------------- | | action: | action:self\_hosted\_runner | Every event in that category, or a single event with the full category.verb form | | actor: | actor:dana-eng | Events performed by one account | | repo: | repo:octo-org/api | Events targeting one repository | | created: | created:2026-04-01..2026-06-30 | Events inside a date window | | country: | country:DE | Events by source country | The qualifier list and the date range syntax are documented in [GitHub's guide to reviewing the audit log for an organization](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/reviewing-the-audit-log-for-your-organization), checked on 2026-08-13\. The event names themselves, grouped by category, are in [audit log events for your organization](https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/audit-log-events-for-your-organization). Runner changes sit in the `self_hosted_runner` and `self_hosted_runner_group` categories. Retention is the property teams discover late. GitHub keeps audit events for a documented window rather than forever, so a review that reaches back further than that window needs the events copied somewhere the organization controls before they age out. ## [Example](#example)Copy Anchor Link A reviewer opens a quarterly access review with a narrow question: which account changed a runner setting in the last quarter, and when. The question has two halves, an actor and a time, which is exactly the shape an audit log is built to answer. In the web interface the path is organization Settings, then Logs, then Audit log, with this search phrase: Copy Text ``` action:self_hosted_runner created:2026-04-01..2026-06-30 ``` The same query through the API returns JSON that can be attached to the review: Copy Text ``` gh api --paginate "/orgs/octo-org/audit-log" \ -f include=web \ -f phrase="action:self_hosted_runner created:2026-04-01..2026-06-30" ``` A matching entry looks like this, and it closes both halves of the question: Copy Text ``` { "action": "self_hosted_runner.created", "actor": "dana-eng", "org": "octo-org", "created_at": 1780531200000, "_document_id": "aBc123-example" } ``` Running that query by hand once a quarter works until the window in question is older than the retention period. A scheduled workflow keeps the evidence instead: Copy Text ``` name: export-audit-log on: schedule: - cron: '0 6 1 * *' workflow_dispatch: permissions: contents: read jobs: export: runs-on: ubuntu-latest steps: - name: Pull the last month of runner configuration events env: GH_TOKEN: ${{ secrets.AUDIT_LOG_TOKEN }} run: | since=$(date -u -d '1 month ago' +%Y-%m-%d) gh api --paginate "/orgs/${{ github.repository_owner }}/audit-log" \ -f include=web \ -f phrase="action:self_hosted_runner created:>=${since}" \ > runner-events.json - name: Keep the export with the review record uses: actions/upload-artifact@v4 with: name: runner-audit-events path: runner-events.json retention-days: 90 ``` Three details in that workflow matter. The token comes from a secret because the `GITHUB_TOKEN` minted for a run is scoped to one repository and cannot read organization audit events; the endpoint requires an organization owner and the `read:audit_log` scope. `--paginate` is present because a quarter of events for a large organization exceeds one page. And `_document_id` in each entry lets a downstream job drop duplicates when two monthly runs overlap at a boundary. An artifact is a first step rather than an archive. Copying the export into storage the workflow cannot rewrite, and that a repository administrator cannot clear, keeps the append only property intact once the events leave GitHub. ## [Related Terms](#related-terms)Copy Anchor Link * [How to find which account changed a runner setting and when](/answers/how-do-i-audit-who-changed-runner-settings): the query, the event categories to search, and what to capture for a review. * [What a GitHub Actions security review asks for](/enterprise/ci-security-review): the evidence a security questionnaire expects around runners, isolation, and access. * [Single sign-on, the identity layer behind the actor field](/glossary/single-sign-on): how a central identity provider makes the actor on each entry map to a real person. * [WarpBuild audit logs](/docs/ci/features/audit-logs): every change made in a WarpBuild organization, with who made it, the outcome and the time, kept for 13 months and exportable as CSV. * [WarpBuild runner security documentation](/docs/ci/security): how runner isolation, storage, and secrets handling are documented. * [WarpBuild single sign-on setup](/docs/sso): the SAML 2.0 and OIDC connection steps for an organization. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What separates an audit log from an application log? Purpose and write behavior. An application log records what a program did so an engineer can debug it, and it can be rotated, sampled, or rewritten. An audit log records who changed a configuration or a permission so a reviewer can reconstruct that change later, so entries are written once, carry an identity and a timestamp, and are readable by people who did not make the change. ### Which GitHub Actions changes appear in an organization audit log? Configuration and permission changes rather than job output. Adding or removing a self-hosted runner, moving a runner between runner groups, changing Actions settings for a repository, and changing who can administer those settings all produce entries. What a job printed lives in the workflow run logs instead, under a separate retention setting. ### Can a workflow read the audit log through the REST API? Yes, with a token that carries the read:audit\_log scope and belongs to an organization owner. The GITHUB\_TOKEN minted for a workflow run is scoped to one repository and does not carry that scope, so a scheduled export job reads the token from a secret instead. --- # Autoscaling Runner | WarpBuild URL: https://www.warpbuild.com/glossary/autoscaling-runner Description: An autoscaling runner is a self-hosted GitHub Actions runner created in response to queued jobs and released when demand falls. How the pattern works. # Autoscaling Runner An autoscaling runner is a self-hosted GitHub Actions runner created in response to queued jobs and released when demand falls. How the pattern works. An autoscaling runner is a self-hosted GitHub Actions runner whose capacity is created in response to queued jobs and released again when demand falls, instead of staying registered around the clock. The name describes a pattern rather than a product: something watches the job queue, adds runners while work is waiting, and takes them away once the queue drains. Autoscaling is a property of self-hosted fleets. GitHub-hosted runners are provisioned per job by GitHub, so the question of how many machines to keep online only arises once you register runners of your own. ## [Definition](#definition)Copy Anchor Link GitHub's [self-hosted runners reference](https://docs.github.com/en/actions/reference/runners/self-hosted-runners) describes autoscaling as dynamically adjusting the number of self-hosted runners based on demand (read on 2026-08-13). Every implementation of that idea has the same three parts. **A queue signal.** Something has to learn that jobs are waiting. The [workflow\_job webhook](https://docs.github.com/en/webhooks/webhook-events-and-payloads) is available at the repository, organization, and enterprise levels, and its payload carries an `action` key that follows the stages of a job's lifecycle, so `queued` is a request for capacity and `completed` releases it. GitHub notes that webhook-driven scaling depends on the timeliness of webhook delivery, which introduces delay and reliability concerns, and points higher volume fleets at the scale set APIs instead. The reference implementation of those APIs is [Actions Runner Controller](/glossary/actions-runner-controller), whose listener holds an outbound long poll and is told when a job is available. **A provisioning step.** A machine boots, carries or installs the runner agent, and registers with the labels the workflow names in `runs-on`. Registration uses a registration token passed to `config.sh` or a just-in-time runner configuration created through the [REST API for self-hosted runners](https://docs.github.com/en/rest/actions/self-hosted-runners). Until a runner carrying every requested label is online and idle, the job stays in the queue. **A teardown rule.** GitHub recommends autoscaling with ephemeral runners and advises against autoscaling persistent ones, because it cannot guarantee that a persistent runner will be spared a job assignment while it is shutting down. A runner registered with `--ephemeral` accepts one job and then deregisters, which collapses "job finished" and "capacity released" into a single event. Four documented numbers bound the pattern. Values below come from GitHub's [Actions limits](https://docs.github.com/en/actions/reference/limits) and the self-hosted runners reference, both read on 2026-08-13. | Documented behavior | Value | Effect on an autoscaler | | ------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- | | Job queue time | 24 hours | A job with no matching runner waits rather than failing, and is cancelled once the window expires | | Job pickup after assignment | 60 seconds | An assigned runner that does not claim the job in time returns it to the queue for another runner | | Runners registered at once per runner group | 10,000 | The ceiling on a single group, well above what most queues reach | | Runner software updates | 30 days | A runner that goes 30 days without a software update stops being queued jobs, so long-lived images need rebuilds | Scaling to zero and back puts machine boot, image pull, and runner registration in front of the first workflow step. Holding a floor of idle runners moves that wait off the critical path and pays for the idle time instead. That floor is a [warm pool](/glossary/warm-pool), and picking its size is the main tuning decision in any autoscaling setup. ## [Example](#example)Copy Anchor Link A push to `main` runs a twelve shard test matrix. Two runners are registered and idle at the moment the push lands. Copy Text ``` name: test on: push: branches: [main] jobs: unit-tests: runs-on: [self-hosted, linux, x64] strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] steps: - uses: actions/checkout@v4 - run: ./run-tests.sh --shard ${{ matrix.shard }} --of 12 ``` GitHub expands the matrix into twelve independent jobs, each queued on its own and each visible to the autoscaler as a separate `workflow_job` event with `action` set to `queued`. Two of them start immediately on the idle runners. The other ten are the demand signal. | Point in the run | Jobs queued | Runners registered | What the autoscaler does | | -------------------- | ------------ | ------------------ | ----------------------------------------------------------- | | Push lands | 12 | 2 idle | Two shards start, ten queued events stay unmatched | | Capacity request | 10 | 2 | Requests ten machines from the cloud API or the cluster | | New runners register | 10 down to 0 | 12 | Each new runner claims one shard | | Shards finish | 0 | 12 down to 2 | Ephemeral runners deregister after their single job | | Queue empty | 0 | 2 | No new registrations happen and the fleet sits at its floor | Each new machine registers with the same labels the job asked for: Copy Text ``` ./config.sh --url https://github.com/my-org/my-repo \ --token "$RUNNER_REGISTRATION_TOKEN" \ --labels self-hosted,linux,x64 \ --ephemeral ``` Those labels are the contract with the workflow. A job asking for `[self-hosted, linux, x64]` matches a runner advertising all three, and a runner missing one of them is skipped, so a label typo in either place looks exactly like an autoscaler that failed to scale. Shrinking is mostly passive in this design. Ephemeral runners remove themselves after their one job, so the scale-in work left over is releasing the underlying machine and keeping the count honest. Counting queued events alone drifts, because jobs get cancelled, `fail-fast` matrices stop mid-flight, and a job that was not picked up inside 60 seconds is queued a second time. Autoscalers that survive contact with a real repository reconcile against current queue state on a timer instead of trusting the event stream to balance. Pointing the same matrix at a managed fleet changes one line and nothing about the shape above. The queue signal, the provisioning step, and the teardown rule are unchanged; the pool answering them is one a provider operates instead of one you built: Copy Text ``` jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-2x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] steps: - uses: actions/checkout@v4 - run: ./run-tests.sh --shard ${{ matrix.shard }} --of 12 ``` A label of the shape `warp-ubuntu-latest-x64-2x` is one opaque routing key, so the ten queued events still read as ten capacity requests and the pool still drains back to its floor once the shards finish. What moves is who is holding the queue signal. ## [Related Terms](#related-terms)Copy Anchor Link * [Warm pools and the idle capacity that removes boot latency](/glossary/warm-pool): the standing floor an autoscaling fleet keeps to hide boot time. * [Actions Runner Controller and how it autoscales runner pods](/glossary/actions-runner-controller): the Kubernetes implementation of this pattern, with a listener and ephemeral pods. * [Scaling self-hosted runners without owning the cluster](/guides/github-actions-self-hosted-runner-scaling): the same queue problem approached without a controller to run. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner labels available and how a workflow selects them. * [BYOC runners documentation](/docs/ci/byoc): running managed runners inside a cloud account you own. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What makes a runner an autoscaling runner? Its capacity follows the job queue. A queue signal reports that jobs are waiting, a provisioning step boots machines and registers them with the labels the workflow asks for, and a teardown rule removes them once the work is done. GitHub's self-hosted runners reference describes autoscaling as dynamically adjusting the number of self-hosted runners based on demand. ### Why does GitHub recommend ephemeral runners for autoscaling? Because an ephemeral runner accepts exactly one job and then deregisters, so job completion and capacity release are the same event. GitHub's self-hosted runners reference recommends autoscaling with ephemeral runners and advises against autoscaling persistent runners, since a persistent runner can be assigned a job while it is shutting down. ### What happens when the autoscaler is too slow? The job waits. GitHub does not fail a job that has no matching runner online; it stays queued until a runner carrying every requested label comes online, and GitHub's Actions limits page documents a 24 hour job queue time after which the job is automatically cancelled. A scaling gap therefore reads as a slow queue rather than an error. --- # Base Image | WarpBuild URL: https://www.warpbuild.com/glossary/base-image Description: A base image is the image a Dockerfile FROM instruction names, supplying the filesystem a build starts from. How the reference resolves and invalidates cache. # Base Image A base image is the image a Dockerfile FROM instruction names, supplying the filesystem a build starts from. How the reference resolves and invalidates cache. A base image is the image named in a Dockerfile `FROM` instruction, and it supplies the filesystem and image configuration that the build starts from. Every instruction after `FROM` adds a layer on top of that starting point, so the base image decides which shell, package manager, libraries, and default user exist before the first `RUN` line executes. The reference in a `FROM` line is usually a mutable tag rather than a fixed identity. That single detail explains most base image surprises inside a GitHub Actions workflow: the tag can point at new content between two runs of the same commit, and the build behaves differently as a result. ## [Definition](#definition)Copy Anchor Link The Dockerfile reference defines `FROM` as the instruction that initializes a new build stage and sets the base image for the instructions that follow ([Dockerfile reference, FROM](https://docs.docker.com/reference/dockerfile/#from), checked on 2026-08-13). The image it names contributes two things to the build: 1. **A root filesystem**, delivered as an ordered set of layers. Anything the build expects to find before it installs something (a libc, a shell, certificate bundles, a package manager, a language runtime) either arrives here or has to be added by a later instruction. 2. **An image configuration**, which is a JSON document carrying `ENV`, `WORKDIR`, `USER`, `ENTRYPOINT`, `CMD`, exposed ports, and labels. These values are inherited as the defaults of the image being built and stay in effect until an instruction overrides them. One reserved name has no filesystem at all. `FROM scratch` starts a stage from an empty layer set, which is how statically linked binaries and the published minimal images are assembled. Terminology drifted over time and both usages are still in circulation. Older Docker documentation reserved "base image" for an image with no parent, built `FROM scratch`, and called the image your Dockerfile sits on top of the "parent image". Current usage treats the image named in `FROM` as the base image, which is the sense used on this page and the sense a build tool error message means. ### [One base image per stage](#one-base-image-per-stage)Copy Anchor Link Each `FROM` instruction opens a new stage, so the count of base images in a Dockerfile equals the count of `FROM` lines. A build that compiles in a toolchain image and ships the result in a slim runtime image names two base images, and only the final stage contributes layers to the published result. The [multi stage build entry](/glossary/multi-stage-build) covers how stages are named, selected, and copied between. A `FROM` line can also name an earlier stage instead of a registry reference, as in `FROM build AS test`. The stage is then the base image, and the same inheritance rules apply. ### [How the reference resolves](#how-the-reference-resolves)Copy Anchor Link The text after `FROM` is a reference, and the reference form decides how stable the build's starting point is across runs. | Reference form | Example | What it resolves to | Stability between two builds | | --------------- | ----------------------- | --------------------------------------------------- | --------------------------------------------------- | | Floating tag | node:22 | Whatever the registry currently serves for that tag | Changes whenever the maintainer republishes the tag | | Pinned digest | node@sha256: | Exactly one manifest, by content address | Fixed, and a mismatch fails the pull | | Tag plus digest | node:22@sha256: | The digest, with the tag kept as a readable hint | Fixed, and the tag documents the intent | | Earlier stage | build | A stage defined higher in the same Dockerfile | Fixed within the build | A tag on a public image such as `node:22` is a moving pointer by design: the maintainers republish it when the patch release, the distribution packages, or the security fixes underneath it change. The digest is the content address of the manifest and identifies one exact image. Multi-platform references add a second step. A tag commonly points at an image index that lists one manifest per platform, so the digest a build resolves depends on the target platform. `docker buildx imagetools inspect node:22` prints the index and the per-platform digests underneath it, which is the fastest way to see what a `FROM` line resolves to right now. ## [Example](#example)Copy Anchor Link This Dockerfile builds a Node application on a slim base image. Nothing here is unusual, and that is the point: the only reference to the outside world is on line 3. Copy Text ``` # syntax=docker/dockerfile:1 FROM node:22-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY . . CMD ["node", "server.js"] ``` The workflow below builds it on a GitHub Actions runner and keeps a registry cache so that repeat builds reuse layers. Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . push: true pull: true tags: ghcr.io/${{ github.repository }}:latest cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max ``` ### [What happens when the tag moves](#what-happens-when-the-tag-moves)Copy Anchor Link Assume Monday's run pushed the image and populated the cache. On Tuesday the Node maintainers republish `node:22-slim` with a patched distribution package, so the tag now resolves to a different digest. Wednesday's push changes one file under `src/`. BuildKit computes a cache key for each step from the step itself and the key of the step before it, so an invalidated step invalidates everything downstream of it ([Docker build cache invalidation](https://docs.docker.com/build/cache/invalidation/), checked on 2026-08-13). The base image is the parent of the first instruction, which puts it at the head of that chain. | Instruction | Monday | Wednesday, after the tag moved | | ----------------------------------------- | ------ | ------------------------------------------------- | | FROM node:22-slim | Pulled | New digest, so the chain restarts here | | WORKDIR /app | Cached | Re-executed | | RUN apt-get update && apt-get install ... | Cached | Re-executed, downloading package lists again | | COPY package.json package-lock.json ./ | Cached | Re-executed | | RUN npm ci --omit=dev | Cached | Re-executed, downloading the dependency set again | | COPY . . | Cached | Re-executed, and the source changed anyway | The `apt-get` and `npm ci` steps are the expensive ones, and neither was touched by the commit. A registry cache does not help, because the cache entries recorded on Monday were keyed against the old parent digest and no key from Wednesday matches them. The `pull: true` input in the workflow above is what makes the update land on the same day it is published. It maps to the `--pull` behavior of the build command and forces the builder to re-resolve every referenced tag instead of reusing an image already in its local store. Dropping it produces the mirror-image problem: a long-lived builder keeps serving a stale digest, so the workflow reports green while the published image still carries the unpatched packages. Pinning turns the moving part into a reviewable one: Copy Text ``` # comes from: docker buildx imagetools inspect node:22-slim FROM node:22-slim@sha256: ``` The build now resolves the same manifest on every run until a commit changes the digest, so a rebuild triggered by an unrelated push cannot pick up a different starting point. The tradeoff is that patches stop arriving on their own and need an update path of their own, which is what the [base image update workflow guide](/guides/base-image-update-workflow-github-actions) sets up on a schedule. ## [Related Terms](#related-terms)Copy Anchor Link * [Automating base image updates in a GitHub Actions workflow](/guides/base-image-update-workflow-github-actions): a scheduled workflow that checks for a new digest behind a pinned reference and opens a pull request with the bump. * [Multi stage builds and the base image each stage starts from](/glossary/multi-stage-build): why a Dockerfile has one base image per `FROM` line and how only the last stage reaches the published image. * [Why a base image update invalidates a Docker build cache](/answers/why-do-my-docker-builds-lose-cache-after-a-base-image-update): the cache key chain behind the table above, and the steps that pay for it. * [Dockerfile reference for FROM](https://docs.docker.com/reference/dockerfile/#from): the upstream definition of the instruction, its `AS` form, and the `scratch` reserved name. * [Docker build cache invalidation](https://docs.docker.com/build/cache/invalidation/): the rules that decide when a step is reused and when it runs again. * [WarpBuild Docker builders documentation](/docs/ci/docker-builders): builder profiles and the actions that point a workflow at a builder. * [WarpBuild caching documentation](/docs/ci/features/caching): cache usage inside GitHub Actions workflows. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a base image in a Dockerfile? The base image is the image named in a FROM instruction. It supplies the root filesystem and the image configuration that the instructions after it build on, so the tools, libraries, default user, and PATH available to the first RUN line all come from it. ### Does every Dockerfile have exactly one base image? A Dockerfile has one base image per stage, and each FROM instruction starts a stage. A two stage Dockerfile that compiles in one image and copies the result into a smaller runtime image names two base images, and only the last stage decides what the published image contains. ### Why does a base image update invalidate the rest of the build cache? Each step's cache key includes the parent it was built on, so a new digest behind the same tag changes the parent of the first instruction and every key downstream of it. The build then re-executes every instruction after FROM even when the Dockerfile and the source tree are unchanged. --- # Build Duration Percentile | WarpBuild URL: https://www.warpbuild.com/glossary/build-duration-percentile Description: A build duration percentile is the time below which a stated share of builds finish, such as p50 or p95. Why averages hide the slow tail developers feel. # Build Duration Percentile A build duration percentile is the time below which a stated share of builds finish, such as p50 or p95\. Why averages hide the slow tail developers feel. A build duration percentile is the duration below which a stated share of builds finish, written as p50, p95, or p99\. A p95 build duration of twenty minutes means 95 percent of the runs in the window completed in twenty minutes or less and the remaining 5 percent took longer. Percentiles are reported because the average of a build duration series is pulled upward by a small number of slow runs while still landing below all of them. The slow tail is the part a developer sits through, and it is the part an average erases. ## [Definition](#definition)Copy Anchor Link Take every run of one job over a stated window, sort the durations from shortest to longest, and read off the value at a rank. The nearest-rank method puts the p-th percentile at index `ceil(p / 100 * N)` in that sorted list, where `N` is the number of runs. p50 is the median, p95 is the value with 5 percent of runs above it, and p99 is the value with 1 percent above it. Four choices decide what the resulting number actually describes. **The unit being measured.** A duration can be a step, a job, or a whole workflow run. A workflow run holds jobs that execute in parallel, so its duration is the critical path through the dependency graph rather than the sum of its jobs. Percentiles taken at different units are not interchangeable, and the p95 of a workflow is unrelated to the sum of the p95s of its jobs. **Where the clock starts.** A number measured from the moment the job was queued includes time spent waiting for a machine. A number measured from the first step includes only execution. Those two series diverge under load, and mixing them turns a capacity problem into what looks like a slow test suite. See [queue time](/glossary/queue-time) for the interval that sits between them. **The estimation method.** Nearest-rank returns a value that actually occurred. Linear interpolation returns a value between the two neighboring runs. On the same 20 runs used below, nearest-rank gives a p95 of 19:48 and linear interpolation gives 20:01\. The difference is small at p95 with a healthy sample and grows as the sample shrinks. **The population.** Percentiles do not average and do not add. Two jobs each with a p95 of 10 minutes do not combine into a fleet p95 of 10 minutes, and a weekly p95 cannot be recovered from seven daily p95 values. The underlying durations have to be re-ranked over the new window. Sample size sets a floor on what a percentile can resolve. With 20 runs the p95 lands on the second slowest run, so a single unlucky build swings it. With 100 runs it lands on the fifth slowest. Tooling that reports p75 and p90 rather than p95 is making the same tradeoff in the other direction: a lower percentile is steadier on a thin sample and hides less of the tail than an average does. A p95 cannot be derived from a p75 and a p90, so the percentile a report exposes is the one a budget has to be written against. ## [Example](#example)Copy Anchor Link This workflow writes one line per run into the job summary: the elapsed seconds and whether the dependency cache was restored. Those lines are what a percentile is computed from later. Copy Text ``` name: test on: push: jobs: unit-tests: runs-on: ubuntu-latest steps: - name: Mark start id: start run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore npm cache id: npm-cache uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - run: npm ci - run: npm test - name: Record duration if: always() env: START: ${{ steps.start.outputs.epoch }} CACHE_HIT: ${{ steps.npm-cache.outputs.cache-hit }} run: | seconds=$(( $(date +%s) - START )) echo "job=unit-tests seconds=$seconds cache_hit=${CACHE_HIT:-false} run=${{ github.run_id }}" \ >> "$GITHUB_STEP_SUMMARY" ``` The clock starts at the first step, so this series measures execution and leaves queue time out. Recording `cache_hit` on the same line is what makes the split further down possible. Twenty consecutive runs of that job, sorted shortest to longest: | Rank | Duration | Seconds | Cache restored | | ---- | -------- | ------- | -------------- | | 1 | 3:20 | 200 | yes | | 2 | 3:31 | 211 | yes | | 3 | 3:38 | 218 | yes | | 4 | 3:44 | 224 | yes | | 5 | 3:47 | 227 | yes | | 6 | 3:52 | 232 | yes | | 7 | 3:55 | 235 | yes | | 8 | 3:58 | 238 | yes | | 9 | 4:00 | 240 | yes | | 10 | 4:02 | 242 | yes | | 11 | 4:06 | 246 | yes | | 12 | 4:11 | 251 | yes | | 13 | 4:19 | 259 | yes | | 14 | 4:28 | 268 | yes | | 15 | 5:02 | 302 | yes | | 16 | 6:41 | 401 | no | | 17 | 9:14 | 554 | no | | 18 | 13:36 | 816 | no | | 19 | 19:48 | 1188 | no | | 20 | 24:10 | 1450 | no | Nearest-rank puts p50 at index `ceil(0.5 * 20)`, which is rank 10, giving 4:02\. It puts p95 at index `ceil(0.95 * 20)`, which is rank 19, giving 19:48\. The arithmetic mean of the same twenty runs is 6:40, which is higher than fifteen of the twenty runs and still under a third of the p95\. Quoting 6:40 as the build time misrepresents both ends of the series. The gap between 4:02 and 19:48 is a ratio of about 5 to 1, and a spread that wide says the same steps are running under conditions that change from run to run. Identical work on a stable machine with stable inputs produces a tight distribution, so a fanned-out tail points at something outside the test code: a cache that sometimes misses, a shared machine under contention, a network dependency that occasionally retries, or two different populations of runs recorded under one label. The `cache_hit` column identifies which one it is here. Splitting the same twenty runs by that field: | Population | Runs | p50 | p95 | | -------------- | ---- | ----- | ----- | | Cache restored | 15 | 3:58 | 5:02 | | Cache missed | 5 | 13:36 | 24:10 | Each population is tight on its own. The combined p95 of 19:48 was describing a mixture, and the fix it points at is cache key design rather than test performance. The five-run population also shows the sample size limit directly: its p95 lands on the slowest run it has, so that 24:10 is an upper bound on a thin sample rather than a stable figure. Two habits follow. Split a duration series by anything that changes the work before reading a percentile from it, including matrix variant, runner label, event type, and cache outcome. And track p50 and p95 side by side, because the p50 tracks the common path while the p95 tracks the run a developer is waiting on when they ask why the build is slow. ## [Related Terms](#related-terms)Copy Anchor Link * [Reading build duration percentiles out of GitHub Actions run history](/guides/github-actions-build-duration-percentiles): pulling per-job durations from run history and turning them into p50 and p95 series per workflow. * [Setting p50 and p95 build time budgets per workflow class](/guides/ci-slo-and-build-time-budgets): choosing targets per workflow class, and the escalation rule for a broken budget. * [Queue time and why it sits outside the duration measure](/glossary/queue-time): the interval between a job being queued and a runner starting it, which is a separate series with its own percentiles. * [GitHub Actions job summaries](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#adding-a-job-summary): the upstream reference for the `$GITHUB_STEP_SUMMARY` file used in the example. * [WarpBuild reports documentation](/docs/ci/features/reports): per-job duration and queue-time percentiles with CSV export. * [WarpBuild observability documentation](/docs/ci/features/observability): CPU, memory, and disk utilization per runner instance, which is where a wide duration spread gets checked against resource limits. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What does p95 build duration mean? It means 95 percent of the builds in the window finished at or below that time and 5 percent took longer. It is a property of a set of runs over a stated window, so a p95 quoted without the window, the job, and the run count cannot be compared against another p95. ### Why report a percentile instead of the average build time? The average is pulled upward by a handful of slow runs and still lands below every one of them, so it describes neither the common case nor the tail. A p50 and a p95 together give the typical run and the run a developer remembers waiting through. ### How many runs does a p95 need before it means anything? At 20 runs the p95 sits at the second slowest run, so one unlucky build moves it by minutes. Read p95 over a window with at least a few hundred runs, or fall back to p75 and p90 when the sample is small and state which one you used. --- # Build Matrix | WarpBuild URL: https://www.warpbuild.com/glossary/build-matrix Description: A build matrix expands one job definition into many jobs, one per combination of the values you list. How matrix keys, include, exclude and fail-fast behave. # Build Matrix A build matrix expands one job definition into many jobs, one per combination of the values you list. How matrix keys, include, exclude and fail-fast behave. A build matrix is a workflow construct that expands one job definition into many jobs, one for each combination of the variables you list. In GitHub Actions it is written as a `strategy.matrix` block on a single job, and every combination it produces runs as an independent job with its own runner, its own workspace, and its own log. The construct predates GitHub Actions and shows up in most workflow systems under names such as matrix, axes, or fan-out. The spelling below is the GitHub Actions one, because that is where most engineers meet the idea first. ## [Definition](#definition)Copy Anchor Link Each key under `strategy.matrix` declares one variable and holds a list of values. The workflow expands the job into the Cartesian product of those lists, so a matrix with a list of three values and a list of four values produces twelve jobs from a single block of YAML. Three words cover most conversations about matrices. A dimension, sometimes called an axis, is one key and its list. A combination is one selection of a value from every dimension, and it becomes one job. Expansion is the moment the workflow turns the definition into that set of jobs, which happens when the run starts. Every expanded job receives its own values through the `matrix` context. A job reads them as `${{ matrix.os }}` or `${{ matrix.node }}`, and those expressions are valid in `runs-on`, in step inputs, in `env`, and in `if` conditions. That is the whole mechanism: one definition, one context lookup per job, many machines. Expanded jobs are independent. They do not share a filesystem, a step sequence, or a process. Anything one combination needs from another has to travel through an artifact, a cache entry, or a job output. The GitHub Actions run view names each expanded job with the job id followed by the combination values in parentheses, so `test (ubuntu-latest, 20)` is the display name of the job for the `ubuntu-latest` and `20` pair. That naming has a practical consequence: required status checks are matched by name, so changing a matrix value renames the check and a branch protection rule that referenced the old name stops matching. ### [The strategy keys](#the-strategy-keys)Copy Anchor Link Five keys shape a matrix. The behavior below is from the [GitHub Actions workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax), checked on 2026-08-13. | Key | What it does | Default | | ------------- | -------------------------------------------------------------------------------------------- | ------------------------------------- | | matrix. | Declares one dimension and its list of values. The product of all dimensions is the job set. | none | | include | List of objects that either add keys to matching combinations or append new combinations. | empty | | exclude | List of partial combinations to remove from the product. | empty | | fail-fast | Cancels the other in-progress jobs in the matrix when one job fails. | true | | max-parallel | Ceiling on how many jobs from this matrix run at the same time. | unset, so runner availability decides | ### [How include and exclude change the job set](#how-include-and-exclude-change-the-job-set)Copy Anchor Link `exclude` is the simpler of the two. Each entry is a partial combination, and any expanded job whose values match every key in that entry is dropped from the set. `include` has two behaviors that depend on the object it holds. When the object's keys match an existing combination and add no conflicting value to the original dimensions, its extra key and value pairs are merged into that combination, and the job count stays the same. When the object describes a combination the product never produced, it is appended as one new job. Order matters when both keys appear. GitHub processes `exclude` first and `include` afterwards, which means an `include` entry can add back a combination that `exclude` removed. Reading a matrix from top to bottom in the file gives the wrong answer for that case, so count the product first, subtract the exclusions, then apply the inclusions. ### [fail-fast and what cancellation costs](#fail-fast-and-what-cancellation-costs)Copy Anchor Link `fail-fast` defaults to `true`. The first matrix job that reports a failure cancels the other jobs from the same matrix that are already running, and jobs from that matrix still waiting in the queue never start. That default suits a matrix used as a gate, where one broken combination is enough to block the merge and the remaining jobs would only spend minutes confirming it. It works against a matrix used as a compatibility report, where the question is which of the twelve combinations passed. In that second case one early failure leaves you with a run full of cancelled jobs and no picture of the rest, so set `fail-fast: false` and read the whole grid. ### [Matrix size and available capacity](#matrix-size-and-available-capacity)Copy Anchor Link Expansion is bounded by a documented limit. A matrix generates a maximum of 256 jobs per workflow run, and the limit applies to GitHub-hosted and self-hosted runners alike ([GitHub Actions usage limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). A product that grows past that number fails the run rather than truncating the set, so wide matrices built from generated lists need a guard on the list length. Execution is bounded by something else. Expansion creates every job at the same instant, and each job then waits for an online runner that advertises every label in its `runs-on` key. A matrix of 24 jobs facing 6 eligible runners runs in 4 waves, so its wall clock time is roughly four job durations plus queue time, and doubling the matrix width without adding runners doubles the wall clock rather than the parallelism. Total consumption behaves differently again. Those 24 jobs at 6 minutes each are 144 job minutes whichever way the waves fall. Parallelism moves when the minutes are spent, and the matrix width decides how many minutes exist at all. This is the arithmetic to run before adding a fourth dimension to a matrix that already has three. `max-parallel` narrows the set deliberately. It is the right key when the constraint sits outside the runner fleet: a rate-limited external API, a fixed number of license seats, or a shared test database that cannot take 24 concurrent connections. Setting it below the fleet size trades wall clock for a constraint you do not control. Two more capacity notes are worth carrying. Matrix jobs count against whatever concurrency ceiling applies to the account or fleet they run on, which the [concurrency limit entry](/glossary/concurrency-limit) covers. And a job that never finds a matching runner sits in the queue rather than failing, so a label typo in one dimension of a matrix shows up as one permanently pending job next to a row of green ones. ## [Example](#example)Copy Anchor Link This workflow tests a Node project across three operating systems and three Node versions. Copy Text ``` name: test on: push: branches: [main] jobs: test: strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] node: [20, 22, 24] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: npm ci - run: npm test ``` Three operating systems multiplied by three Node versions gives nine jobs from one job definition. The run view lists them as follows. | Job name | matrix.os | matrix.node | Runner selected by runs-on | | ------------------------- | -------------- | ----------- | -------------------------- | | test (ubuntu-latest, 20) | ubuntu-latest | 20 | GitHub-hosted Linux | | test (ubuntu-latest, 22) | ubuntu-latest | 22 | GitHub-hosted Linux | | test (ubuntu-latest, 24) | ubuntu-latest | 24 | GitHub-hosted Linux | | test (macos-latest, 20) | macos-latest | 20 | GitHub-hosted macOS | | test (macos-latest, 22) | macos-latest | 22 | GitHub-hosted macOS | | test (macos-latest, 24) | macos-latest | 24 | GitHub-hosted macOS | | test (windows-latest, 20) | windows-latest | 20 | GitHub-hosted Windows | | test (windows-latest, 22) | windows-latest | 22 | GitHub-hosted Windows | | test (windows-latest, 24) | windows-latest | 24 | GitHub-hosted Windows | Nine checkouts, nine `npm ci` runs, nine test runs. The steps were written once. ### [Counting a matrix that uses include and exclude](#counting-a-matrix-that-uses-include-and-exclude)Copy Anchor Link Adding both keys to the same block is where job counts start surprising people. Copy Text ``` strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] node: [20, 22, 24] exclude: - os: windows-latest node: 20 include: - os: ubuntu-latest node: 24 coverage: true - os: ubuntu-latest node: 25 ``` Work through it in the order GitHub applies the keys. The product is nine jobs. The `exclude` entry matches `test (windows-latest, 20)` and drops it, leaving eight. The first `include` entry matches the existing `ubuntu-latest` and `24` combination and merges `coverage: true` into it, so that job can branch on `${{ matrix.coverage }}` while the count stays at eight. The second `include` entry names a Node version that appears in no dimension, so it is appended as a new job. The run holds nine jobs, one of which carries a key the other eight do not have. ### [Pointing the same matrix at specific runner labels](#pointing-the-same-matrix-at-specific-runner-labels)Copy Anchor Link `runs-on` reads a matrix value like any other expression, so a dimension can hold runner labels instead of operating system names. That form puts the machine choice in the matrix and leaves the steps untouched. Copy Text ``` jobs: test: strategy: fail-fast: false matrix: runner: - warp-ubuntu-latest-x64-4x - warp-ubuntu-latest-arm64-4x - warp-macos-15-arm64-6x - warp-windows-latest-x64-4x node: [22, 24] runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: npm ci - run: npm test ``` Four labels multiplied by two Node versions gives eight jobs. Each label is a routing key that selects one machine shape, the same way `ubuntu-latest` or `macos-latest` does in the first example, and whoever operates the fleet publishes the mapping from label to machine, which the [runner label entry](/glossary/runner-label) covers in full. Because the matrix value lands in the job name, the run view shows `test (warp-ubuntu-latest-arm64-4x, 22)` and a reader can tell which machine each job used without opening the log. ### [Building the matrix at run time](#building-the-matrix-at-run-time)Copy Anchor Link A dimension can also be a JSON array produced by an earlier job, which is how a monorepo fans out over only the packages a commit touched. Copy Text ``` jobs: plan: runs-on: ubuntu-latest outputs: targets: ${{ steps.list.outputs.targets }} steps: - uses: actions/checkout@v4 - id: list run: echo "targets=$(./scripts/changed-packages.sh)" >> "$GITHUB_OUTPUT" build: needs: plan strategy: matrix: package: ${{ fromJSON(needs.plan.outputs.targets) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: make build PACKAGE=${{ matrix.package }} ``` The job count is now decided at run time by whatever array `changed-packages.sh` prints. A commit touching two packages expands `build` into two jobs, and a commit touching thirty expands it into thirty. One edge case is worth knowing before shipping this pattern: an empty array expands into zero jobs and GitHub marks `build` as skipped, so a downstream job that gates on `build` succeeding needs an `if` condition that treats the skipped result the way you intend. ## [Related Terms](#related-terms)Copy Anchor Link * [How to structure matrix builds in a GitHub Actions workflow](/guides/github-actions-matrix-builds): patterns for splitting test suites across a matrix and keeping the job count under control. * [Concurrency limit](/glossary/concurrency-limit): the ceiling that decides how many matrix jobs run at once and how the rest queue. * [Runner label](/glossary/runner-label): how a value in `runs-on` selects a machine, including labels held in a matrix dimension. * [Diagnosing and reducing GitHub Actions queue times](/guides/github-actions-queue-times): what to measure when a wide matrix spends longer waiting than running. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the full label catalog with operating systems, architectures, and machine sizes. * [WarpBuild reports documentation](/docs/ci/features/reports): per label queue time and duration data, for measuring how many waves a wide matrix ran in. * [WarpBuild pricing](/pricing): per minute rates by runner type, which is the input to any job minute arithmetic across a matrix. ## FAQ ### What is a build matrix in GitHub Actions? A build matrix is the strategy.matrix block on a job. Each key names a variable and holds a list of values, and the workflow expands that one job definition into a separate job for every combination of those values. Nine combinations become nine independent jobs, each with its own runner, its own workspace, and its own log. ### How many jobs does a matrix create? Multiply the lengths of every list in the matrix block. Two operating systems and three language versions give six jobs. Each exclude entry that matches a combination removes one job, and each include entry that matches no existing combination adds one. GitHub caps a matrix at 256 jobs per workflow run. ### What does fail-fast do in a matrix? fail-fast defaults to true, so the first matrix job that fails cancels the other in-progress jobs in the same matrix. Set strategy.fail-fast to false when the point of the run is the full pass and fail picture across every combination, because the default hides later failures behind the first one. ### Why do matrix jobs queue instead of all starting at once? Expansion and execution are separate steps. The matrix creates every job immediately, then each job waits for an online runner that advertises every label in its runs-on key. A matrix wider than the number of eligible runners runs in waves, so wall clock time tracks available capacity instead of matrix width. --- # BuildKit | WarpBuild URL: https://www.warpbuild.com/glossary/buildkit Description: BuildKit is the Docker build engine that resolves a Dockerfile into a graph of build steps, runs independent steps concurrently, and manages the layer cache. # BuildKit BuildKit is the Docker build engine that resolves a Dockerfile into a graph of build steps, runs independent steps concurrently, and manages the layer cache. BuildKit is the Docker build engine that reads a Dockerfile, resolves it into a graph of build steps, runs the steps that do not depend on each other at the same time, and manages the layer cache that lets a later build skip work it has already done. Docker Engine 23.0 and later use it as the default builder behind `docker build`, and it is published as an open source project at [github.com/moby/buildkit](https://github.com/moby/buildkit) under the Apache 2.0 license. The graph is the part worth holding on to. A Dockerfile reads like a script from top to bottom, and BuildKit treats it as a dependency graph instead, which is why two stages that never reference each other run in parallel and why a stage nobody depends on never runs at all. ## [Definition](#definition)Copy Anchor Link BuildKit is a build engine: a daemon that accepts a build definition, executes it, and produces an output. The daemon binary is `buildkitd`. It ships inside Docker Desktop and Docker Engine, and it can also run on its own in a container, on a Kubernetes cluster, or on a separate machine reached over TCP ([BuildKit documentation](https://docs.docker.com/build/buildkit/), checked on 2026-08-13). A build passes through four parts of the engine in order. | Part | Input | Output | Consequence for a build | | -------- | ----------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Frontend | Dockerfile text, build args, target stage | An LLB graph | The \# syntax= directive selects the frontend image, so new Dockerfile features arrive by pulling a newer frontend rather than upgrading the daemon. | | Solver | The LLB graph | An execution plan with cache decisions | Vertices the target does not depend on are pruned before anything runs. | | Executor | Plan vertices | Filesystem snapshots | Vertices with no edge between them execute at the same time. | | Exporter | The final snapshot | Image, registry push, tar, local directory | The output format is a build-time choice rather than a property of the Dockerfile. | LLB stands for low-level build, the intermediate format the frontend emits. It is a content-addressable graph in which each vertex is one operation: run a command in a container, copy files between snapshots, fetch a source such as an image or a Git ref. The Dockerfile is one way to produce that graph, and the frontend interface lets other formats produce it too. ### [What decides a cache hit](#what-decides-a-cache-hit)Copy Anchor Link The solver computes a digest for every vertex from the operation itself plus the digests of its parents. A vertex whose digest matches a result already in the cache is skipped, and its stored snapshot is used as the parent of whatever comes next. Two details follow from that rule and explain most surprising cache misses. A `COPY` vertex includes the checksums of the files it copies, so editing one source file changes that vertex and every vertex downstream of it. And because parent digests feed child digests, changing an early instruction invalidates the whole tail of that branch even when the later commands are byte-identical. ### [Mounts](#mounts)Copy Anchor Link BuildKit extends `RUN` with a `--mount` flag that attaches storage to a single command. The mount is visible while that command runs and is absent from the image layer the command produces. | Mount type | What it attaches | Present in the image | | ---------- | --------------------------------------------------------------------------------------------------------- | -------------------- | | cache | A directory managed by the builder and reused across builds, such as a package manager download directory | No | | bind | A read-only view of another stage or of the build context | No | | secret | A file supplied at build time, such as a token or a credentials file | No | | ssh | A forwarded SSH agent socket for fetching private dependencies | No | | tmpfs | An empty in-memory directory for the duration of the command | No | The `cache` type is the one that changes build time the most, because it keeps a populated directory outside the layer graph. A cache mount also takes a `sharing` option: `shared` is the default and lets concurrent builds use the mount at once, `locked` serializes them, and `private` gives each concurrent build its own copy ([Dockerfile reference](https://docs.docker.com/reference/dockerfile/#run---mounttypecache), checked on 2026-08-13). ### [Moving the cache between machines](#moving-the-cache-between-machines)Copy Anchor Link The layer cache lives with the builder by default. To carry it somewhere else, a build passes `--cache-to` and `--cache-from` with a backend type: `inline` writes cache metadata into the pushed image, `registry` writes a separate cache image, `local` writes a directory, `gha` uses the GitHub Actions cache service, and `s3` and `azblob` use object storage. Cache mount contents stay behind on the builder and are excluded from every one of those exports. ## [Example](#example)Copy Anchor Link This Dockerfile has two stages that reference nothing from each other, and a third that depends on both. Copy Text ``` # syntax=docker/dockerfile:1 FROM node:22 AS web WORKDIR /src/web COPY web/package.json web/package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci COPY web/ ./ RUN npm run build FROM golang:1.24 AS api WORKDIR /src/api COPY api/go.mod api/go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod go mod download COPY api/ ./ RUN --mount=type=cache,target=/root/.cache/go-build go build -o /out/server ./cmd/server FROM gcr.io/distroless/base-debian12 COPY --from=api /out/server /server COPY --from=web /src/web/dist /public ENTRYPOINT ["/server"] ``` The solver draws no edge between the `web` branch and the `api` branch, so the executor starts `npm ci` and `go mod download` at the same time on a builder with capacity for both. The final stage has an edge to each branch, so it waits for the slower one. A build invoked with `--target web` prunes the `api` branch entirely and never downloads the Go module set. The workflow below builds that Dockerfile on a GitHub Actions runner and exports the layer cache to the GitHub Actions cache service so the next run can import it. Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 with: context: . push: false tags: example/app:latest cache-from: type=gha cache-to: type=gha,mode=max ``` Trace what the solver does as the repository changes between pushes. | Change since the last build | web branch | api branch | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | | Nothing | Every vertex digest matches the imported cache, so no command runs and the exporter assembles the image from stored snapshots. | Same. | | One line edited in web/src/App.tsx | COPY web/ ./ changes, so npm run build runs again. The two vertices above it still match, so npm ci is skipped. | Untouched, restored from cache. | | A package added to web/package-lock.json | The lockfile COPY changes, so npm ci runs again. If the builder still holds the /root/.npm mount, the download step reads tarballs from disk and fetches only the new package. | Untouched, restored from cache. | | A module added to api/go.mod | Untouched, restored from cache. | go mod download and go build run again, reading whatever the /go/pkg/mod and build cache mounts still hold. | The last column of rows three and four carries a condition worth stating plainly. `docker/setup-buildx-action` creates a builder inside the job, and that builder disappears when the job ends, taking its cache mounts with it. Layer cache survives because `cache-to` pushed it to an external store, while the npm download directory and the Go module directory start empty on every run. A builder whose storage outlives the job keeps those mounts populated, which is the difference between a cache mount that helps on the second build and one that only ever helps within a single build. ## [Related Terms](#related-terms)Copy Anchor Link * [Docker Buildx and how it picks the builder that runs a build](/glossary/buildx): the CLI in front of the engine, the driver choices it offers, and what `docker buildx create` actually creates. * [Multi-stage Docker builds and how a target stage prunes work](/glossary/multi-stage-build): the Dockerfile pattern the example above uses, and why naming a target changes which stages execute. * [Running a Docker build on a builder outside the workflow job](/runners/docker-builders): the arrangement where the builder and its storage sit apart from the runner that started the build. * [BuildKit documentation](https://docs.docker.com/build/buildkit/): the upstream reference for the daemon, its frontends, and its cache backends. * [WarpBuild Docker builders documentation](/docs/ci/docker-builders): builder profiles, the actions that point a workflow at a builder, and the cache reset endpoint. * [WarpBuild caching documentation](/docs/ci/features/caching): cache usage inside GitHub Actions workflows. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between BuildKit and Buildx? BuildKit is the engine that executes a build: it turns the Dockerfile into a graph, schedules the steps, and owns the cache. Buildx is the client-side CLI that decides which BuildKit instance receives the build, creates and lists builders, and passes flags such as platforms and cache backends to the engine. ### Do BuildKit cache mounts persist between GitHub Actions runs? A cache mount lives in the storage of the builder that ran the build, so it lasts exactly as long as that builder does. A builder created inside the job and discarded when the job finishes starts each run with an empty mount. Layer cache exported with cache-to travels between runs through an external store, and cache mount contents are excluded from that export. ### How do I tell whether a build used BuildKit? Docker Engine 23.0 and later run docker build through BuildKit by default, and the giveaway is the grouped step output with per-step timings instead of a flat stream of intermediate container IDs. On older engines the legacy builder ran unless DOCKER\_BUILDKIT=1 was set in the environment. --- # Docker Buildx | WarpBuild URL: https://www.warpbuild.com/glossary/buildx Description: Docker Buildx is the Docker CLI plugin that drives BuildKit: it selects the builder instance a build runs on and targets several platforms in one build. # Docker Buildx Docker Buildx is the Docker CLI plugin that drives BuildKit: it selects the builder instance a build runs on and targets several platforms in one build. Docker Buildx is the Docker CLI plugin that drives BuildKit, the engine behind modern Docker builds. It selects which builder instance runs a build, and it can produce images for several platforms from one `docker buildx build` invocation. Buildx is the client half of a build. BuildKit is the engine half, and the two halves can sit on different machines, which is the property that makes a builder outside the GitHub Actions job possible. ## [Definition](#definition)Copy Anchor Link Buildx installs as a CLI plugin and adds one build command plus a set of builder management subcommands. Its own reference lives in the [Docker Buildx CLI documentation](https://docs.docker.com/reference/cli/docker/buildx/), checked on 2026-08-13\. Since Docker Engine 23.0 the plain `docker build` command is routed through Buildx as well, so most builds already pass through the plugin even when the command line never says `buildx`. The plugin carries three responsibilities. The first is builder management. `docker buildx create` defines a builder instance, `docker buildx ls` lists the instances a machine knows about, `docker buildx use` sets the default, and `docker buildx inspect` reports the endpoints behind one. A builder instance is a named handle to one or more BuildKit endpoints, and each endpoint is called a node. The second is driver selection. The driver decides where BuildKit runs and therefore what a build is able to do. Docker documents four drivers in the [builder drivers reference](https://docs.docker.com/build/builders/drivers/), checked on 2026-08-13. | Driver | Where BuildKit runs | Multi-platform in one build | Layer cache after the job ends | | ---------------- | --------------------------------------------- | ------------------------------ | ------------------------------ | | docker | Inside the local Docker daemon | One platform per invocation | Discarded with the daemon | | docker-container | A container Buildx starts on the local daemon | Yes, through QEMU emulation | Discarded with the container | | kubernetes | Pods in a cluster | Yes, one node per architecture | Held by the pod volumes | | remote | An existing BuildKit daemon reached over TCP | Yes, one node per architecture | Held on the remote host | The `docker` driver gains multi-platform output when the daemon runs the containerd image store, which is the one exception to the first row. The third responsibility is the feature surface. Multi-platform output through `--platform`, external cache backends through `--cache-from` and `--cache-to`, named build contexts through `--build-context`, and direct output selection through `--push` and `--load` all arrive with Buildx. The cache backends are the flags that matter most inside a workflow, and the [cache backend documentation](https://docs.docker.com/build/cache/backends/) lists them: `type=registry` writes cache into a registry repository, `type=gha` writes into the GitHub Actions cache service, `type=local` writes to a directory on the machine running the build, and `type=inline` embeds cache metadata in the pushed image. Driver choice and cache backend answer the same question in two ways. A build whose engine disappears when the job ends has to export its cache somewhere durable and import it again on the next run. A build whose engine outlives the job keeps the cache on local disk where BuildKit wrote it. ## [Example](#example)Copy Anchor Link The default builder in a GitHub Actions job is the daemon inside that job, so every layer it writes disappears when the job finishes. This workflow instead creates a builder on the `remote` driver, points it at a BuildKit endpoint reached over mutual TLS, and runs the build there: Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Write the builder TLS material run: | mkdir -p "$RUNNER_TEMP/buildkit" printf '%s' "${{ secrets.BUILDKIT_CA }}" > "$RUNNER_TEMP/buildkit/ca.pem" printf '%s' "${{ secrets.BUILDKIT_CERT }}" > "$RUNNER_TEMP/buildkit/cert.pem" printf '%s' "${{ secrets.BUILDKIT_KEY }}" > "$RUNNER_TEMP/buildkit/key.pem" - name: Point buildx at the remote builder run: | docker buildx create \ --name remote-builder \ --driver remote \ --driver-opt cacert="$RUNNER_TEMP/buildkit/ca.pem" \ --driver-opt cert="$RUNNER_TEMP/buildkit/cert.pem" \ --driver-opt key="$RUNNER_TEMP/buildkit/key.pem" \ --use \ tcp://buildkit.internal.example.com:1234 - name: Build and push run: | docker buildx build \ --builder remote-builder \ --platform linux/amd64,linux/arm64 \ --tag ghcr.io/${{ github.repository }}:${{ github.sha }} \ --push \ . ``` `docker buildx inspect remote-builder` reports what the job is now attached to, and the node list is the part worth reading: Copy Text ``` Name: remote-builder Driver: remote Nodes: Name: remote-builder0 Endpoint: tcp://buildkit.internal.example.com:1234 Status: running Platforms: linux/amd64, linux/amd64/v2, linux/arm64 ``` Four things move off the runner once that builder is in use. | Part of the build | Default builder in the job | Builder on the remote driver | | ----------------- | --------------------------------------------------- | ------------------------------------------------- | | Build context | Read from the job's own disk | Streamed from the job to the builder | | Layer cache | Written into the job's daemon, discarded at job end | Written on the builder host, read by the next run | | Registry push | The job's daemon pushes after the build | The builder pushes directly under \--push | | Multi-platform | QEMU emulation inside the job | One node per architecture on the builder | The `--push` line is easy to miss. A multi-platform result cannot be loaded into the classic Docker image store, so `--load` fails and the image has to go to a registry, which the [multi-platform build documentation](https://docs.docker.com/build/building/multi-platform/) states directly. Pushing from the builder also keeps the finished layers off the runner entirely, so the job transfers the build context up and the digest back. A workflow that keeps the default builder still benefits from the same flags. Adding `--cache-to type=gha,mode=max` and `--cache-from type=gha` gives the throwaway engine a durable place to put its layers between runs, at the cost of an upload and a download on every job. ## [Related Terms](#related-terms)Copy Anchor Link * [BuildKit, the build engine that Buildx drives](/glossary/buildkit): the graph solver, the concurrency model, and the cache mounts that Buildx exposes through its flags. * [Do I need Buildx in GitHub Actions](/answers/do-i-need-buildx-in-github-actions): the direct answer for a workflow that already runs `docker build`, including when a setup step is redundant. * [Remote Docker builders, sizes and configuration](/runners/docker-builders): vCPU, memory, and disk options for builders that run outside the GitHub Actions job. * [Docker Buildx CLI reference](https://docs.docker.com/reference/cli/docker/buildx/): the upstream command and flag list. * [WarpBuild Docker builder documentation](/docs/ci/docker-builders): builder profiles, the actions that configure a builder, and the CLI flow that creates a `remote` driver instance. * [WarpBuild caching documentation](/docs/ci/features/caching): cache action inputs, key and version matching, and the entry expiry window. * [WarpBuild pricing](/pricing): per minute rates by runner type and by builder size. ## FAQ ### Do I need to install Docker Buildx separately? Usually no. Buildx ships with Docker Desktop and with current Docker Engine packages as the docker-buildx-plugin, and the GitHub-hosted runner images include it. Run docker buildx version to confirm the plugin is on the path before adding a setup step. ### What is the difference between docker build and docker buildx build? Since Docker Engine 23.0 both commands reach the same BuildKit engine. The buildx form adds the flags the legacy path lacked: --builder to choose a builder instance, --platform for multi-platform output, and --cache-from and --cache-to for external cache backends. ### Why does a multi-platform build fail on the default builder? The default builder uses the docker driver, which builds for one platform per invocation unless the daemon runs the containerd image store. Create a builder on the docker-container, kubernetes, or remote driver, then pass --platform with the architecture list. --- # BYOC (Bring Your Own Cloud) | WarpBuild URL: https://www.warpbuild.com/glossary/byoc Description: BYOC, or bring your own cloud, is a deployment model where a vendor's control plane operates resources that run inside the customer's own cloud account. # BYOC (Bring Your Own Cloud) BYOC, or bring your own cloud, is a deployment model where a vendor's control plane operates resources that run inside the customer's own cloud account. ## [Definition](#definition)Copy Anchor Link BYOC, short for bring your own cloud, is a deployment model where a vendor operates the control plane for a service while the resources that do the actual work are created inside the customer's own cloud account. The vendor supplies the software and the operations; the customer supplies the account, and the resources appear under the customer's ownership, in the customer's chosen region, on the customer's cloud invoice. The term is used across many categories of software, including databases, data warehouses, observability pipelines, and build infrastructure. The shape is the same in every case. One party operates the system, and the other party owns the machines it runs on. ### [The two planes](#the-two-planes)Copy Anchor Link Almost every BYOC description reduces to a split between a control plane and a data plane. The **control plane** is the part the vendor runs in vendor infrastructure. It holds configuration, scheduling decisions, the reconciliation loop that keeps reality matched to configuration, the dashboard, the API, and the billing meter for the vendor's own service. It calls cloud provider APIs on the customer's behalf. The **data plane** is the part that lives in the customer's account. It is the set of instances, disks, buckets, network interfaces, and load balancers that carry the workload and hold the data. Requests, builds, queries, and logs move through these resources without leaving the customer's account boundary. The cloud account boundary is what makes BYOC a distinct model rather than a hosting detail. Once the data plane sits in the customer's account, the customer's existing controls apply to it automatically: organization policies, service control policies, tag enforcement, private networking, encryption keys, audit logging, and the cost allocation the finance team already uses. ### [What "your own cloud account" means concretely](#what-your-own-cloud-account-means-concretely)Copy Anchor Link Four things move to the customer under this model, and they move together. **Ownership.** Every resource is created with the customer as the account owner. The resources show up in the customer's console, respond to the customer's API calls, and can be deleted by the customer at any time without asking the vendor. **Billing.** The cloud provider meters those resources against the customer's account, at the rates and commitments that account carries. Committed use discounts, savings plans, enterprise agreements, and any negotiated pricing apply, because the usage is the customer's usage. **Region and network placement.** The customer picks the region, the virtual network, and the subnets. Resources land where the customer put them, which is what makes BYOC a common answer to data residency requirements. **Access.** The vendor reaches the account through an identity the customer creates: an IAM role with a trust policy on AWS, a service account on GCP, or a managed identity or app registration on Azure. The permission set is visible before it is granted, every call the vendor makes appears in the cloud provider's own audit log, and deleting the role ends the vendor's access without a support ticket. ### [The resource categories a BYOC deployment touches](#the-resource-categories-a-byoc-deployment-touches)Copy Anchor Link Whatever the product, a BYOC data plane is assembled from the same four categories of cloud primitive. The names differ per provider. | Category | What it holds | AWS | GCP | Azure | | -------------- | ---------------------------------------- | ----------------------------- | ---------------------------- | ------------------------------ | | Compute | The instances that run the workload | EC2 instances | Compute Engine instances | Virtual Machines | | Network | The private network the workload sits in | VPC, subnets, security groups | VPC, subnets, firewall rules | Virtual Network, subnets, NSGs | | Object storage | Caches, artifacts, logs, state | S3 bucket | Cloud Storage bucket | Blob Storage container | | Identity | The scoped grant the vendor assumes | IAM role | Service account | Managed identity | Reading a vendor's BYOC documentation is largely a matter of checking those four rows: which resources get created, which can be imported from what the customer already has, and which permissions the identity row carries. ### [Where BYOC sits between the two familiar models](#where-byoc-sits-between-the-two-familiar-models)Copy Anchor Link BYOC is the middle option in a spectrum of three. The table below splits the operational duties that people usually argue about. | Responsibility | Vendor-hosted | BYOC | Self-managed | | --------------------------- | ------------------------- | ------------------------------------ | ------------------------------------ | | Provisioning resources | Vendor, in vendor account | Vendor, in customer account | Customer | | Patching images and agents | Vendor | Vendor | Customer | | Scaling policy | Vendor | Vendor, using customer capacity | Customer | | Network placement | Vendor decides | Customer decides | Customer decides | | Data location | Vendor regions | Customer regions | Customer regions | | Cloud bill for the workload | Included in vendor price | Customer, direct from cloud provider | Customer, direct from cloud provider | | Ending vendor access | Cancel the contract | Delete the granted role | No vendor access exists | The trade is visible in the table. Vendor-hosted asks the least of the customer and gives the least control over placement. Self-managed gives full control and hands the customer the on-call rotation for the orchestration layer. BYOC keeps the operational burden with the vendor while the resources and their audit trail stay in the customer's account. ### [Terms that get confused with BYOC](#terms-that-get-confused-with-byoc)Copy Anchor Link **Self-hosted** describes where software runs. It leaves open the question of who operates it, and in common usage the customer operates it. **On-premises** means hardware in a data center the customer owns or leases. BYOC usually refers to a public cloud account, though the same control plane and data plane split is what hybrid deployments describe. **Single tenant** or **dedicated hosting** means the vendor runs isolated infrastructure per customer inside the vendor's own account. The isolation improves; the account boundary stays with the vendor, so the bill and the audit trail stay there too. **Remote agent** or **connector** models install an outbound agent in the customer's network to reach private data. That solves connectivity. It leaves the workload itself in vendor infrastructure. ## [Example](#example)Copy Anchor Link GitHub Actions makes the model easy to see, because a workflow job runs on exactly one machine and the job says which machine it wants. A job that names a GitHub-hosted label runs on a machine GitHub owns: Copy Text ``` name: ci on: [push] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: make build ``` The instance is created and destroyed inside GitHub's infrastructure. The repository owner picks a size from what GitHub publishes, and the charge for the job time appears on the GitHub bill. Region, virtual network, disk encryption key, and instance family are decisions someone else has already made. Under a BYOC model, the same job changes one line. The label points at a runner configuration the customer defined in a connected cloud account. In the WarpBuild implementation, that configuration registers a runner ID carrying the `warp-custom-` prefix, documented in the [BYOC setup guide](/docs/ci/byoc): Copy Text ``` name: ci on: [push] jobs: build: runs-on: warp-custom-ci-linux-use1 steps: - uses: actions/checkout@v4 - run: make build ``` Nothing else in the file moves. The checkout action, the cache actions, the secrets, and the label matching rules behave exactly as before, because the machine still registers with GitHub through the standard runner registration API. ### [What happens after that job is queued](#what-happens-after-that-job-is-queued)Copy Anchor Link 1. GitHub queues the job and looks for an online runner advertising the label `warp-custom-ci-linux-use1`. 2. The vendor control plane, watching the queue for the connected repositories, sees the queued job. 3. The control plane calls the cloud provider API using the role the customer granted, and launches an instance in the customer's account, in the customer's region and subnet. 4. The instance boots the runner agent, registers with GitHub, and claims the job. 5. Steps execute on that instance. Source code, build outputs, caches, and logs are written to storage in the customer's account. 6. When the job ends the instance is terminated, and the storage the workload used remains under the customer's control. Every resource created in steps 3 through 6 is tagged and billed to the customer's account. The vendor never held the build data; it held the decision to create the instance. ### [Which invoice each line item lands on](#which-invoice-each-line-item-lands-on)Copy Anchor Link The billing split is the part teams most often get wrong when they model the change, because it turns one invoice into two. | Line item | Billed by | | ------------------------------------------------- | ------------------------------------ | | Instance hours for the runner | Your cloud provider, to your account | | Block storage attached to the runner | Your cloud provider, to your account | | Object storage for caches, artifacts, and logs | Your cloud provider, to your account | | Data transfer generated by the workload | Your cloud provider, to your account | | The managed service that orchestrates the runners | The vendor | Two consequences follow. The cloud line items inherit the account's existing discounts and its existing cost allocation tags, so the spend appears in the same reports as the rest of the infrastructure. And capacity choices such as instance family or purchase model become the customer's decision, since the customer is the party being metered for them. Which capabilities are available per cloud is documented in the [feature matrix](/docs/ci/feature-matrix). ## [Related Terms](#related-terms)Copy Anchor Link * [BYOC runners on AWS for GitHub Actions](/byoc/aws): the AWS architecture for this model, the IAM surface it asks for, and the stack configuration knobs. * [Data residency](/glossary/data-residency): why the account boundary described above is the usual answer to a residency requirement. * [Spot instance](/glossary/spot-instance): the purchase model that becomes available once instance choice moves to the customer's account. * [Can GitHub Actions runners run in my own cloud account](/answers/can-github-actions-runners-run-in-my-own-cloud): the short answer for GitHub Actions specifically, with the setup path. * [BYOC setup documentation](/docs/ci/byoc): the three step flow of connecting a cloud account, creating a stack, and defining a custom runner. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What does BYOC stand for? BYOC stands for bring your own cloud. It describes a deployment model where a vendor operates the control plane for a service while the compute, storage, and network resources that do the work are created inside the customer's own cloud account, under the customer's ownership and on the customer's cloud invoice. ### How is BYOC different from self-hosted? Both place the workload in infrastructure the customer owns. They differ in who operates it. Under self-hosted, the customer runs the orchestration, the scaling policy, the image builds, and the upgrades. Under BYOC, the vendor's control plane performs those operations remotely through a scoped role the customer grants. ### Who pays the cloud bill under BYOC? The customer does, directly to the cloud provider. Instance hours, disks, object storage, and data transfer are metered against the customer's own account at whatever rates and commitments that account carries. The vendor bills separately for its own service. ### Does BYOC give the vendor access to my cloud account? It gives the vendor a scoped identity, typically an IAM role, a service account, or a managed identity, with permissions limited to the resource types the service manages. The customer defines the trust relationship and the permission set, can audit every API call in the cloud provider's own audit log, and revokes access by removing the role. --- # Cache Eviction | WarpBuild URL: https://www.warpbuild.com/glossary/cache-eviction Description: Cache eviction is the removal of cache entries to hold a store under a size ceiling or an age limit. How least recently used order plays out in GitHub Actions. # Cache Eviction Cache eviction is the removal of cache entries to hold a store under a size ceiling or an age limit. How least recently used order plays out in GitHub Actions. Cache eviction is the removal of entries from a cache store so the store stays under a size ceiling or an age limit, usually taking the least recently used entry first. The store performs it on its own schedule, so a key that hit yesterday can miss this morning with nothing changed in the workflow that asked for it. That split is what makes cache behavior look random from inside a workflow file. The workflow chooses what to store and under which key. Whether the entry survives until the next run is decided by everything else that wrote to the same store in between. ## [Definition](#definition)Copy Anchor Link A cache holds results that can be recomputed, inside a fixed budget. Entries keep arriving and the budget does not grow, so something has to leave. Eviction is the rule that picks what leaves. Two triggers cover almost every implementation. **Capacity pressure.** The store has a size ceiling. When a new entry pushes the total past it, the store accepts the write and then deletes existing entries until the total fits again. **Age.** An entry that has gone unread for a set window is deleted whatever the total size. Most build caches use a sliding window measured from the last read, so an entry restored every weekday stays alive and an entry written once and never restored ages out. Removal order is the policy. Least recently used ranks entries by the time of their last read and removes from the oldest end, which is the common default because a recent read is a cheap prediction of a future read. Other stores rank by write time, by read frequency, or by size, and a size-weighted policy removes one large entry instead of many small ones. Four words get used for the same visible symptom, and separating them is most of the diagnosis: | Term | What removes the entry | Timing | Decided by | | ------------ | --------------------------------------------------- | --------------------------------------------- | ------------------------------------------- | | Eviction | The store, under capacity pressure | When some other write does not fit | Traffic from every writer sharing the store | | Expiration | The store, under an age rule | A fixed window after the last read or write | The store's retention policy | | Invalidation | Nothing is removed; a new key addresses a new entry | On the next run that computes a different key | The workflow author | | Deletion | An explicit API or console call | On demand | An operator | Invalidation is the odd one. Entries in a content addressed store are immutable, so a changed key writes a fresh entry beside the old one rather than replacing it. The superseded entry keeps holding space until eviction or expiration collects it. ### [How the two triggers appear in GitHub Actions](#how-the-two-triggers-appear-in-github-actions)Copy Anchor Link GitHub applies both. The combined size of all caches in a repository is capped at 10 GB by default, entries are evicted in least recently used order once the total passes that cap, and any entry that goes 7 days without use is deleted regardless of the total ([GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13). Two properties of that budget cause most of the surprise. The ceiling is per repository, so a nightly workflow exporting large layer caches competes for space with the dependency cache every pull request restores. And entries are scoped per branch, so four active branches running the same workflow hold four copies of a mostly identical tree, each one counting in full. Neither the cap nor the age rule is visible from inside a job. The Caches view under the repository's Actions tab and `gh cache list --sort last_accessed_at` show the size and last read time of every entry, and the REST endpoint `GET /repos/{owner}/{repo}/actions/cache/usage` returns the active entry count and the total size in bytes that the cap applies to. ## [Example](#example)Copy Anchor Link Take a repository with four active branches. Each branch writes a Node dependency cache of about 2.2 GB, and a nightly workflow exports a Docker layer cache of about 3.5 GB into the same store. Those sizes are an assumption for illustration; read your own with `gh cache list --sort size_in_bytes`. The dependency cache comes from an ordinary cache step, with no eviction handling anywhere in the file: Copy Text ``` name: test on: push: pull_request: jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore npm cache id: npm-cache uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} - run: npm ci - run: npm test ``` Trace one day of writes against the 10 GB ceiling. Every branch resolves the same key text, and each branch scope holds its own entry: | Time | Event | Written | Store total | Evicted | | --------- | -------------------------- | ---------------------------- | --------------------- | ---------------------------------------------------------------- | | Mon 09:00 | Push to main | 2.2 GB on main | 2.2 GB | None | | Mon 11:00 | Pull request A | 2.2 GB on branch A | 4.4 GB | None | | Mon 14:00 | Pull request B | 2.2 GB on branch B | 6.6 GB | None | | Mon 18:00 | Pull request C | 2.2 GB on branch C | 8.8 GB | None | | Mon 23:00 | Nightly layer cache export | 3.5 GB | 7.9 GB after eviction | Main entry, last read 09:00, and branch A entry, last read 11:00 | | Tue 09:15 | Push to main | 2.2 GB on main, rebuilt cold | 7.9 GB after eviction | Branch B entry, last read Mon 14:00 | Tuesday morning's main build is the job that expected a hit. Its lockfile did not change, its key resolved to the same string as Monday's, and the restore step still logged a miss, because the entry it wanted was deleted at 23:00 to make room for a cache belonging to a different workflow. `npm ci` then resolved and downloaded the whole dependency tree, and the entry it saved evicted branch B's copy on the way in. That is the treadmill: a store held at its ceiling by writers that outpace reads, where every job pays the cold path and immediately evicts the next job's warm start. It repeats until the working set fits or the writers slow down. Three signals separate eviction from a key problem before you edit anything: * The restore step logs a miss on a key that hit on an earlier run, with no change to the lockfile, the runner image, or the `path` list. * `gh cache list` shows a total sitting near the cap, and the missing entry's last read was older than the entries that survived. * The repository sits far under the cap and the missing entry had gone 7 days without a read, which points at the age rule instead. ## [Related Terms](#related-terms)Copy Anchor Link * [Cache hit rate](/glossary/cache-hit-rate): the metric that eviction moves, and how to read a falling hit rate against a store that is full rather than misconfigured. * [The GitHub Actions cache size limit, explained](/guides/github-actions-cache-size-limit): where the 10 GB ceiling comes from, how least recently used order picks a victim, and what to do when a working set does not fit under it. * [How to clear a GitHub Actions cache](/answers/how-do-i-clear-a-github-actions-cache): removing entries deliberately through the Caches view, the GitHub CLI, and the REST API. * [GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching): the documented cap, the eviction order, and the 7 day age rule. * [WarpBuild caching documentation](/docs/ci/features/caching): cache action inputs, the restore and save split, and key examples. * [Cache usage reporting](/docs/ci/features/reports): the reports view listing cache storage and cache operations per day. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is cache eviction? Cache eviction is the removal of entries from a cache store so the store stays inside its budget. Two triggers cover most implementations: capacity pressure, where a new write does not fit under the size ceiling, and age, where an entry that has gone unread for a set window is deleted. The usual removal order is least recently used first. ### What is the difference between eviction and expiration? Expiration is driven by a clock: an entry is removed once it has gone unread for a fixed window, whatever the store holds. Eviction is driven by pressure: an entry is removed because another write needs the space. An expiring entry has a predictable removal date, while an evicted entry disappears at a moment decided by other workflows writing to the same store. ### Why did my GitHub Actions cache disappear without the key changing? Either the repository passed its cache size cap and the entry was evicted in least recently used order, or the entry went 7 days without a read and was deleted by the age rule. Check the total size and the last accessed timestamps with gh cache list before touching the workflow, because neither cause is visible in the key. --- # Cache Hit Rate | WarpBuild URL: https://www.warpbuild.com/glossary/cache-hit-rate Description: Cache hit rate is the share of cache lookups that return a usable entry. How to pick the numerator and the denominator, and how to count it in GitHub Actions. # Cache Hit Rate Cache hit rate is the share of cache lookups that return a usable entry. How to pick the numerator and the denominator, and how to count it in GitHub Actions. Cache hit rate is the share of cache lookups that find a usable entry, counted over a window of runs and reported as a percentage. It is measured per key prefix or per workflow, because one repository holds several caches whose change rates have nothing to do with each other. The number carries no information until the numerator and the denominator are stated. Two engineers reading the same week of runs on the same cache can report 50 percent and 80 percent and both be arithmetically correct. ## [Definition](#definition)Copy Anchor Link A lookup is one request to a cache store for an entry under a named key. A hit is a lookup that returns an entry the job can use. The rate is hits divided by lookups over a stated window, which folds three separate choices into one figure. **The numerator.** Caches that support prefix fallback return two different kinds of success. An exact match returns the entry the run asked for. A fallback match returns an older entry whose key is a prefix of the requested key, which covers part of the work and leaves the rest. Counting only exact matches gives the strict rate. Counting any returned bytes gives the restore rate, which runs higher on the same data. **The denominator.** The set of lookups being divided into has three defensible definitions, and they diverge as soon as a workflow gets complicated. | Denominator | What the rate then answers | Where it misleads | | ---------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------- | | Every execution of the restore step | How often the store served a request | A matrix job that fans out to eight variants contributes eight lookups for one logical build | | Every job that could use the cache | How often a job started warm | Jobs that restore two caches collapse into one data point | | Every unique key requested in the window | How well the key design matches the change rate | A key requested 400 times and missed once looks the same as a key requested twice | **The window and the grouping.** A rate over a seven day window on a repository that deploys twice a day mixes weekday and weekend traffic, and a rate over all caches in a repository averages a lockfile cache that changes monthly against a compiled artifact cache that changes hourly. Grouping by key prefix keeps caches with different change rates apart. Two properties limit how far the number can be pushed. Some misses are correct: when the cached contents genuinely changed, a miss is the cache doing its job, and the ceiling for any cache is set by how often its inputs change. And a hit is not the same as time saved, because a fallback match can return a tree that still forces a full reinstall. A restore rate that climbs while job duration holds flat means the restored entries are covering less than they appear to. Entries also vanish from underneath a stable key. GitHub removes cache entries that have not been accessed for seven days, and once a repository passes its cache storage limit it evicts least recently used entries to make room ([GitHub caching documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows), checked on 2026-08-13). A rate that decays over a quarter with no change to the workflow file is usually [cache eviction](/glossary/cache-eviction) rather than key design. In GitHub Actions the restore step reports its outcome through three outputs. `cache-hit` is `true` only on an exact match against the primary key, so a fallback restore sets it to `false` ([GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13). `cache-matched-key` holds the key that actually matched and is empty when nothing did, and `cache-primary-key` echoes the evaluated key ([actions/cache README](https://github.com/actions/cache)). Those three values are enough to compute either numerator. ## [Example](#example)Copy Anchor Link This workflow restores an npm download cache and writes the outcome of that lookup into the job summary, so every run leaves one line that can be counted later ([job summaries reference](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#adding-a-job-summary), checked on 2026-08-13). Copy Text ``` name: test on: push: jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore npm cache id: npm-cache uses: actions/cache/restore@v4 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} restore-keys: | npm-${{ runner.os }}- - name: Record cache outcome env: HIT: ${{ steps.npm-cache.outputs.cache-hit }} MATCHED: ${{ steps.npm-cache.outputs.cache-matched-key }} PRIMARY: ${{ steps.npm-cache.outputs.cache-primary-key }} run: | if [ "$HIT" = "true" ]; then outcome=exact elif [ -n "$MATCHED" ]; then outcome=partial else outcome=miss fi echo "cache=npm outcome=$outcome primary=$PRIMARY matched=${MATCHED:-none}" \ >> "$GITHUB_STEP_SUMMARY" - run: npm ci - run: npm test - name: Save npm cache if: steps.npm-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: ~/.npm key: ${{ steps.npm-cache.outputs.cache-primary-key }} ``` The three-way branch is what makes the line countable. A single boolean would flatten fallback restores into the same bucket as complete misses, and those two outcomes call for different fixes. Ten consecutive runs of that workflow, with lock file hash `9f2c1a` as the starting state: | Run | What changed | cache-hit | cache-matched-key | Outcome recorded | | --- | --------------------------- | --------- | ----------------- | ---------------- | | 1 | first run, store empty | false | empty | miss | | 2 | nothing | true | npm-Linux-9f2c1a | exact | | 3 | nothing | true | npm-Linux-9f2c1a | exact | | 4 | one dependency added | false | npm-Linux- | partial | | 5 | nothing | true | npm-Linux-4b77de | exact | | 6 | nothing | true | npm-Linux-4b77de | exact | | 7 | dependency bump | false | npm-Linux- | partial | | 8 | nothing | true | npm-Linux-1c30ef | exact | | 9 | entry aged out of the store | false | empty | miss | | 10 | nothing | false | npm-Linux- | partial | Five runs recorded `exact`, three recorded `partial`, two recorded `miss`. The strict rate is 5 of 10, or 50 percent. The restore rate is 8 of 10, or 80 percent. Both describe the same ten runs, and quoting either one without the definition leaves the reader guessing which cache behavior is being described. The split also points at different work. Runs 4 and 7 are the cache working as designed, since the dependency set changed and a prefix fallback covered most of the install. Run 9 is the one worth chasing, because the key was unchanged since run 8 and the entry was gone anyway. Grouping the summary lines by the `cache=` field keeps an npm cache from being averaged against a Docker layer cache in the same repository. Aggregating those lines across a workflow's run history is covered on the sibling page below. ## [Related Terms](#related-terms)Copy Anchor Link * [Cache eviction and why entries disappear under a stable key](/glossary/cache-eviction): the storage limit and the access-age rule that turn a stable key into an intermittent miss, which is the usual cause of a rate that decays over time. * [How to measure cache hit rate in GitHub Actions](/answers/how-do-i-measure-cache-hit-rate-in-github-actions): pulling the recorded outcomes out of run history and turning them into a per-prefix rate. * [Why a GitHub Actions cache misses on every run](/answers/why-does-my-github-actions-cache-miss-every-run): the diagnosis path for a rate stuck near zero, where the key changes on every evaluation. * [GitHub caching documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows): the upstream reference for restore order, the storage limit, and the access-age rule. * [WarpBuild caching documentation](/docs/ci/features/caching): cache action inputs and the `cache-hit` output behavior. * [WarpBuild reports documentation](/docs/ci/features/reports): per-job duration and queue-time percentiles, which is where a hit rate gets checked against elapsed time. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What counts as a cache hit? Whatever the person quoting the number says it counts as. Two definitions are in common use: an exact match against the primary key, and any restore that returned bytes including a prefix fallback. The second is the looser of the two and lands at or above the first on any given set of runs, so the definition has to travel with the figure. ### What is a good cache hit rate? There is no single target, because the achievable rate depends on how often the cached contents legitimately change. A dependency cache keyed on a lockfile that changes twice a month should sit near the top of its range, while a cache keyed on a source tree hash in an active repository will miss most of the time by design. ### Should cache hit rate be measured per repository or per cache? Per key prefix, then rolled up per workflow. One repository usually holds several caches with different change rates, and a single repository-wide number averages them into a figure that no fix moves. --- # Cache Key | WarpBuild URL: https://www.warpbuild.com/glossary/cache-key Description: A cache key is the string that names one cache entry. How GitHub Actions builds a key from the runner platform and a lockfile hash, and how a restore matches. # Cache Key A cache key is the string that names one cache entry. How GitHub Actions builds a key from the runner platform and a lockfile hash, and how a restore matches. A cache key is the string that identifies one entry in a build cache, so a later job can ask for that exact entry by name. In GitHub Actions the key is usually assembled from the runner platform, the tool version, and a hash of a lockfile, which makes the string change at the moment the cached contents stop being valid. Almost everything a cache does well or badly follows from that one string. A key that never changes serves the same stale tree forever, and a key that changes on every run writes a fresh entry every time and restores none of them. ## [Definition](#definition)Copy Anchor Link A cache key is a string chosen by whoever writes the cache. The store treats it as opaque: it holds bytes under the key and returns those bytes when a later request presents the same string. Nothing inside the key is parsed by the store, so any structure it carries is a convention between the workflow author and the reader. The convention is to join segments with a hyphen, ordered from the most general segment on the left to the most specific on the right. That ordering exists because fallback matching reads from the left, so a truncated key stays meaningful as a prefix. | Segment | Example value | What a change in this segment means | | --------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- | | Platform | Linux | The entry was produced on a different operating system, and the binaries inside it may not run. | | Ecosystem and version | node-22-npm | The toolchain that produced the tree changed, so the layout or the compiled artifacts may differ. | | Content hash | 3f9c1a... from a lockfile | The dependency set itself changed, so the stored tree is missing packages or holds packages nobody wants. | In GitHub Actions the key is the required `key` input of the cache action. It accepts any combination of literals, context values, expressions, and functions, and the maximum length is 512 characters, with longer keys failing the step ([GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13). ### [What the key alone does not decide](#what-the-key-alone-does-not-decide)Copy Anchor Link Four properties surprise people who assume the key is the whole identity of an entry. The key is evaluated when the step runs. `hashFiles` reads the files present on disk at that moment and returns a SHA-256 over the matched set, or an empty string when the pattern matches nothing ([GitHub expressions reference](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#hashfiles), checked on 2026-08-13). A cache step placed before checkout therefore produces a key ending in a bare hyphen, shared by every run of the workflow. The key is half of the identity. The cache action also computes a version from the list of `path` values and the compression tool available on the machine, and a restore matches on key and version together. The Cache Version section of the [actions/cache README](https://github.com/actions/cache) documents this behavior. Two jobs with identical key text and different path lists address different entries. Entries are immutable. When an entry already exists for a key and version, the save step writes nothing and reports that the entry exists. Replacing cached content means computing a new key rather than overwriting the old one. The `cache-hit` output is `true` only on an exact match against the primary key. A restore that lands through a fallback prefix sets it to `false`, which is why an install step guarded by `if: steps.cache.outputs.cache-hit != 'true'` still runs after a partial restore. The table below separates the repository changes that move the key from the ones that move the version or the scope. | Change in the repository | Key | Version | Result on the next run | | --------------------------------------------------- | --------- | --------- | ----------------------------------------------------------------------------- | | A dependency added to the lockfile | changes | unchanged | Primary key misses, a fallback prefix may still restore the previous tree. | | node-version bumped from 20 to 22 | changes | unchanged | Primary key misses, and a prefix that includes the version misses with it. | | The job moves from a Linux runner to a macOS runner | changes | changes | No entry matches, and the job starts cold. | | A second directory added to path | unchanged | changes | No entry matches even though the key text is identical. | | The same workflow runs on a feature branch | unchanged | unchanged | Whether the entry is visible depends on [cache scope](/glossary/cache-scope). | ## [Example](#example)Copy Anchor Link This workflow caches the npm download cache with the key shape most Node repositories use: platform, tool version, then a hash of the lockfile, with two shorter prefixes as fallbacks. Copy Text ``` name: test on: push: jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore npm cache id: npm-cache uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-node-22-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node-22- ${{ runner.os }}-node- - run: npm ci - run: npm test ``` On a Linux runner `runner.os` renders `Linux` and `hashFiles` renders a 64 character hex digest, so the primary key resolves to a string such as `Linux-node-22-3f9c1a4b...`. The two `restore-keys` lines are prefixes of that same string with the most specific segment removed, so each one widens the search by one step. Trace three runs of that workflow against an empty store: | Run | Lockfile | Primary key | What the restore step does | | --- | ----------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | initial | Linux-node-22-3f9c1a4b | Nothing matches. npm ci downloads every package, and the job saves a new entry at the end. | | 2 | unchanged | Linux-node-22-3f9c1a4b | Exact match. cache-hit is true and npm ci installs from the restored download cache. | | 3 | one package added | Linux-node-22-8e02d7c5 | The primary key misses. The prefix Linux-node-22- matches the run 1 entry, cache-hit is false, npm ci fetches only the new package, and a new entry is saved under the new key. | Run 3 is the case the key shape is built for. The hash changed because the dependency set changed, so the exact entry is gone, and the prefix still finds a tree that is almost right. The choice of which segments to include and how many prefixes to list is covered on the sibling pages below. ## [Related Terms](#related-terms)Copy Anchor Link * [Cache scope and which runs may read an entry](/glossary/cache-scope): the boundary that decides whether a branch can see an entry another branch wrote, which applies after the key matches. * [How to write a good cache key in GitHub Actions](/answers/how-do-i-write-a-good-cache-key-in-github-actions): a key template with each segment explained, and the two failure shapes a bad key produces. * [What restore-keys are in GitHub Actions](/answers/what-are-restore-keys-in-github-actions): how prefix fallbacks resolve when several entries match, and when a partial restore hurts. * [GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching): the documented `key` input, its 512 character limit, and the order a restore searches in. * [WarpBuild caching documentation](/docs/ci/features/caching): cache action inputs, key examples, and the cache version rule. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What makes a good cache key? A key that changes exactly when the cached contents should change. The common shape puts the runner platform first, then the ecosystem and its version, then a hash of the lockfile that determines what gets installed. Shorter prefixes of the same string go in restore-keys as fallbacks. ### Does changing the cache key delete the old entry? No. The previous entry stays in storage under its own key until it ages out or is evicted to make room. A new key writes a new entry, so a lockfile that changes every day leaves a trail of superseded entries behind it. ### Can two jobs share one cache key? Yes, when both jobs resolve the same key string, request the same path list, and sit inside a scope that allows the read. Two jobs with matching key text but different path values compute a different cache version and address different entries. --- # Cache Scope | WarpBuild URL: https://www.warpbuild.com/glossary/cache-scope Description: Cache scope is the boundary that decides which GitHub Actions runs may read a cache entry: the branch that wrote it, plus the default branch. # Cache Scope Cache scope is the boundary that decides which GitHub Actions runs may read a cache entry: the branch that wrote it, plus the default branch. Cache scope is the boundary that decides which workflow runs are allowed to read a cache entry. A run reads entries written on its own Git ref plus entries written on the repository's default branch, and entries written anywhere else are refused. Scope is checked before the key. An entry whose key matches a request exactly still produces a cache miss when that entry sits outside the requesting run's scope. ## [Definition](#definition)Copy Anchor Link Every GitHub Actions cache entry carries three pieces of identity: the repository it belongs to, the Git ref of the run that wrote it, and the key the run supplied. Scope is the repository and ref part of that identity. GitHub describes the rules under restrictions for accessing a cache in the [caching documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows), checked on 2026-08-13. Two boundaries apply, in order. The repository boundary comes first and has no exceptions. An entry belongs to one repository, and a workflow in a different repository is refused whatever key it asks for. Teams that want one dependency cache across several repositories have to move the storage out of the per repository cache service, which the [guide on sharing a build cache across repositories](/guides/build-cache-sharing-across-repositories) covers. The ref boundary comes second and is where the surprises live. A restore request searches the run's own ref first, then the default branch, and stops at the first match. Nothing else is searched. | Event that wrote the entry | Ref recorded on the entry | Runs that can read it | | -------------------------- | ------------------------- | ------------------------------------ | | Push to the default branch | refs/heads/main | Every run in the repository | | Push to a feature branch | refs/heads/feature-a | Later runs on feature-a | | Pull request run | refs/pull/318/merge | Later runs on that same pull request | | Tag push | refs/tags/v1.2.3 | Later runs on that tag | The first row is asymmetric on purpose. Work done on the default branch flows outward to every branch, and work done on a branch flows nowhere until the change merges and a run on the default branch writes the entry again. A long lived branch that installs its own dependency set pays the install cost on every run of that branch, then pays it once more on the first run after the merge. Pull request runs sit in a scope of their own because the ref they record is the merge ref rather than the head branch. Two runs on the same pull request share entries with each other. A `push` run on the same head branch records `refs/heads/feature-a` instead, so the two event types on one branch keep two separate sets of entries. Scope decides eligibility only. Inside the right scope a request still has to match on the key and on the entry version, which is derived from the list of cached paths and the compression tool the writing runner used. The [cache key page](/glossary/cache-key) covers that half of the match. ## [Example](#example)Copy Anchor Link This workflow caches the npm download cache on every push and every pull request. It uses an exact key built from the lock file hash and a prefix fallback: Copy Text ``` name: test on: push: pull_request: jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} restore-keys: | npm-${{ runner.os }}- - run: npm ci - run: npm test ``` Trace five events through a repository whose default branch is `main`. Lock file hash `9f2c1a` is the state on `main`, and `4b77de` is the state after a dependency bump. | Order | Event | Ref of the run | Restore result | Entry written | | ----- | ---------------------------------------- | -------------------- | --------------------------------------------------------- | ---------------------------------------- | | 1 | Push to main | refs/heads/main | Miss, cache is empty | npm-Linux-9f2c1a on refs/heads/main | | 2 | Push to feature-a, lock file unchanged | refs/heads/feature-a | Exact hit from the default branch | Nothing new to write | | 3 | Push to feature-a after the bump | refs/heads/feature-a | Prefix hit on npm-Linux- from main | npm-Linux-4b77de on refs/heads/feature-a | | 4 | Push to feature-b carrying the same bump | refs/heads/feature-b | Prefix hit on main again, the exact entry is out of scope | npm-Linux-4b77de on refs/heads/feature-b | | 5 | feature-a merges, push to main | refs/heads/main | Prefix hit | npm-Linux-4b77de on refs/heads/main | Step 4 is the whole idea. An entry with the exactly correct key already existed when `feature-b` ran, and `feature-b` still installed from a stale partial restore, because that entry belonged to `feature-a`. Step 5 fixes it for everyone: once the bump lands on the default branch, the next run there writes `npm-Linux-4b77de` in the scope every branch can read. ### [Reading the scope of live entries](#reading-the-scope-of-live-entries)Copy Anchor Link The REST API returns the ref alongside each entry, so the scope of a repository's cache is directly observable: Copy Text ``` gh api repos/:owner/:repo/actions/caches \ --jq '.actions_caches[] | "\(.ref)\t\(.key)\t\(.size_in_bytes)"' ``` Copy Text ``` refs/heads/main npm-Linux-9f2c1a 82134528 refs/heads/feature-a npm-Linux-4b77de 82397184 refs/heads/feature-b npm-Linux-4b77de 82397184 refs/pull/318/merge npm-Linux-4b77de 82397184 ``` Four rows, three of them holding a near identical payload under three refs. Duplicate keys across refs are normal output rather than a fault, and they are the usual reason a repository sits near its storage limit while individual jobs still report misses. `gh cache list --ref refs/heads/feature-a` narrows the same listing to one scope, which is the fastest way to confirm whether a branch is reading its own entry or borrowing the default branch entry. ## [Related Terms](#related-terms)Copy Anchor Link * [Cache key and how a restore request is matched](/glossary/cache-key): the key, the `restore-keys` prefix list, and the version hash that decide the match once scope has allowed the lookup. * [Do caches carry across branches in GitHub Actions](/answers/do-caches-carry-across-branches-in-github-actions): the direct answer for the branch case, with the fork and pull request variants. * [Sharing a build cache across repositories](/guides/build-cache-sharing-across-repositories): the options once the repository boundary, rather than the branch boundary, is the constraint. * [GitHub caching documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows): the upstream reference for restrictions on accessing a cache. * [WarpBuild caching documentation](/docs/ci/features/caching): cache action inputs, key and version matching, and the entry expiry window. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What decides the scope of a GitHub Actions cache entry? The Git ref of the run that wrote it, inside the repository that owns it. A push run records refs/heads/, a pull request run records refs/pull//merge, and a tag run records refs/tags/. That ref is the boundary every later restore request is checked against. ### Can one feature branch read a cache written by another feature branch? No. Two feature branches are two separate scopes, so an entry written on feature-a is invisible to feature-b even when the keys match exactly. Both branches can read entries written on the default branch, which is why shared dependencies are usually seeded by a run on main. ### Why does a cache stop being found once a pull request is opened? A pull request run records the merge ref rather than the head branch, so entries written by push runs on that same branch sit outside its scope. The run still falls back to the default branch, and entries it writes are readable by later runs on the same pull request. --- # Code Signing | WarpBuild URL: https://www.warpbuild.com/glossary/code-signing Description: Code signing attaches a cryptographic signature to a binary so an operating system can check its origin and its integrity before it agrees to run it. # Code Signing Code signing attaches a cryptographic signature to a binary so an operating system can check its origin and its integrity before it agrees to run it. Code signing is the practice of attaching a cryptographic signature to a binary so that the operating system can check where the binary came from and whether it has been modified before it agrees to run it. The signature is made with a private key whose certificate chains to a root the platform trusts, so a verifier can name the publisher and detect any edit to the signed bytes. Every desktop and mobile platform now enforces some version of this check at install or at launch, which makes signing a required step in a release pipeline rather than an optional hardening pass. ## [Definition](#definition)Copy Anchor Link A code signature has three parts: a digest of the signed contents, that digest encrypted with the signer's private key, and the certificate chain that lets a verifier connect the key back to a trusted root. The digest is what gives integrity. Change a single byte of the binary after signing and the recomputed digest stops matching the one inside the signature, so the check fails without anyone needing to know what the original bytes were. The certificate chain is what gives origin. The certificate carries a subject name that a certificate authority validated before issuance, so the verifier learns which publisher signed rather than only that some key signed. Signing is enforced by a different component on every platform, and the signature itself lives in a different place. The mechanism is the same underneath, so the table below is the practical map from one platform to another. | Platform | Signing tool | Where the signature lives | What enforces it | | -------------------------------- | ---------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------ | | macOS and iOS | codesign | Inside the Mach-O binary, or in \_CodeSignature for a bundle | Gatekeeper at first launch and the kernel at load time | | Windows | signtool, using Authenticode | The certificate table of the PE file | SmartScreen, installer policy, and driver load checks | | Android | apksigner | The APK signing block | The package manager on install and on update | | Java archives | jarsigner | META-INF entries inside the JAR | The runtime or the application that loads the archive | | Container images and loose files | cosign, gpg | A detached signature stored beside the artifact or in the registry | A verification step in the deploy path | ### [What a verifier checks](#what-a-verifier-checks)Copy Anchor Link Verification is a sequence of independent checks. Each one catches a different substitution, and a build can pass four of them and still be rejected on the fifth. | Check | Failure it catches | | -------------------------------------------------------------- | ----------------------------------------------- | | The digest in the signature matches the artifact in hand | The bytes were edited after signing | | The certificate chain terminates in a trusted root | A self-signed build presented as a release | | The certificate was inside its validity window at signing time | An expired certificate reused for a fresh build | | The certificate has not been revoked through CRL or OCSP | A key withdrawn after a compromise | | The signer identity matches the one the policy expects | A valid signature from an unrelated publisher | The third row is why timestamping matters. A countersignature from an RFC 3161 timestamp authority records the moment the signature was made, and a verifier reading that countersignature can accept a signature produced while the certificate was live even years after the certificate expired ([RFC 3161](https://www.rfc-editor.org/rfc/rfc3161), checked on 2026-08-13). Skip the timestamp and every shipped release becomes unverifiable on the certificate's expiry date. ### [Where the private key lives](#where-the-private-key-lives)Copy Anchor Link The signature is only as trustworthy as the custody of the key that made it. Publicly trusted code signing certificates are subject to the CA/Browser Forum baseline requirements, which call for the private key to be generated and stored in a hardware crypto module rather than as a loose file on a workstation ([CA/Browser Forum code signing requirements](https://cabforum.org/working-groups/code-signing/requirements/), checked on 2026-08-13). Teams that sign in an automated build reach that bar either with a network-attached signing service or with a key that is delivered to a job from an encrypted secret store and destroyed with the machine when the job ends. A signature says nothing about the quality of the code. A binary signed with a stolen key verifies cleanly until the certificate is revoked, which is the argument for keeping signing keys out of developer laptops and out of every job that does not ship an artifact. ## [Example](#example)Copy Anchor Link The workflow below signs a macOS binary on a tag push. The signing identity arrives as a base64 encoded `.p12` in a repository secret, gets imported into a keychain created for this job alone, signs the artifact, and is verified before the artifact is uploaded. Secrets are unavailable to workflow runs triggered from forks, so gating on tags keeps the signing job off pull request traffic ([GitHub secrets documentation](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions), checked on 2026-08-13). Copy Text ``` name: release on: push: tags: ["v*"] jobs: sign-macos: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v4 - run: swift build -c release - name: Import the signing identity into a temporary keychain env: SIGNING_CERT_P12: ${{ secrets.SIGNING_CERT_P12 }} SIGNING_CERT_PASSWORD: ${{ secrets.SIGNING_CERT_PASSWORD }} run: | KEYCHAIN="$RUNNER_TEMP/signing.keychain-db" KEYCHAIN_PASSWORD="$(openssl rand -base64 24)" echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security set-keychain-settings -lut 3600 "$KEYCHAIN" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security list-keychains -d user -s "$KEYCHAIN" login.keychain-db echo "$SIGNING_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12" security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \ -P "$SIGNING_CERT_PASSWORD" -T /usr/bin/codesign rm -f "$RUNNER_TEMP/cert.p12" security set-key-partition-list -S apple-tool:,apple:,codesign: \ -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" - name: Sign the binary and verify the signature env: SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }} run: | codesign --force --options runtime --timestamp \ --keychain "$KEYCHAIN" \ --sign "$SIGNING_IDENTITY" \ .build/release/acme-cli codesign --verify --strict --verbose=2 .build/release/acme-cli - uses: actions/upload-artifact@v4 with: name: acme-cli-macos path: .build/release/acme-cli - if: always() run: security delete-keychain "$KEYCHAIN" ``` Four lines carry the security properties of that job. `security import` with `-T /usr/bin/codesign` and the follow-up `set-key-partition-list` call pre-authorize `codesign` to use the imported key, which is what stops the headless machine from stalling on an authorization prompt no one can answer. `--options runtime` enables the hardened runtime that Apple requires before a build can be notarized. `--timestamp` asks Apple's timestamp service for the countersignature described above. The `if: always()` teardown removes the keychain even when the signing step fails. Nothing in that job is specific to one runner fleet. Changing the value of `runs-on` moves the same steps onto a different macOS machine, which is covered step by step in the [iOS code signing guide](/guides/ios-code-signing-github-actions). The Windows half of a cross-platform release follows the same shape with a different tool. `signtool` reads a PFX from the same kind of secret, and `/tr` points at the RFC 3161 timestamp authority published by the certificate authority that issued the certificate: Copy Text ``` sign-windows: runs-on: warp-windows-2025-x64-4x needs: build-windows steps: - uses: actions/download-artifact@v4 with: name: acme-cli-windows - name: Sign with a timestamp and verify shell: pwsh env: WINDOWS_CERT_PFX: ${{ secrets.WINDOWS_CERT_PFX }} WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} TIMESTAMP_URL: ${{ vars.TIMESTAMP_URL }} run: | $pfx = "$env:RUNNER_TEMP\cert.pfx" [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERT_PFX)) signtool sign /fd SHA256 /td SHA256 /tr $env:TIMESTAMP_URL ` /f $pfx /p $env:WINDOWS_CERT_PASSWORD acme-cli.exe Remove-Item $pfx signtool verify /pa /v acme-cli.exe ``` `/fd SHA256` sets the digest algorithm for the file, `/td SHA256` sets it for the timestamp, and `signtool verify /pa /v` re-reads the finished executable using the same policy a Windows client applies. Running the verify command in the job turns a silent signing failure into a red step, which is worth the two seconds it costs. ## [Related Terms](#related-terms)Copy Anchor Link * [Code signing iOS builds on GitHub Actions](/guides/ios-code-signing-github-actions): the keychain, certificate, and profile sequence for an Xcode archive job, with the errors each step throws. * [What a provisioning profile binds together](/glossary/provisioning-profile): the Apple file that ties an application identifier, a signing certificate, and a distribution method into one document. * [Signing Windows binaries in GitHub Actions](/guides/windows-code-signing-github-actions): certificate delivery, `signtool` invocation, and timestamping on a Windows job. * [WarpBuild security documentation](/docs/ci/security): runner isolation, encrypted storage volumes, and how build secrets are handled. * [Cloud runners documentation](/docs/ci/cloud-runners): the macOS and Windows runner labels available for a signing job, with sizes and images. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What does a code signature actually prove? It proves two things. The signature was produced by the holder of a private key whose certificate chains to a root the verifying platform trusts, and the signed bytes have not changed since that signature was made. Whether the code behaves well is a separate question that signing leaves to tests, review, and scanners. ### Why does a signature need a timestamp? A signing certificate has a validity window, usually one to three years. Without a timestamp, every binary signed with that certificate starts failing verification the day the certificate expires. A countersignature from an RFC 3161 timestamp authority records when the signing happened, so a verifier can accept a signature made while the certificate was still valid. ### Is code signing the same as notarization? No. Signing is a local operation that attaches a signature using a key you hold. Notarization on Apple platforms is a separate submission to Apple's service, which scans the already signed build and returns a ticket. A build can be signed and still be refused by Gatekeeper if it was never notarized. --- # Cold Start | WarpBuild URL: https://www.warpbuild.com/glossary/cold-start Description: A cold start is the time between a GitHub Actions job being queued and its first useful step, spent on runner allocation, image boot, and setup. # Cold Start A cold start is the time between a GitHub Actions job being queued and its first useful step, spent on runner allocation, image boot, and setup. A cold start is the time between a job being queued and that job doing useful work, spent on allocating a machine, booting the machine and its image, and setting up the environment the job needs. The steps you actually care about have not run yet during that window, so it is overhead added to every run. The term is borrowed from serverless computing, where a cold start is the first invocation on a freshly created instance. GitHub Actions has the same shape one level up, at the job: a job that lands on a machine with no history pays for everything that machine does not already have. ## [Definition](#definition)Copy Anchor Link A cold start is measured from the moment a job is queued to the moment the first step you wrote begins doing its work. Everything inside that window is preparation for the work rather than the work. Four costs sit inside it, and they always occur in this order. **Runner allocation.** The gap between the job entering the queue and a machine being assigned to it. On a fixed pool of long-lived machines this is a wait for a free one. On a per-job provisioner it is the time to select an instance type, launch a virtual machine, and let the runner agent register with GitHub. Nothing of yours runs here, and on most billing models nothing is metered here either, which is why this part disappears from invoices and shows up only as people waiting. **Image boot.** The machine exists and now has to become usable: kernel boot, cloud-init or its equivalent, disk attach, network configuration, container runtime start, and the agent handshake that makes the runner eligible for work. **Toolchain install.** Every `setup-*` action, `apt-get install`, `brew install`, SDK download, and container image pull that runs before your build command. A job that installs a language runtime, a package manager, a linter, and two command line tools pays for four downloads and four extractions on every run. **Dependency restore.** `npm ci`, `bundle install`, `go mod download`, `pip install`, or a cache action pulling a tarball and unpacking it. A cache hit is faster than a fresh resolve and still costs a download plus an extraction proportional to the size of the tree. ### [Where the window ends](#where-the-window-ends)Copy Anchor Link The start of the window is unambiguous. The end is fuzzy, because the first step you wrote often carries setup cost of its own. A compiler on a fresh machine begins with empty incremental state and rebuilds output that the previous run already produced. Rust repopulates `target/`, Gradle runs without a warm daemon, TypeScript runs without `.tsbuildinfo`, and a container build without a warm layer cache re-executes every layer. That first-run penalty hides inside your build command rather than appearing as its own step, and it belongs to the cold start by any honest accounting even though no log line marks it. Pick one boundary and hold it. The practical choice is the first line of the first step that produces something the job exists to produce, with the first-run compilation penalty tracked as a separate number underneath it. ### [Two numbers that get folded in by mistake](#two-numbers-that-get-folded-in-by-mistake)Copy Anchor Link Concurrency wait is the first. A job held back by a workflow concurrency group, an organization limit, or a runner group restriction is waiting for permission rather than for hardware. It looks identical from the run view, since both cases show a job with no logs, and the fixes have nothing in common. Steady-state build time is the second. That is what remains when the machine, the toolchain, and the dependency tree are already in place. Adding it to the cold start makes every improvement look smaller than it was and hides which half of the run actually moved. ### [Warm and cold describe the machine](#warm-and-cold-describe-the-machine)Copy Anchor Link Cold and warm are properties of the machine that received the job. A long-lived runner keeps whatever the previous job left on disk. The second job to land there finds a populated package cache, pulled container images, and a checked out repository, so its window is short. It also finds half written files from a job that was cancelled, which is the trade. An ephemeral runner takes one job and is destroyed afterwards, so every job starts on a machine with no history and the full window is present by default. What shortens it is state that arrives with the machine rather than state left by an earlier job. ### [The levers, by phase](#the-levers-by-phase)Copy Anchor Link Each part of the window responds to a different mechanism, and none of them touches all four. | Phase | What shortens it | | --------------------- | ----------------------------------------------------------------------------------------------------------------------- | | Runner allocation | Machines booted in advance and held ready, so the request is answered by a machine that already exists | | Image boot | A smaller image, fewer services started at boot, or resuming a machine from saved state instead of booting from scratch | | Toolchain install | Baking the toolchain into the machine image, so the job finds it installed at step one | | Dependency restore | A cache keyed on the lockfile, a disk that already holds the tree, or a smaller tree | | First-run compilation | Restored incremental state: a compiler cache, a build cache, or a warm container layer cache | Reading that table top to bottom explains why a single change rarely moves the total much. Baking a toolchain into an image removes the third row and leaves the first two exactly where they were. ## [Example](#example)Copy Anchor Link ### [The first minute of a Node job](#the-first-minute-of-a-node-job)Copy Anchor Link The profile below is an example shape for a service with a moderate dependency tree, running unit tests on a per-job machine. Substitute your own measurements; the point is the split rather than the figures. | Elapsed | Phase | Duration | What is happening | | ------------ | ------------------ | ------------------------------------------------------- | ------------------------------------------------------------- | | 0:00 | Job queued | GitHub creates the job and looks for an eligible runner | | | 0:00 to 0:22 | Runner allocation | 22 s | An instance is selected and launched, and the agent registers | | 0:22 to 0:38 | Image boot | 16 s | Kernel, network, disk, container runtime, agent handshake | | 0:38 to 0:57 | Toolchain install | 19 s | actions/setup-node downloads and extracts the runtime | | 0:57 to 1:14 | Dependency restore | 17 s | A 480 MB node\_modules cache is downloaded and unpacked | | 1:14 | First useful step | npm test starts | | Seventy four seconds pass before the test command runs. If the suite itself takes 105 seconds, the job occupies 179 seconds of wall clock and 41 percent of that is preparation. Multiply by the number of jobs in the workflow and by the number of pull requests per day to see why the figure gets attention. ### [Reading the same numbers from your own logs](#reading-the-same-numbers-from-your-own-logs)Copy Anchor Link A workflow can time the second half of the window and only the second half, because it does not exist during the first half. Timestamps written between steps give you the toolchain and dependency parts: Copy Text ``` name: cold-start-profile on: push: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - name: Mark machine ready run: date +%s > /tmp/t0 - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - name: Mark toolchain ready run: date +%s > /tmp/t1 - run: npm ci - name: Mark dependencies ready run: date +%s > /tmp/t2 - name: Print the split run: | t0=$(cat /tmp/t0) t1=$(cat /tmp/t1) t2=$(cat /tmp/t2) echo "toolchain install: $((t1 - t0))s" echo "dependency restore: $((t2 - t1))s" - run: npm test ``` The first half comes from two other places. The `Set up job` step in the raw log carries the timestamps for allocation and image boot together. For allocation on its own, the workflow jobs endpoint of the GitHub REST API returns `created_at`, `started_at`, and `completed_at` for every job, and the difference between the first two is the time the job spent waiting for a machine ([GitHub REST API reference for workflow jobs](https://docs.github.com/en/rest/actions/workflow-jobs), checked on 2026-08-13). Take that difference across a week rather than on one run. Allocation is the part with the widest spread, because it depends on what else was queued at the same time. ### [The same job behind a different label](#the-same-job-behind-a-different-label)Copy Anchor Link The durations in the timeline belong to the machine and the image that the label in `runs-on` selected. Changing the label changes which fleet claims the job while every other line in the file stays where it is: Copy Text ``` jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm test ``` Labels of this shape encode an operating system, an architecture, and a machine size, and GitHub treats the whole string as one opaque routing key. The mapping for the Linux x64 labels is below, from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Label | OS | vCPU | RAM | Storage | | -------------------------- | ------------ | ---- | ------ | --------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | | warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | Machine size moves the last two rows of the timeline, since download and extraction speed scale with the machine. It leaves the first two rows alone, because allocation and boot are decided by how the machine is supplied rather than by how large it is. Some fleets accept options appended to the label after a semicolon, which is how a job asks for a machine booted from disk state captured on an earlier run instead of from a base image. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) covers that label form and the platforms it applies to, and the [standby disks documentation](/docs/ci/byoc/standby-disks) covers holding machines ready before a job is queued. The two mechanisms sit at opposite ends of the window: one removes work from the setup phases, the other removes waiting from the allocation phase. ## [Related Terms](#related-terms)Copy Anchor Link * [Removing cold starts from GitHub Actions jobs](/guides/github-actions-cold-starts): the same five parts with measurement passes, configuration, and a weekly time model. * [Warm cache, defined](/glossary/warm-cache): what counts as warm, how a cache key decides, and why a hit rate is the number to watch. * [Ephemeral runner, defined](/glossary/ephemeral-runner): the one-job lifecycle that makes every job start cold, and the mechanisms for moving state across the boundary on purpose. * [Standby disks for BYOC runners](/byoc/standby-disks): pre-booted machines held in a pool so the allocation phase is answered by a machine that already exists. * [Snapshot runners and saved disk state](/runners/snapshot): booting a fresh machine from a disk captured during an earlier run, with the label options and retention window. * [WarpBuild snapshot runners documentation](/docs/ci/features/snapshot-runners): the `snapshot.enabled` and `snapshot.key` label forms, supported platforms, and cleanup guidance. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a cold start in GitHub Actions? The stretch between a job being queued and the first step you wrote doing useful work. It covers runner allocation, image boot, and environment setup such as toolchain installs and dependency restores. Nothing your workflow exists to produce is produced during that window, so it is overhead paid once per job. ### Is a cold start the same thing as queue time? Queue time is one part of it. A job can wait because no machine has been supplied yet, which is allocation, or because a concurrency rule is holding it back, which is policy. Both show up as a job sitting with no logs, and they have different fixes, so measure them separately. ### How do I measure the cold start of a job? Take the difference between the job's created\_at and started\_at timestamps from the GitHub REST API for allocation, read the Set up job step in the raw log for image boot, and read the durations of the setup and install steps for the environment part. The workflow cannot time the part that happens before the machine exists, so that half comes from the log and the API. ### Do ephemeral runners always pay a cold start? Every job on an ephemeral runner starts on a machine with no history, so the full window is there by default. What shortens it is state that arrives with the machine rather than state left behind by an earlier job: a prebuilt image, a restored cache, a disk captured on an earlier run, or a machine that was booted before the job was queued. --- # Compiler Cache | WarpBuild URL: https://www.warpbuild.com/glossary/compiler-cache Description: A compiler cache stores object files from earlier compilations, keyed by source content and compiler flags, and returns them when the same inputs recur. # Compiler Cache A compiler cache stores object files from earlier compilations, keyed by source content and compiler flags, and returns them when the same inputs recur. A compiler cache is a store of compiler output from earlier builds, keyed by a hash of the compilation inputs, so a translation unit whose inputs have not changed is served from the store instead of being compiled again. Because the key comes from content rather than from file timestamps, the same store produces hits on a clean checkout and on a machine that has never seen the project before. That second property is why compiler caches appear in GitHub Actions workflows at all. A hosted job starts on a machine with no build tree from the previous run, which defeats timestamp based rebuild logic and leaves content addressing as the mechanism that still works. ## [Definition](#definition)Copy Anchor Link A compiler cache is a wrapper placed in front of the compiler. The build system calls the wrapper with the arguments it would have passed to the compiler. The wrapper hashes the inputs of that one compilation, looks the hash up in a store, and either copies out the stored object file or runs the real compiler and writes the result back under the hash. The unit of caching is one translation unit: one source file compiled to one object file with `-c`. Linking is outside the model, because a link step reads object files that the cache itself just produced and its inputs are not fixed by a single source file. The [ccache manual](https://ccache.dev/manual/latest.html) lists the invocation shapes that are skipped for this reason, including a compiler called for linking and a compiler called to compile several source files in one command. ### [What goes into the key](#what-goes-into-the-key)Copy Anchor Link The key has to change when the produced object file would change, and stay identical otherwise. That pulls in more than the source file. ccache documents two ways of assembling it, and the difference between them decides how a header edit behaves. | Hashed input | Direct mode | Preprocessor mode | | ------------------------------------------------------ | -------------------------------------- | ------------------------------------------------------- | | Compiler identity and version | Yes | Yes | | Source file content | Yes | Folded into the preprocessed output | | Preprocessor output from running the compiler with \-E | No | Yes | | Command line options | Yes | Yes, except the options that affect include file lookup | | Contents of every included header | Recorded in a manifest and revalidated | Folded into the preprocessed output | | Standard error written by the preprocessor | No | Yes | Direct mode hashes the source file and the options and skips the preprocessor, which is the faster path. It then reads a manifest stored under that hash. The manifest holds references to cached results together with the paths of the include files that were read and hash sums of those files, so the wrapper can check whether the headers behind a stored result are still the headers on disk. When no manifest entry revalidates, ccache falls back to preprocessor mode for that file and pays for one `-E` run. Options are part of the key, so a flag change is a full miss across the tree. Moving a project from `-O2` to `-O3`, changing the C++ standard, or upgrading the compiler package invalidates every entry at once, and the next build refills the store from scratch. ### [The two implementations a workflow is likely to use](#the-two-implementations-a-workflow-is-likely-to-use)Copy Anchor Link Both common implementations are open source and both are drop-in wrappers. * ccache is a C and C++ compiler cache. Its compiler support covers GCC, Clang, MSVC, clang-cl, the Intel compilers, and NVCC ([ccache manual](https://ccache.dev/manual/latest.html)). * sccache is a compiler wrapper in the same style with a wider language range and remote storage. It caches assembler, C and C++ through GCC, Clang and MSVC, Rust through rustc, CUDA through nvcc and Clang, and HIP through hipcc, and it can keep the store on local disk, S3, Google Cloud Storage, Azure Blob Storage, Redis, memcached, WebDAV, or the GitHub Actions cache ([sccache repository](https://github.com/mozilla/sccache)). sccache documents two limits worth knowing before it goes into a Rust workflow: crates that invoke the system linker are not cached, which covers `bin`, `dylib`, `cdylib` and proc-macro crates, and incrementally compiled crates are not cached at all, so `CARGO_INCREMENTAL=0` is the usual setting alongside it ([sccache repository](https://github.com/mozilla/sccache)). Neither tool keeps state between GitHub Actions jobs on its own. The cache directory has to be restored and saved by the workflow, or live on a runner whose disk survives from one job to the next. ## [Example](#example)Copy Anchor Link A CMake project builds 400 translation units. The workflow installs ccache, restores the cache directory with `actions/cache`, and points CMake at the wrapper through the compiler launcher variables. Copy Text ``` name: build on: push: jobs: compile: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: sudo apt-get update && sudo apt-get install -y ccache ninja-build - name: Restore compiler cache uses: actions/cache@v4 with: path: ~/.cache/ccache key: ${{ runner.os }}-ccache-${{ github.sha }} restore-keys: | ${{ runner.os }}-ccache- - name: Configure run: | cmake -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache - name: Build run: cmake --build build - name: Cache statistics run: ccache -s ``` The `key` carries the commit sha so that every run writes a new entry, and the `restore-keys` prefix pulls the most recent earlier entry in at the start of the job. `ccache -s` at the end prints the counters the store keeps, which separate direct mode hits from preprocessed hits and from misses. Now trace four runs of that workflow against the same repository. The header in question, `include/geometry/vec3.h`, is included by 12 of the 400 source files. | Run | Change since the previous run | What the wrapper does | Compilations that reach the compiler | | --- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | | 1 | First run, empty store | No manifest exists for any source file | 400 | | 2 | One .cpp body edited | 399 manifests revalidate and return stored objects | 1 | | 3 | vec3.h edited | 388 manifests revalidate; the 12 files that include the header fail revalidation and fall back to preprocessor mode | Up to 12 | | 4 | \-O2 changed to \-O3 | The option is part of the key, so no entry matches | 400 | Run 3 is the case the manifest exists for. The wrapper does not need to know which files include `vec3.h`, because the manifest recorded the include set from the last successful compilation of each file, and the header hash comparison answers the question directly. The 12 affected files then take the slower path, and even there a hit is still possible: if the edit sits inside a block that the preprocessor drops for a given file, the preprocessed output is byte identical to the previous run and preprocessor mode matches an existing result. Run 4 is the case that catches teams out. A compiler upgrade delivered by a base image update has the same effect, because the compiler identity is hashed alongside the options. A store that looked healthy for weeks goes cold on the run after the image changes, and the first build afterwards pays full price. ## [Related Terms](#related-terms)Copy Anchor Link * [Setting up ccache in GitHub Actions](/guides/ccache-github-actions): the wrapper installation, the cache key shape for the ccache directory, and the counters to read after a run. * [Setting up sccache in GitHub Actions](/guides/sccache-github-actions): the Rust and C++ setup, the storage backends, and the crate types that stay uncached. * [Incremental builds and how they differ from a compiler cache](/glossary/incremental-build): the timestamp and dependency graph model, and why it needs a build tree that survives. * [ccache manual](https://ccache.dev/manual/latest.html): direct mode, preprocessor mode, the manifest, and the full list of uncacheable invocations. * [sccache repository](https://github.com/mozilla/sccache): supported compilers, storage backends, and documented limitations. * [WarpBuild caching documentation](/docs/ci/features/caching): cache action inputs and key examples for GitHub Actions workflows. * [WarpBuild snapshot runners documentation](/docs/ci/features/snapshot-runners): how a runner boots from a saved machine image with its disk contents already in place. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a compiler cache? A store of compiler output from earlier builds, addressed by a hash of the compilation inputs. When a later build presents the same source content, the same compiler, and the same options, the stored object file is copied into place and the compiler is skipped. ### How is a compiler cache different from an incremental build? An incremental build compares timestamps and a dependency graph inside one build tree, so it needs that tree to survive. A compiler cache hashes the inputs of each compilation, so it produces hits on a clean checkout and on a machine that has never built the project before. ### Does a compiler cache work in GitHub Actions? Yes, once the cache directory outlives the job. Every hosted job starts on a fresh machine, so the cache directory has to be restored at the start of the job and saved at the end, or held on a runner whose disk persists between jobs. --- # Composite Action | WarpBuild URL: https://www.warpbuild.com/glossary/composite-action Description: A composite action bundles several GitHub Actions workflow steps into one reusable action defined by a single action.yml file. Syntax, limits, example. # Composite Action A composite action bundles several GitHub Actions workflow steps into one reusable action defined by a single action.yml file. Syntax, limits, example. A composite action is a reusable GitHub Actions action whose body is a list of workflow steps written directly in a single `action.yml` file, rather than JavaScript source or a container image. A workflow calls it with `uses` like any other action, and every step inside it runs within the calling job. The point of the format is that the unit of reuse is a step sequence you already wrote. Three `run` commands repeated across ten workflow files move into one directory holding an `action.yml`, and each of those ten files keeps a single `uses` line where the three commands used to be. ## [Definition](#definition)Copy Anchor Link GitHub supports three kinds of custom action, and the `runs.using` key in the action metadata file selects which one you are writing ([metadata syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/metadata-syntax), checked on 2026-08-13). | Action type | runs.using value | What supplies the logic | | ---------------- | ---------------- | ----------------------------------------------------- | | Composite | composite | runs.steps, a list of workflow steps in action.yml | | JavaScript | node20 or node24 | A script file named by runs.main | | Docker container | docker | A Dockerfile or a published image named by runs.image | The metadata file must be called `action.yml` or `action.yaml`, and GitHub states `action.yml` as the preferred form. Its `name` and `description` keys are required for every action type. The file lives at the root of a directory, and that directory is what a workflow references. ### [What goes inside runs.steps](#what-goes-inside-runssteps)Copy Anchor Link `runs.steps` accepts both `run` steps and `uses` steps, so a composite action can shell out and call other published actions in the same sequence. Four rules govern the steps: 1. `shell` is required on any step that sets `run`. GitHub documents `runs.steps[*].shell` as required in that case, and a composite step without it fails to parse. 2. Inputs are read through the `inputs` context, written as `${{ inputs.name }}`. GitHub notes that composite actions do not automatically receive the `INPUT_` environment variables that Docker container and JavaScript actions get. 3. Outputs must be declared under a top level `outputs` block, and each one needs a `value` key mapping it to a step output such as `${{ steps.build.outputs.digest }}`. Declaring the output alone leaves the caller with an empty string. 4. Files shipped alongside `action.yml`, such as a helper script, are addressed through `${{ github.action_path }}` or `$GITHUB_ACTION_PATH`. A bare relative path resolves against the job workspace instead of the action directory. Per step keys `id`, `name`, `env`, `if`, `working-directory`, `with`, and `continue-on-error` all work the same way they do in a workflow file. ### [How a workflow references one](#how-a-workflow-references-one)Copy Anchor Link Three reference forms reach a composite action, and they differ in what has to exist on disk first: * `owner/repo@ref` fetches the action from another repository at a tag, branch, or commit SHA. GitHub recommends pinning to a commit SHA for stability and security. * `./path/to/action` reads the action from the caller's checked out working directory, so the workflow has to run `actions/checkout` before the step that uses it. * `$/path/to/action` is the self repository reference. GitHub calls it the recommended way to reference an action inside its own repository, because it resolves to that repository at the running commit with no prior checkout. The prefix carries no `@{ref}` suffix and is unavailable on GitHub Enterprise Server ([workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#example-using-an-action-in-the-same-repository-as-the-workflow-at-the-running-commit-recommended), checked on 2026-08-13). ### [Documented behavior and limits](#documented-behavior-and-limits)Copy Anchor Link GitHub's comparison of the two reuse mechanisms records the following for composite actions ([reusing workflow configurations](https://docs.github.com/en/actions/concepts/workflows-and-actions/reusing-workflow-configurations), checked on 2026-08-13): | Property | Composite action | | ---------------------- | -------------------------------------------------------- | | Contains jobs | No, it runs as one step inside a caller job | | Chooses its own runner | No, it inherits the machine the caller job is already on | | Logging granularity | Logged as one step even when it contains many | | Secrets context | Unavailable, so values arrive as inputs passed with with | | Nesting depth | Up to 10 composite actions in one workflow | | Marketplace | Can be published | Two of those rows drive most design decisions. Because a composite action inherits the caller's runner, steps that need a different operating system belong in a reusable workflow instead. Because it collapses into one log line, a composite action that grows past a handful of steps becomes hard to debug from the run view. GitHub also caps each workflow file in `.github/workflows` at 500 KB and names composite actions as one of the two ways to shrink an oversized file ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). ## [Example](#example)Copy Anchor Link This composite action installs dependencies and runs a test suite in two `run` steps. It lives at `.github/actions/unit-tests/action.yml` in the same repository as the workflow that calls it. Copy Text ``` name: Unit tests description: Install dependencies and run the unit test suite for one package inputs: working-directory: description: Directory holding the package.json to build and test required: false default: '.' outputs: report-path: description: Path to the JUnit report written by the test run value: ${{ steps.test.outputs.report-path }} runs: using: "composite" steps: - name: Install dependencies run: npm ci shell: bash working-directory: ${{ inputs.working-directory }} - name: Run tests id: test run: | npm test -- --reporters=default --reporters=jest-junit echo "report-path=${{ inputs.working-directory }}/reports/junit.xml" >> "$GITHUB_OUTPUT" shell: bash working-directory: ${{ inputs.working-directory }} ``` The calling job references the directory with a relative path. `actions/checkout` runs first, because the `./` form reads the action from the workspace the job has already checked out: Copy Text ``` name: tests on: push: branches: [main] jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' - id: suite uses: ./.github/actions/unit-tests with: working-directory: packages/api - run: echo "Report at ${{ steps.suite.outputs.report-path }}" ``` Three details of that run are worth noting. The job log shows one step named after the action, and the install and test commands appear nested under it rather than as siblings of the checkout step. The `report-path` output reaches the caller only because `outputs.report-path.value` maps it to the `id` of the `test` step. And the `working-directory` input is readable inside the action as `${{ inputs.working-directory }}` with no `INPUT_WORKING_DIRECTORY` environment variable behind it. Swapping `./.github/actions/unit-tests` for `$/.github/actions/unit-tests` removes the ordering constraint, since the self repository reference pulls the action at the running commit whether or not the workspace has been checked out. Moving the action to its own repository changes one line in the caller. `uses: ./.github/actions/unit-tests` becomes `uses: my-org/ci-actions/unit-tests@a1b2c3d`, the checkout requirement for the action itself goes away, and the pinned SHA decides which version of the step sequence runs. ## [Related Terms](#related-terms)Copy Anchor Link * [Reusable workflow](/glossary/reusable-workflow): the other GitHub Actions reuse mechanism, which packages whole jobs, picks its own runners, and can accept secrets. * [How do I write a composite action](/answers/how-do-i-write-a-composite-action): the file layout, input and output wiring, and testing loop for building one. * [Reusable workflows across many repositories](/guides/reusable-workflows-at-scale-github-actions): versioning, pinning, and rollout patterns once shared configuration spans an organization. * [Cache-enabled setup-\* actions](/docs/ci/features/caching/setup-actions): the forked `setup-*` actions and their cache inputs, callable from a `uses` step inside a composite action. * [Runner rates per minute](/pricing): per-minute rates by runner type. ## FAQ ### What makes an action composite rather than JavaScript or Docker? The runs.using value in action.yml. A composite action sets runs.using to composite and supplies runs.steps, a list of workflow steps. A JavaScript action sets runs.using to a Node.js runtime such as node20 or node24 and points runs.main at a script file. A Docker container action sets runs.using to docker and points runs.image at a Dockerfile or a registry image. ### Do composite action steps need a shell key? Yes, whenever the step uses run. GitHub documents runs.steps\[\*\].shell as required if run is set, so a composite step that omits it fails to parse. Steps that use uses instead of run do not need a shell key. ### Why do the individual steps of a composite action not appear in the run log? GitHub logs a composite action as the single caller step that invoked it, even when it contains many steps inside. Reusable workflows log every job and step separately, so a long composite action is harder to read in the run view than the same logic split across workflow steps. --- # Concurrency Group | WarpBuild URL: https://www.warpbuild.com/glossary/concurrency-group Description: A concurrency group is a named lane in GitHub Actions that runs one workflow run or job at a time, and can cancel the run already in progress. # Concurrency Group A concurrency group is a named lane in GitHub Actions that runs one workflow run or job at a time, and can cancel the run already in progress. A concurrency group is a named lane in GitHub Actions that allows one workflow run or job at a time. Work that arrives while the lane is busy becomes pending, and the workflow can ask GitHub to cancel whatever is already running so the newest arrival takes the lane immediately. The name is the whole mechanism. Anything in the repository that resolves to the same group string shares the lane, and anything that resolves to a different string runs without waiting. ## [Definition](#definition)Copy Anchor Link A concurrency group is created by the `concurrency` key in workflow YAML. The key takes a `group` value, which is the group name, plus two optional settings that decide what happens to work already in the group. GitHub documents the behavior in the [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency), checked on 2026-08-13. The key sits at one of two levels, and the level decides what occupies the lane: * At the top level of a workflow file, the group governs whole workflow runs. * Inside a job, under `jobs..concurrency`, the group governs that one job across runs. The group name can be a literal string or an expression. The allowed expression contexts differ by level: the workflow level accepts `github`, `inputs`, and `vars`, while the job level also accepts `needs`, `strategy`, and `matrix`. ### [The three settings](#the-three-settings)Copy Anchor Link | Key | Values | Default | What it decides | | ------------------ | --------------------- | -------------------- | ------------------------------------------------------------------ | | group | string or expression | required, no default | The identity of the lane. The same string means the same lane. | | cancel-in-progress | boolean or expression | false | Whether a new arrival cancels the run already occupying the lane. | | queue | single or max | single | How many arrivals may wait: one under single, up to 100 under max. | `queue: max` and `cancel-in-progress: true` cannot be combined. GitHub rejects that workflow with a validation error, because one setting asks to preserve work in flight while the other asks to end it. ### [The states a group produces](#the-states-a-group-produces)Copy Anchor Link At most one run or job in a group is in progress at any moment. A second arrival becomes pending. What happens to a third depends on `queue`: * Under `queue: single`, the pending item is cancelled and the new arrival takes the waiting slot, so the group holds one running item and one waiting item. * Under `queue: max`, up to 100 items wait and are processed first in first out by the time each one started waiting on the group rather than the time it was dispatched. Arrivals beyond 100 are cancelled. Two further properties catch people out. Group names are case insensitive, so `prod` and `Prod` resolve to one group. And membership is scoped to the repository rather than to a workflow file, so two different workflow files whose group expressions produce the same string end up sharing a single lane. A concurrency group never makes work start sooner. It removes superseded work and serializes work that must not overlap. Time spent waiting because every runner is busy is a different problem with different symptoms. ## [Example](#example)Copy Anchor Link The common shape keys the group on the branch ref and cancels the run in progress, so a new push replaces the check that is already running: Copy Text ``` name: test on: push: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test ``` Including `github.workflow` in the group keeps this file's lane separate from other workflow files in the repository. `github.ref` gives every branch its own lane, so a push to a feature branch leaves a run on main alone. Trace three pushes to the same branch, arriving 30 seconds apart, against a suite that takes 6 minutes: | Clock | Event | Run A | Run B | Run C | | ----- | ------------------- | ----------- | ----------- | ----------- | | 0:00 | Push 1 queues run A | in progress | not created | not created | | 0:30 | Push 2 queues run B | cancelled | in progress | not created | | 1:00 | Push 3 queues run C | cancelled | cancelled | in progress | | 7:00 | Run C reports | cancelled | cancelled | success | Only the newest commit gets a full test cycle. Under those assumptions the three runs consume 30 seconds plus 30 seconds plus 6 minutes of runner time, which is 7 minutes instead of the 18 minutes the same three pushes would consume with no group at all. The commit that ships is also the only one anybody reads a result for. ### [A group that queues instead of cancelling](#a-group-that-queues-instead-of-cancelling)Copy Anchor Link Deploys want the opposite behavior. Put the group on the job, give it a literal name so every run of that job shares one lane, leave `cancel-in-progress` at its default, and let arrivals line up: Copy Text ``` jobs: deploy: needs: unit-tests runs-on: ubuntu-latest concurrency: group: production-deploy queue: max steps: - run: ./scripts/deploy.sh ``` A rollout in flight now finishes, and the next deploy starts when the lane clears. Because the group is on the job rather than on the workflow, the test job in the same run is unaffected and keeps running in parallel with deploys from other runs. ### [Fallbacks in the group expression](#fallbacks-in-the-group-expression)Copy Anchor Link An expression that references a property defined only for some events needs a fallback. `github.head_ref` exists on `pull_request` events and is empty elsewhere, so a workflow that also runs on `push` writes the group as `${{ github.head_ref || github.run_id }}`. The run ID is unique per run, so runs from other events each land in a private lane instead of colliding in an empty group name. ## [Related Terms](#related-terms)Copy Anchor Link * [Concurrency limit and how it differs from a group](/glossary/concurrency-limit): the ceilings that cap how many jobs run at once, which is a separate mechanism from the lane a group creates. * [How to cancel in-progress runs on a new push](/answers/how-do-i-cancel-in-progress-runs-on-a-new-push): the group expression and cancel flag to use, and the jobs to keep out of that pattern. * [Diagnose and fix GitHub Actions queue times](/guides/github-actions-queue-times): how to tell waiting on a group apart from waiting for a free runner. * [GitHub workflow syntax reference for concurrency](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency): the documented behavior of `group`, `cancel-in-progress`, and `queue`. * [WarpBuild reports documentation](/docs/ci/features/reports): queue time and duration percentiles per job, plus a queue timings view per runner label. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What does the concurrency group name control? The group name is the identity of the lane. Any workflow run or job in the same repository that resolves to the same string shares that lane and waits for it. GitHub treats the name as case insensitive, so prod and Prod are one group. ### Does a concurrency group make jobs start sooner? No. A concurrency group only holds work back or cancels it. One run occupies the lane, the next becomes pending, and later arrivals are cancelled or lined up depending on the queue setting. Waiting caused by a shortage of runners is a separate problem. ### How many runs can wait in one concurrency group? One by default, because queue is set to single and each new arrival cancels the pending run it replaces. Setting queue to max lets up to 100 runs wait in the group, and arrivals beyond that are cancelled. queue max cannot be combined with cancel-in-progress true. --- # Concurrency Limit | WarpBuild URL: https://www.warpbuild.com/glossary/concurrency-limit Description: A concurrency limit is a ceiling on how many GitHub Actions jobs run at the same time, set by a workflow concurrency key, the account plan, or runner capacity. # Concurrency Limit A concurrency limit is a ceiling on how many GitHub Actions jobs run at the same time, set by a workflow concurrency key, the account plan, or runner capacity. ## [Definition](#definition)Copy Anchor Link A concurrency limit is a ceiling on how many GitHub Actions jobs are allowed to run at the same time. The ceiling can come from three separate places: the `concurrency` key written into a workflow, the concurrent job allowance attached to the account plan, or the number of runners available to accept the queued jobs. The phrase is used loosely, and that is where most of the confusion around it starts. Two engineers can both say "we are hitting a concurrency limit" and mean different mechanisms, with different symptoms and different fixes. ### [The three ceilings, separated](#the-three-ceilings-separated)Copy Anchor Link | Ceiling | Where it is set | What it counts | Typical symptom | | -------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | Workflow concurrency group | The concurrency key in workflow YAML, at the workflow level or the job level | Runs, or jobs, sharing one group name | A run waits with a banner naming the group, or flips to cancelled when a newer commit lands | | Account plan allowance | The plan attached to the account, with no configuration knob | Concurrent jobs on GitHub-hosted runners, counted across the whole account | The running job count plateaus at a fixed number while everything else queues | | Runner pool capacity | The size or the autoscaler maximum of the fleet behind a runs-on label | Machines able to accept a job carrying that label | Jobs on one label queue while jobs on other labels start immediately | The three ceilings count different units. The first counts runs inside a named group. The second counts jobs across an account. The third counts machines behind a label. Mixing those units is the reason a fix applied to the wrong ceiling changes nothing about the wait. ### [The workflow concurrency group](#the-workflow-concurrency-group)Copy Anchor Link The `concurrency` key is a throttle an engineer writes on purpose. It takes a group name, usually built from an expression, and an optional `cancel-in-progress` flag. Every run that resolves to the same group name shares one lane. A group holds at most two items: one running and one pending. When a third item arrives, GitHub cancels the item that was pending and parks the new arrival in its place. With `cancel-in-progress: true`, the arrival also cancels whatever was running. The key never makes anything start sooner. It cancels work or parks it, so the symptoms it produces are pending runs and cancelled runs rather than a long queue of jobs waiting on machines. Setting the key at the job level narrows the lane to that one job across runs, which is the usual shape for deployment jobs that have to serialize. ### [The account plan allowance](#the-account-plan-allowance)Copy Anchor Link Standard GitHub-hosted runners draw from shared pools with a documented ceiling on concurrent jobs. The ceiling follows the plan attached to the account, and macOS carries a lower sub-ceiling of its own. The figures below are from [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13. | Plan | Concurrent jobs | Concurrent macOS jobs | | ---------- | --------------- | --------------------- | | Free | 20 | 5 | | Pro | 40 | 5 | | Team | 60 | 5 | | Enterprise | 500 | 50 | Jobs above the ceiling queue until a slot frees. The count is account wide, so a large fan-out in one repository delays workflows in every other repository under the same account. The macOS sub-ceiling explains a pattern iOS teams see often: Linux jobs start right away while macOS jobs sit queued, because five macOS slots serve the entire account on the Free, Pro, and Team plans. Two more documented ceilings sit near this one and are worth keeping separate in your head. A single workflow run generates at most 256 jobs from a matrix. A job that has been queued for 24 hours waiting on a self-hosted runner is cancelled. Both figures are from the same usage limits reference, checked on 2026-08-13. ### [Runner pool capacity](#runner-pool-capacity)Copy Anchor Link Jobs that target self-hosted or managed labels sit outside the plan allowance. Their ceiling is the number of matching runners that can come online at once. For a static self-hosted fleet, that number is the count of registered machines. For an autoscaled fleet, it is the controller maximum, such as the maximum runner count configured on an Actions Runner Controller scale set. For a managed fleet, it is whatever capacity the provider keeps behind the label. One failure mode hides inside this category. A job whose labels match zero online runners waits forever, which reads as exhausted capacity and is really misrouting: a typo in a label, a runner registered to one repository while the workflow lives in another, or a runner group that excludes the repository. ## [Example](#example)Copy Anchor Link ### [A concurrency group cancelling an in-progress run](#a-concurrency-group-cancelling-an-in-progress-run)Copy Anchor Link The workflow below puts every run for a pull request into one lane keyed by the ref, and cancels the in-progress run when a newer commit arrives. Copy Text ``` name: pr-checks on: pull_request: concurrency: group: pr-checks-${{ github.ref }} cancel-in-progress: true jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: make test ``` Here is what happens on a pull request that gets two pushes close together. 1. At 09:00:00 a commit lands on the branch. Run 1 enters the group `pr-checks-refs/pull/482/merge` and starts, because the group is empty. 2. At 09:02:10 a second commit lands on the same branch. Run 2 resolves to the same group name. 3. Because `cancel-in-progress` is `true`, GitHub cancels run 1 where it stands. Run 1 ends with the conclusion `Cancelled`, part way through `make test`, and run 2 starts. Change one line and the behavior changes with it. With `cancel-in-progress: false`, which is the default, run 1 finishes and run 2 waits as the pending item in the group. A third push would then cancel the pending run 2 and park itself in that slot, because the group holds one running item and one pending item and no more. The group expression matters as much as the flag. `pr-checks-${{ github.ref }}` gives every pull request its own lane. A hard-coded name such as `group: ci` puts every branch and every workflow that copies the line into a single lane, which produces a queue that looks like a capacity problem and is a naming accident. ### [A ceiling nobody wrote down](#a-ceiling-nobody-wrote-down)Copy Anchor Link The second workflow writes no `concurrency` key at all. Its ceiling comes from the fan-out itself and from the machines behind the labels. Copy Text ``` name: test-matrix on: push: branches: [main] jobs: test: strategy: fail-fast: false matrix: runner: - warp-ubuntu-latest-x64-4x - warp-ubuntu-latest-arm64-4x - warp-windows-latest-x64-4x shard: [1, 2, 3, 4, 5, 6] runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - run: ./scripts/test.sh --shard=${{ matrix.shard }}/6 ``` Three runner labels multiplied by six shards produce 18 jobs per run. The labels in the matrix are managed runner labels taken from the WarpBuild runner catalog, and the mechanism is the same for GitHub-hosted labels or for self-hosted labels: the fan-out asks for 18 slots, and whichever ceiling is lowest decides how many of the 18 start together. Hold the work constant and vary only the slots. Assume each shard takes 5 minutes, shards are uniform, there are no retries, and a job starts the moment a slot frees. Total job time is 18 x 5 = 90 job-minutes in every case. Wave count is 18 divided by the available slots, rounded up. | Slots available | Waves | Wall clock per run | Job-minutes consumed | | --------------- | ----- | ------------------ | -------------------- | | 6 | 3 | 15 min | 90 | | 9 | 2 | 10 min | 90 | | 18 | 1 | 5 min | 90 | The arithmetic shows what a concurrency limit does and what it leaves alone. It sets wall clock time, so the engineer waiting on the pull request feels it directly. Total job time stays at 90 minutes across all three rows, so the metered work is identical whether the suite clears in one wave or three. Two knobs sit next to this. `max-parallel` under `strategy` caps a matrix below its natural size on purpose, which is how teams protect a rate-limited dependency such as a shared staging database. And the 256-job matrix cap is a hard stop: a matrix that expands past it fails to expand rather than queueing, so very wide matrices have to be split across workflows. ### [Reading the symptom back to the ceiling](#reading-the-symptom-back-to-the-ceiling)Copy Anchor Link * A waiting banner that names a group, or runs that flip to `Cancelled` when a newer commit lands, points at a workflow concurrency group. * A running job count that plateaus at 20, 40, 60, or 500 while the rest queue points at the plan allowance. * macOS jobs queueing while Linux jobs on the same account start points at the macOS sub-ceiling. * Jobs on one label queueing while other labels start points at pool capacity for that label. * Jobs on a label that never start at all point at misrouting or permissions rather than any ceiling. Measure before changing anything. Subtract each job's queued timestamp from its started timestamp in the workflow run API output to separate waiting from running, then group that wait by label and by hour. A wait that spikes during working hours and clears overnight behaves like contention for a shared ceiling. A wait that stays flat around the clock on one label behaves like a pool sized too small for that label. ## [Related Terms](#related-terms)Copy Anchor Link * [Diagnose and fix GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits): the symptom-to-mechanism table in full, plus the configuration that clears each of the three ceilings. * [How many GitHub Actions jobs can run at once](/answers/how-many-github-actions-jobs-can-run-at-once): the plan figures, the macOS sub-ceiling, and how the count works for self-hosted labels. * [Build matrix](/glossary/build-matrix): the workflow feature that turns one job definition into many, and the 256-job cap that bounds it. * [Diagnose and fix GitHub Actions queue times](/guides/github-actions-queue-times): how to measure the gap between queued and started, and what each shape of that gap means. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner labels used in the matrix example above, with the image and machine details behind each one. * [Job and queue timing reports](/docs/ci/features/reports): where queue wait is reported per runner label, with percentiles and CSV export. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a concurrency limit in GitHub Actions? A concurrency limit is a ceiling on how many jobs are allowed to run at the same time. Three separate ceilings carry that name: the concurrency key written into a workflow, the concurrent job allowance attached to the account plan, and the number of runners available to accept jobs for a given runs-on label. ### Does the concurrency key limit how many jobs run in parallel? No. The concurrency key throttles workflow runs, or single jobs when it is set at the job level, within a named group to one running item plus one pending item. Parallel jobs inside a single run are governed by the plan allowance and by runner pool capacity instead. ### How many GitHub Actions jobs can run at the same time? On GitHub-hosted runners the ceiling follows the plan: 20 concurrent jobs on Free, 40 on Pro, 60 on Team, and 500 on Enterprise, with a lower macOS sub-ceiling of 5 jobs on Free, Pro, and Team and 50 on Enterprise. Figures from GitHub's usage limits reference, checked on 2026-08-13. ### Is a concurrency limit the same thing as queue time? Queue time is the symptom and a concurrency limit is one of its causes. Queue time is the gap between the moment a job is queued and the moment a runner starts it, and that gap grows whenever a ceiling holds the job back, whenever no runner matches the labels, or whenever the fleet is busy. --- # Container Registry | WarpBuild URL: https://www.warpbuild.com/glossary/container-registry Description: A container registry stores and serves container images and their manifests over the registry API, addressing every layer and config blob by its digest. # Container Registry A container registry stores and serves container images and their manifests over the registry API, addressing every layer and config blob by its digest. A container registry is a service that stores and serves container images and their manifests over the registry API, an HTTP interface that clients such as `docker`, `podman`, `crane`, and `containerd` all speak. An image in a registry is a set of content addressed blobs plus a manifest listing which blobs belong to it, so a push uploads those parts and a pull fetches whichever parts the local store is missing. That shape explains most registry behavior. A pull of a six layer image writes six blobs, a re-tag inside one registry moves zero layer bytes, and a rebuild that changes one line at the end of a Dockerfile uploads one new layer while the rest of the image is already stored. ## [Definition](#definition)Copy Anchor Link A container registry implements the [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec/blob/main/spec.md), the standard that fixes the URL shapes, the media types, and the status codes a registry serves. The specification defines two object types and one grouping for them. **Blobs** are opaque byte streams addressed by their digest, written as `sha256:` followed by 64 hex characters. Layer tarballs and the image config JSON are both blobs. Because the name is the hash of the content, a client can verify a downloaded blob without trusting the connection, and two repositories that share a base layer share one stored copy of it. **Manifests** are JSON documents listing the config blob and the layer blobs of one image, in order, each entry carrying a media type, a size in bytes, and a digest. A manifest is itself addressed by the digest of its bytes. An image index (also called a manifest list) is a manifest whose entries are other manifests annotated with `os` and `architecture`, which is how one tag serves `linux/amd64` and `linux/arm64` from a single reference. **Repositories** group the objects and give them a name. A full reference reads `host[:port]/owner/repository[:tag|@digest]`, and the host is omitted only when the client has a default configured. Repository path components are restricted to lowercase letters, digits, and separators by the specification grammar, so an organization or repository name with capitals has to be lowercased before it becomes part of a reference. ### [The API surface](#the-api-surface)Copy Anchor Link Every registry serves the same small set of routes. The table below lists the ones a build and deploy sequence touches. | Method and path | Purpose | | --------------------------------------------------------- | ----------------------------------------------------------------------- | | GET /v2/ | Version check and authentication probe | | HEAD /v2//blobs/ | Ask whether a blob is already stored before uploading it | | POST /v2//blobs/uploads/ | Open an upload session and receive a session URL | | PUT /v2//blobs/uploads/?digest= | Finish an upload and commit the blob under its digest | | POST /v2//blobs/uploads/?mount=&from= | Link a blob that another repository on the same registry already stores | | PUT /v2//manifests/ | Publish the manifest, which makes the image resolvable | | GET /v2//manifests/ | Resolve a reference to a manifest | | GET /v2//blobs/ | Download one blob | | GET /v2//tags/list | List the tags in a repository | The ordering matters in both directions. A push checks each blob with `HEAD`, uploads only the missing ones, and writes the manifest last, so a reference never resolves to an image whose layers are still in flight. A pull resolves the manifest first, then requests only the blobs the local content store lacks, which is why the second pull of a slightly changed image transfers far less than the first. ### [Authentication](#authentication)Copy Anchor Link The distribution specification leaves credential handling to the registry and standardizes the handshake around it. A client sends a request without credentials, receives `401 Unauthorized` with a `WWW-Authenticate: Bearer` header naming a realm, a service, and a scope such as `repository:acme/api:pull,push`, exchanges its credentials at the realm for a bearer token carrying exactly that scope, and retries the original request ([registry token authentication](https://distribution.github.io/distribution/spec/auth/token/), checked on 2026-08-13). Tokens are short lived. The durable credential sits in `~/.docker/config.json` or in a credential helper on the machine, which is why a job on a machine created for that job logs in before its first pull. ### [What a pull moves](#what-a-pull-moves)Copy Anchor Link Bytes on the wire equal the sum of the compressed sizes of the blobs the client does not already hold, plus a manifest of a few kilobytes. Layers already present on the machine cost one `HEAD` each. Storage sits behind the registry, and reading it may cross a network boundary that the storage provider meters, which is covered on the [egress costs page](/glossary/egress-costs). ## [Example](#example)Copy Anchor Link This workflow builds an image, pushes it to a private registry, and hands the resulting digest to a second job that pulls the same bytes back. The registry here is GitHub's, and the `packages` permission on `GITHUB_TOKEN` is what authorizes the push ([working with the container registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry), checked on 2026-08-13). Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write outputs: digest: ${{ steps.push.outputs.digest }} steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: push uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} deploy: needs: build runs-on: ubuntu-latest permissions: contents: read packages: read steps: - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - run: docker pull ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }} ``` Four details in that file are worth reading closely. The `permissions` block is asymmetric on purpose. The build job asks for `packages: write` because it publishes a manifest, and the deploy job asks for `packages: read` because it only resolves and downloads. `GITHUB_TOKEN` is scoped to the repository that started the run, so a workflow pulling an image published by a different repository needs a personal access token or a GitHub App installation token instead ([automatic token authentication](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication), checked on 2026-08-13). The push writes a tag, and the deploy reads a digest. `build-push-action` exposes the manifest digest as a step output, so the deploy job pins the exact bytes the build produced even if another run retags `main` in between. The reference is lowercased by the repository name. `github.repository` reproduces the owner and repository casing from GitHub, and a name containing capitals fails the repository grammar, so workflows in such repositories pass the name through a lowercasing step before building the tag. Here is what the build job puts on the wire for a three layer image whose base layers are already stored in that registry: | Step | Request | Result | | ---- | -------------------------------------------------------------------------- | --------------------------------------------- | | 1 | GET /v2/ | 401 with a WWW-Authenticate: Bearer challenge | | 2 | Token exchange at the realm with scope repository:/:push,pull | Bearer token | | 3 | HEAD /v2///blobs/ for each of the 4 blobs | 3 present, 1 missing | | 4 | POST then PUT for the missing application layer | Blob committed | | 5 | PUT /v2///manifests/ | Tag now resolves | Only step 4 transfers meaningful bytes. The deploy job then runs steps 1 and 2 again with a `pull` scope, resolves the manifest by digest, and downloads whichever of the four blobs its local store lacks. ## [Related Terms](#related-terms)Copy Anchor Link * [How to authenticate to a private registry in GitHub Actions](/answers/how-do-i-authenticate-to-a-private-registry-in-github-actions): the credential options for a workflow job, and which one survives a token expiry mid run. * [Egress costs and the network boundaries image bytes cross](/glossary/egress-costs): what a cloud provider meters when a pull leaves a region or a network. * [Publishing one image to several registries from one workflow](/guides/multi-registry-publishing-github-actions): tagging one build for multiple hosts and keeping the digests aligned. * [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec/blob/main/spec.md): the upstream reference for the routes, media types, and status codes above. * [Registry token authentication](https://distribution.github.io/distribution/spec/auth/token/): the bearer token handshake in full, including scope grammar. * [WarpBuild Docker builders documentation](/docs/ci/docker-builders): builder profiles and the actions that build and push an image from a workflow. * [WarpBuild BYOC AWS configuration documentation](/docs/ci/byoc/aws/config): registry endpoint requirements when runners sit in your own AWS account. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What does a container registry actually store? Two kinds of object. Blobs are opaque byte streams named by their sha256 digest, and they hold the layer tarballs and the image config JSON. Manifests are small JSON documents that list the config blob and the layer blobs belonging to one image, in order, with a media type and a size for each. A tag is a mutable label pointing at one manifest digest. ### What is the difference between a tag and a digest? A digest is the sha256 hash of the manifest bytes, so a digest reference always resolves to the same image. A tag is a name the registry lets a push reassign, so the same tag can point at different content in two runs. Referencing an image by digest in a deploy step removes the drift between what a build produced and what a deploy pulled. ### Why does a workflow have to log in to a registry on every run? Registry credentials live in the client's config.json or in a credential helper on the machine running the pull. A workflow job that starts on a fresh machine has an empty config, so the login step runs again to exchange credentials for a short lived bearer token scoped to the repository and the action requested. --- # Control Plane | WarpBuild URL: https://www.warpbuild.com/glossary/control-plane Description: A control plane is the component that decides what should exist and issues the instructions that create it, while the workload runs elsewhere on the data plane. # Control Plane A control plane is the component that decides what should exist and issues the instructions that create it, while the workload runs elsewhere on the data plane. A control plane is the part of a system that decides what should exist and issues the instructions that make it exist, without carrying the workload traffic itself. The components that carry the work are the data plane, and separating the two is what lets a scheduling failure and a workload failure be different events. The term comes from network equipment and now describes any system where placement decisions are kept apart from execution. ## [Definition](#definition)Copy Anchor Link A control plane holds three things: a description of desired state, a view of actual state, and an authenticated path to an API that can change actual state. It runs one loop: read what should exist, read what does exist, and issue calls until the two agree. Nothing in that loop requires the control plane to touch the payload. | Question | Control plane | Data plane | | ------------------------ | --------------------------------------------------- | ----------------------------------------------------- | | What it holds | desired state, configuration, credentials, metadata | the workload itself: processes, packets, build output | | What it does | decides, schedules, reconciles, retries | executes, forwards, stores | | What it talks to | provider APIs, queues, its own database | the workload's own dependencies | | What stops when it stops | new placements, scaling, cleanup | everything currently running | | What it scales with | the number of objects under management | the amount of work | Two familiar systems show the split clearly. A router computes routing tables from protocols such as BGP or OSPF in its control plane, and its forwarding path moves packets according to the tables already installed. Restarting the routing process does not drop traffic that the installed tables already know how to forward. Kubernetes places the API server, the scheduler, and the controller manager on the control plane side, and the kubelet plus a container runtime on each node on the workload side ([Kubernetes cluster components](https://kubernetes.io/docs/concepts/overview/components/), checked on 2026-08-13). The scheduler decides which node runs a pod. The kubelet on that node starts the container and keeps it running. Pods that are already running survive an API server outage, while a pod that needs a new node stays pending. The direction of the connection matters as much as the division of duties. A control plane usually initiates its connections outward: it calls a cloud provider API, a source control API, or a queue, and it accepts no inbound connections from the machines it manages. Machines dial out too, to register themselves and to report status. The practical result is that a control plane needs credentials rather than network reachability, and the addresses it dials from are a small stable set that a network team can put in a firewall rule. That asymmetry sets the trust boundary. A control plane is trusted to create, size, and destroy capacity, which is significant authority, while the data flowing through the workload can stay outside its reach entirely. Reviewing such a system means asking two separate questions: what the control plane is permitted to do, and what it is able to read. ## [Example](#example)Copy Anchor Link In GitHub Actions, a runner platform's control plane is the component that watches for queued jobs and creates machines to serve them, while the build runs on a machine somewhere else. The workflow file names no plane at all. It names labels: Copy Text ``` name: build on: push: branches: [main] jobs: compile: runs-on: [self-hosted, linux, x64] steps: - uses: actions/checkout@v4 - run: make build ``` When that push lands, the following happens in order, and the two planes take alternating turns: | Step | Which plane | What happens | | ---- | ----------- | -------------------------------------------------------------------------------------------------------- | | 1 | control | The platform observes a queued job carrying the labels self-hosted, linux, and x64. | | 2 | control | It compares queue depth against machines already running and decides one more is needed. | | 3 | control | It calls the cloud provider API with a granted role and launches an instance. | | 4 | data | The instance boots, starts the runner agent, and registers with GitHub under those labels. | | 5 | data | The agent claims the job, checks out the repository, runs make build, and streams logs to GitHub. | | 6 | control | After the job reports its conclusion, the platform terminates the instance and records the minutes used. | Steps 1 through 3 and step 6 involve no repository content. Steps 4 and 5 carry all of it: the checkout, the compiler output, any cache the build reads or writes, and the log lines. The control plane sees a job identifier, a label set, a machine size, and lifecycle timestamps. The split is visible during a partial failure. Once the agent has claimed the job in step 4, that build finishes even if the control plane is unreachable, because the agent holds its own connection to GitHub and reports there directly. What breaks is step 2 for the next push: the job sits in the queue showing "Waiting for a runner to pick up this job" and produces no logs. GitHub cancels a job that has waited 24 hours for a self-hosted runner ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), so a stalled placement surfaces as a cancellation a day later rather than as an immediate error. The same split decides where data lives when the machines run inside a customer's own cloud account. The instances, the disks, the caches, and the artifacts sit in that account, and the component that launches them sits outside it and reaches in through a granted role. The [AWS BYOC architecture reference](/docs/ci/byoc/aws/architecture) draws the resource graph for that arrangement, and the [runner security documentation](/docs/ci/security) covers the isolation model applied to each machine. ## [Related Terms](#related-terms)Copy Anchor Link * [Data plane, the components that carry the workload itself](/glossary/data-plane): the execution side of the split, and what it holds during a build. * [BYOC runners on AWS, with machines inside your own account](/byoc/aws): what the two planes own when the instances belong to your cloud account. * [Control plane egress IPs and how to allowlist them](/byoc/control-plane-egress-ips): the addresses a control plane dials out from, and how a firewall rule is scoped to them. * [AWS BYOC architecture reference](/docs/ci/byoc/aws/architecture): the VPC, subnet, and endpoint graph behind an account-resident data plane. * [Runner security documentation](/docs/ci/security): compute isolation, storage protection, and the control plane egress address list. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a control plane in simple terms? A control plane is the part of a system that decides what should exist and issues the instructions that make it exist. It holds a description of desired state, watches actual state, and calls an API to close the gap. The workload traffic travels somewhere else, so the control plane can be unavailable while the work it already placed keeps running. ### What is the difference between a control plane and a data plane? The control plane decides and instructs. The data plane executes and carries. In a router the control plane computes routing tables while the forwarding path moves packets. In Kubernetes the scheduler decides which node runs a pod while the kubelet and container runtime run it. The two planes fail independently, which is why a scheduling outage and a workload outage are separate events. ### What happens to running work when a control plane goes down? Work that has already been placed usually continues, because the machine executing it holds its own connection to whatever it reports to. What stops is anything that needs a new decision: new placements, scaling up, and cleanup of finished capacity. The visible symptom is a queue that grows while everything already running finishes normally. --- # Cross Compilation | WarpBuild URL: https://www.warpbuild.com/glossary/cross-compilation Description: Cross compilation is compiling on one architecture to produce a binary that runs on another. Target triples, sysroots, and a GitHub Actions example. # Cross Compilation Cross compilation is compiling on one architecture to produce a binary that runs on another. Target triples, sysroots, and a GitHub Actions example. Cross compilation is compiling source on a machine of one architecture to produce a binary that runs on a different architecture, using a toolchain configured to emit code for that other target. An x86-64 build machine can therefore produce an ARM64 executable that the build machine itself is unable to run. In GitHub Actions this appears whenever a workflow ships artifacts for more than one architecture or operating system. A single job on a single runner can emit several targets, because the compiler picks the output architecture from a flag rather than from the hardware underneath it. ## [Definition](#definition)Copy Anchor Link Toolchain documentation still uses the three machine roles the GNU build system established: * **Build**: the machine that compiled the toolchain itself. * **Host**: the machine that runs the compiler. In GitHub Actions the host is the runner. * **Target**: the machine that runs the compiler's output. A native build puts host and target on the same architecture. A cross build separates them, and everything else about cross compilation follows from that separation. A working cross toolchain has four parts. A missing part is where most cross builds fail: 1. A compiler backend that emits instructions for the target instruction set. 2. A linker and binutils that understand the target object format. 3. A sysroot holding the target's headers and libraries, so includes resolve and the link step finds the libc for the target ABI. 4. A standard library or runtime compiled for the target, in languages that ship one. The target is named by a target triple, conventionally written `arch-vendor-os-abi` even though the field count varies by toolchain. `aarch64-unknown-linux-gnu` asks for 64-bit ARM instructions, a Linux kernel interface, and the GNU C library ABI. Changing the final field to `musl` changes the libc the binary links against and yields a different artifact from identical source. Rust publishes its full list on the [platform support page](https://doc.rust-lang.org/rustc/platform-support.html), and GCC documents the naming convention under [configure terms](https://gcc.gnu.org/onlinedocs/gccint/Configure-Terms.html). Every mainstream toolchain exposes the target as a build-time selector: | Toolchain | How the target is selected | Example value | | --------- | ------------------------------------------------------- | ------------------------- | | Go | GOOS and GOARCH environment variables | GOOS=linux GOARCH=arm64 | | Rust | \--target plus a std library added by rustup target add | aarch64-unknown-linux-gnu | | Clang | \--target plus \--sysroot | aarch64-linux-gnu | | GCC | a target-prefixed driver binary | aarch64-linux-gnu-gcc | | .NET | \--runtime, or the RuntimeIdentifier property | linux-arm64 | | Zig | \-target | aarch64-linux-gnu | Cross compilation and emulation solve the same problem from opposite ends. A cross compiler writes out target instructions while every process on the machine stays native, so the build executes at host speed and no target code ever runs. An emulator translates target instructions at run time, which lets a native toolchain and a test suite for the target execute on host hardware at a translation cost. The second path is covered in [QEMU emulation](/glossary/qemu-emulation). A green cross build proves that the source compiled and linked for the target triple. Nothing executed the output, so run-time behavior stays unverified. Weak memory ordering on aarch64, the unsigned default for `char` on ARM64, and a sysroot glibc newer than the deployment target are invisible to the compiler and surface the first time the binary runs on real target hardware. ## [Example](#example)Copy Anchor Link This workflow cross compiles a Rust binary for 64-bit ARM Linux on an x86-64 runner. The target triple is stated explicitly on the `cargo build` line, and the job never executes the artifact it produces. Copy Text ``` name: release on: push: tags: ["v*"] jobs: build-arm64: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: rustup target add aarch64-unknown-linux-gnu - run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu - run: cargo build --release --target aarch64-unknown-linux-gnu env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - uses: actions/upload-artifact@v4 with: name: app-aarch64 path: target/aarch64-unknown-linux-gnu/release/app ``` Each step supplies one part of the toolchain listed above: * `rustup target add` downloads a standard library precompiled for `aarch64-unknown-linux-gnu`. * The `gcc-aarch64-linux-gnu` package installs the cross linker and the target sysroot from the Ubuntu archive. * `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER` points Cargo at that linker, because the host `cc` is unable to link aarch64 objects. * `--target` selects the code generation backend and the ABI. The artifact lands under `target/aarch64-unknown-linux-gnu/release/`, and `file` identifies it as an aarch64 object rather than an x86-64 one: Copy Text ``` app: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked ``` Running that binary on the same runner returns `cannot execute binary file: Exec format error`, which is the expected result and the clearest sign that the cross build worked. Go reaches the same output with two environment variables and no extra packages, because the Go toolchain ships every target it supports: Copy Text ``` - run: GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o app-arm64 ./cmd/app ``` Setting `CGO_ENABLED=1` puts that build back on a C toolchain, so a `CC` variable pointing at a cross compiler becomes necessary again. The same rule governs Python wheels with native extensions, Node addons built by node-gyp, and any JNI library in a JVM project. Runner images differ in which cross toolchains they carry, so a workflow either installs the packages it needs in a step or selects an image that already ships them. [Preinstalled software](/docs/ci/preinstalled-software) lists the toolchains on each image, and [cloud runners](/docs/ci/cloud-runners) lists the labels a workflow can request. ## [Related Terms](#related-terms)Copy Anchor Link * [Cross compiling compared with native ARM64 builds](/guides/cross-compiling-vs-native-arm64-builds): when a cross-compiled artifact is enough, and when a job has to run on ARM64 hardware. * [QEMU emulation in Docker builds](/glossary/qemu-emulation): executing target instructions on the host instead of compiling for the target. * [Building a Rust binary for multiple targets](/answers/how-do-i-build-a-rust-binary-for-multiple-targets): a matrix that produces one artifact per target triple. * [Runner labels available to a workflow](/docs/ci/cloud-runners): the runner types a `runs-on` label can select. * [WarpBuild pricing](/pricing): per minute rates by runner type. --- # Data Plane | WarpBuild URL: https://www.warpbuild.com/glossary/data-plane Description: A data plane is the set of components that carry the actual workload, while the control plane beside it decides what should exist and schedules work onto them. # Data Plane A data plane is the set of components that carry the actual workload, while the control plane beside it decides what should exist and schedules work onto them. A data plane is the set of components that carry the actual workload: the machines, processes, and network paths that work passes through on its way to a result. The control plane sits beside it and decides what should exist and where work should go, without carrying the work itself. The split describes responsibility rather than hardware. Two processes on one machine can belong to different planes, and a single plane can span several accounts, regions, and providers. ## [Definition](#definition)Copy Anchor Link A component belongs to the data plane when it sits on the path of the work. Remove it and work already in flight stops. Remove a control plane component and existing work continues on whatever was already placed, until something needs to change. Three properties follow from that position. **It scales with volume.** Data plane capacity is sized against how much work arrives: requests per second, packets per second, or, in a build system, concurrent jobs. Control plane capacity is sized against the rate of change, which is usually smaller by orders of magnitude. A fleet running 500 concurrent jobs needs 500 machines and one scheduler. **Its failures are immediate.** A data plane outage stops work that is running. A control plane outage stops new decisions while placed work continues, a property AWS describes as [static stability](https://aws.amazon.com/builders-library/static-stability-using-availability-zones/). Systems are usually designed so the data plane keeps serving through a control plane outage. **It holds the workload's own material.** Source code, secrets, intermediate files, and build outputs all pass through the data plane, so a question about where those things live is a question about where the data plane runs. The control plane holds descriptions instead: identifiers, labels, sizes, timestamps, and the record of what should exist. | Dimension | Control plane | Data plane | | ------------------- | ------------------------------------------------- | ------------------------------------------- | | Job | Decides what should exist and issues instructions | Executes the work and moves the bytes | | Sized against | Rate of change, such as decisions per minute | Volume of work, such as concurrent jobs | | Effect of an outage | New changes stall, placed work continues | Work in flight stops | | Holds | Identifiers, labels, configuration, desired state | The workload's inputs, secrets, and outputs | | Usual placement | Central, operated once for many workloads | Wherever the work has to run | The vocabulary comes from network routing. Inside a router, the control plane runs the routing protocols that build the forwarding table, and the data plane, also called the forwarding plane, moves packets according to that table at line rate. Kubernetes reuses the same division: the API server and the scheduler decide which node should run a pod, and the kubelet with the container runtime on that node runs it. ### [Where the boundary is drawn](#where-the-boundary-is-drawn)Copy Anchor Link The boundary is a design choice, and the same system can be cut at different points. A managed database can place query execution and storage in one account while the provisioning and upgrade logic runs elsewhere. A build platform can place the queue watcher and the machine lifecycle logic outside a customer account while every compiler process runs inside it. Two questions settle where the line falls for any given system. Which components would a running workload notice the loss of within seconds, and which components does the workload's data physically pass through. Everything answering yes to either question is data plane. ### [Why placement is a data plane question](#why-placement-is-a-data-plane-question)Copy Anchor Link Because the data plane carries the work, the decisions that follow from placement attach to it: which region the machines sit in, which account owns them, which network they join, and which quotas they consume. A team can therefore adopt scheduling operated by someone else while keeping execution on machines it controls, and the reverse arrangement is equally possible. ## [Example](#example)Copy Anchor Link GitHub Actions splits along this line by default. GitHub holds the queue, evaluates workflow triggers, and matches the labels in `runs-on` against online runners, which is control plane work. The runner machine performs the steps, which is data plane work. Copy Text ``` name: build on: push: branches: [main] jobs: package: runs-on: [self-hosted, linux, x64] steps: - uses: actions/checkout@v4 - run: make build - run: make package - uses: actions/upload-artifact@v4 with: name: dist path: dist/ ``` The `runs-on` line is where the boundary appears in the file. Everything above it describes a decision made outside the machine. Everything below it happens on the machine that claims the job. Now place that runner on an instance inside a cloud account you own, with the scheduling loop running outside the account. One job then reads like this: | Step | Where it happens | Plane | | ------------------------------------------------------------------------ | ------------------- | ------------- | | A push creates a workflow run and queues the package job | GitHub | Control plane | | A scheduler sees the queued job and calls the cloud API for a machine | Outside the account | Control plane | | The instance boots in the account's VPC and registers with GitHub | Inside the account | Data plane | | actions/checkout clones the repository onto the instance disk | Inside the account | Data plane | | make build compiles and writes to the instance disk and the cache bucket | Inside the account | Data plane | | Step logs and the job conclusion stream back to GitHub | Across the boundary | Both | | The instance is terminated after the job finishes | Inside the account | Data plane | The source tree, the compiler, the secrets exposed to the step environment, and the produced artifact stay on the instance. What crosses the boundary is the instruction to create a machine, the log stream, and the conclusion of the job. The runner agent opens an outbound connection and waits for work, so the machine needs no inbound path for any of this to function. One documented arrangement of that shape places a VPC with public and private subnets, a security group attached to every runner instance, an object storage bucket for caches and logs, and per-job instances inside the account, while the scheduling loop stays outside it. The resource graph is drawn in the [BYOC AWS architecture reference](/docs/ci/byoc/aws/architecture), and the three setup steps for a cloud account are listed in the [bring-your-own-cloud documentation](/docs/ci/byoc). Sizing follows the plane it belongs to. Because data plane capacity tracks concurrent work, the account quotas that matter are the ones counted per job: instance count, attached volumes, and one elastic IP for each concurrently running job, plus three for the NAT gateways in three availability zones. The AWS guide in that same documentation lists them per stack. The scheduler outside the account needs none of those numbers to grow, because its work grows with the number of decisions rather than the number of machines. ## [Related Terms](#related-terms)Copy Anchor Link * [Control plane, the side that decides what should exist](/glossary/control-plane): the scheduling and reconciliation half of the split, and why its outages look different. * [GitHub Actions runners inside your own AWS account](/byoc/aws): how a runner data plane is placed in an AWS account you own, including the IAM surface the scheduler uses. * [What gets created in your cloud account](/answers/what-does-warpbuild-create-in-my-cloud-account): the resource list, naming convention, and tags that make the data plane identifiable in a shared account. * [Bring-your-own-cloud documentation](/docs/ci/byoc): connecting a cloud account, creating a stack, and configuring the runners that execute jobs. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a data plane? A data plane is the set of components that carry the actual workload: the machines, processes, and network paths the work passes through on its way to a result. A component belongs to the data plane when removing it stops work that is already in flight. ### What is the difference between a control plane and a data plane? The control plane decides what should exist and issues instructions. The data plane executes. Control plane capacity is sized against the rate of change, data plane capacity against the volume of work, and an outage on each side looks different: losing the control plane stalls new decisions while running work continues, and losing the data plane stops the work itself. ### Where is the data plane in a GitHub Actions workflow? The runner is the data plane. GitHub queues the job and matches the labels in runs-on, and the runner machine performs the checkout, compiles the code, holds the secrets in the step environment, and writes the artifacts. The runs-on line is where the boundary between the two planes appears in the workflow file. --- # Data Residency | WarpBuild URL: https://www.warpbuild.com/glossary/data-residency Description: Data residency is the requirement that data is stored and processed inside a defined geography. What it means for a GitHub Actions build, and how to prove it. # Data Residency Data residency is the requirement that data is stored and processed inside a defined geography. What it means for a GitHub Actions build, and how to prove it. Data residency is the requirement that a defined set of data is stored and processed inside a named geography, such as a country, an economic area, or a single cloud region. The requirement comes from a statute, a customer contract, or an internal policy, and it covers the machines that process the data as well as the disks that hold it. For a GitHub Actions workload the question lands on the build itself. A job checks out source code, writes caches, uploads artifacts, and streams logs, and each of those steps puts data somewhere that a reviewer will ask about. ## [Definition](#definition)Copy Anchor Link A residency requirement has three parts, and a requirement missing any of them is ambiguous enough to argue about later. 1. **The data in scope.** Personal data about customers, regulated records, source code, or everything the company holds. Scope drives everything downstream, because a rule written for customer records may or may not reach a build cache that contains a compiled binary. 2. **The geography.** A country, an economic area such as the European Economic Area, a list of approved countries, or one named cloud region. Granularity matters: "the EU" and "eu-central-1" are different promises, and only one of them survives a region outage without renegotiation. 3. **The operations covered.** Storage at rest is the common floor. Stronger requirements add processing, backups, disaster recovery copies, and remote access by staff sitting outside the geography. The third part is where build systems get caught. A rule that names storage alone is satisfied by a bucket in the right region. A rule that names processing reaches the compute too, which means the virtual machine that untars a repository and runs a compiler has to sit inside the geography as well. Requirements of this shape usually originate in one of four places. Data protection law is the most cited: the GDPR does not mandate EU-only storage in general terms, and instead restricts transfers of personal data outside the European Economic Area unless a transfer mechanism applies. Many teams answer that restriction by keeping the data in region, which is how a legal transfer question turns into an infrastructure requirement. The other three sources are customer contracts with a data processing addendum that names a region, public sector procurement rules that require in-country hosting, and internal policy written by a security team that wants one answer for every auditor. Regulatory framework names describe the concept here and carry no statement about any vendor's compliance posture. ### [Residency, sovereignty, and localization](#residency-sovereignty-and-localization)Copy Anchor Link Buyers use these three words interchangeably, and they mean different things. Getting the distinction straight early saves a review cycle, because the evidence each one wants is different. | Term | What it constrains | The question it answers | Evidence usually requested | | ----------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Data residency | The physical location where data is stored and processed | Where does this data sit while we work on it? | Region configuration, storage bucket locations, list of subprocessors and their regions | | Data sovereignty | Which legal system reaches the data, and who operates it | Whose law applies, and who can be compelled to produce it? | Contract terms, jurisdiction of the operating entity, key custody and support staff location | | Data localization | A statutory duty to keep a copy inside a country, sometimes with limits on export | Are we permitted to move this data out at all? | Statutory citation and proof that an in-country copy exists | Residency is a placement question and it is answered with configuration. Sovereignty is a legal question and it is answered with contracts and corporate structure. A workload can sit entirely on machines inside a geography while the company operating those machines answers to a different jurisdiction, which satisfies residency and leaves sovereignty open. Localization is the strictest of the three, because it can forbid the export that residency merely discourages. ### [Control plane and data plane](#control-plane-and-data-plane)Copy Anchor Link Any managed build platform splits into two halves, and a residency answer has to cover both. The data plane holds the material a build touches: the source checkout, dependency caches, compiled artifacts, container image layers, and job logs. The control plane holds the records that make scheduling work: which job was queued at what time, which machine claimed it, how long it ran, usage counters for billing, and audit events. Reviewers accept control plane metadata crossing a boundary far more often than they accept data plane content crossing it, and the two get conflated in vendor answers. Write the answer as two sentences, one per plane, and name what each holds. ### [Adjacent controls that residency does not replace](#adjacent-controls-that-residency-does-not-replace)Copy Anchor Link Three controls sit next to residency and get substituted for it during reviews. **Encryption** limits who can read data. Encrypted bytes in the wrong geography are still in the wrong geography, so encryption answers confidentiality and leaves placement unanswered. **Access control** limits who can reach a system. A team in another country with production access is a common finding in residency reviews even when every machine sits in the right region. **Retention** limits how long data survives. Short retention shrinks exposure and lowers the stakes of a placement answer without changing it, which is why ephemeral machines make residency conversations easier. ## [Example](#example)Copy Anchor Link A single GitHub Actions run produces several distinct classes of data, and a residency review asks about each class separately. The table below lists the classes that come up in almost every review, what creates them, and where they typically land. | Data class | Created by | Where it typically lands | What the reviewer asks | | -------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------- | | Source checkout | actions/checkout | The working directory on the runner's disk | Where is the runner machine, and is the disk destroyed when the job ends? | | Dependency and build cache | actions/cache and language specific cache actions | An object storage bucket owned by the cache backend | Which region holds the bucket, and how long do entries live? | | Build artifacts | actions/upload-artifact | The platform's artifact store, or a bucket you name | Which store, which region, what retention? | | Container images | docker build and docker push steps | The registry named in the workflow | Which registry region, and who is allowed to pull? | | Job logs | Every step's stdout and stderr | Streamed to GitHub and kept with the run record | Where do logs live, how long, and who can read them? | | Secrets | Repository, environment, or organization secrets | Held by the secret store, delivered into the job environment at run time | Where does the secret store sit, and who can read the values? | | Run metadata | The platform and the runner agent | Queue records, machine assignment, timings, usage counters | Is metadata in scope for our requirement? | Here is a workflow that produces four of those classes in five steps. The `runs-on` label picks the machine, which makes it the one line in the file that decides where the compute half of the answer lands. Copy Text ``` name: build on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 with: path: ~/.cache/go-build key: go-build-${{ hashFiles('go.sum') }} - run: go build ./... - run: go test ./... - uses: actions/upload-artifact@v4 with: name: server path: bin/server ``` Read that file top to bottom as a residency reviewer would. The checkout step writes the full repository to the runner's disk, so the physical location of the runner is the first answer owed. Managed runner labels of this shape encode the operating system, the architecture, and the machine size, and the fleet behind the label decides the region, so the label alone does not tell a reviewer the geography. That answer comes from the fleet configuration. The cache step writes to a bucket owned by whichever cache backend the fleet is wired to. That bucket is a separate location from the runner and needs its own region answer, and cache entries outlive the job, which is why retention comes up here rather than at checkout. The upload step writes the compiled binary to an artifact store with its own location and its own retention window. Logs from the two `run` steps stream to GitHub and are kept with the run record. Secrets, if the workflow used any, would be delivered from the secret store into the job environment. So one workflow file, five steps, and four storage locations under as many as three operators. Only one of those locations is chosen inside the workflow file, and the rest come from platform and fleet configuration that no reviewer can read off the YAML. ### [The questions a residency review actually asks](#the-questions-a-residency-review-actually-asks)Copy Anchor Link Turning the table above into a review checklist gives seven questions. A complete answer names a place for each one. 1. **Scope and geography.** Which data classes are in scope, and what is the geography, stated at the granularity the requirement uses. 2. **Compute location.** Where do runner machines execute, and does that hold for every job, including matrix legs and reusable workflows called from other repositories. 3. **Storage locations.** Where does each of the cache bucket, artifact store, log store, and container registry sit. 4. **Lifetime.** How long each of those stores keeps data, and whether the runner disk is destroyed when the job finishes. 5. **Access from outside.** Who can reach any of those systems from outside the geography, including support staff and automated tooling. 6. **Metadata.** Which control plane records leave the geography, and whether the requirement covers them. 7. **Evidence.** What artifact proves each answer: a region setting in a console, an infrastructure definition in version control, a bucket policy, an audit report, or a contract clause. Question two is the one that changes most often in practice, because it moves with the runner fleet a workflow points at. A job on a hosted pool executes wherever the platform's pool for that label lives. A job on a self-managed fleet executes in the account and region that created the machines, which is why teams with strict requirements often run builds inside their own cloud account and answer questions two through five from their own infrastructure definitions. Question six catches teams late. A build platform that starts and stops machines has to keep records of what it started, and those records are metadata rather than build content. Deciding early whether the requirement reaches metadata prevents a rewrite of the answer during the review. ## [Related Terms](#related-terms)Copy Anchor Link * [BYOC, the model where runners boot inside your own cloud account](/glossary/byoc): the model that puts compute and storage placement under your own account and region choices. * [EU data residency for GitHub Actions runners](/enterprise/data-residency-eu): how the paths above apply to an EU footprint, with the evidence a review collects. * [US data residency for GitHub Actions runners](/enterprise/data-residency-us): the same walkthrough for a US footprint. * [Where GitHub Actions runners actually run](/answers/where-do-github-actions-runners-run): what decides the machine a job lands on and how to find out. * [WarpBuild BYOC documentation](/docs/ci/byoc): how a cloud account connection, a stack, and a custom runner fit together, including the region and bucket choices made at stack creation. * [WarpBuild security documentation](/docs/ci/security): compute isolation, storage handling, and the compliance evidence available through the trust center. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is data residency in plain terms? Data residency is the requirement that a named set of data is stored and processed inside a defined geography, such as a country, an economic area, or a single cloud region. A complete requirement names three things: the data in scope, the geography, and the operations covered, which usually means storage at rest, processing, and backups. ### Is data residency the same as data sovereignty? No. Residency is about physical location: where the bytes sit and where the machines that process them run. Sovereignty is about legal reach: whose law applies to the data and who can be compelled to hand it over. A system can meet a residency requirement while still failing a sovereignty requirement, because the operator holding the keys may answer to a different jurisdiction than the region the data sits in. ### What data does a GitHub Actions run actually produce? Seven classes show up in most reviews: the source checkout on the runner disk, dependency and build caches, uploaded artifacts, container images pushed to a registry, job logs, the secret values delivered into the job environment, and run metadata such as queue records and timings. Each class can live in a different place under a different operator, so each one needs its own answer. ### Does encryption satisfy a data residency requirement? Usually no. Encryption is a confidentiality control that limits who can read data; residency is a location control that limits where data sits and where it is processed. Encrypted data stored outside the required geography is still stored outside it. Encryption and key custody matter to the neighboring sovereignty question, because whoever holds the keys can produce plaintext. --- # Deployment Environment | WarpBuild URL: https://www.warpbuild.com/glossary/deployment-environment Description: A deployment environment is a named target in GitHub Actions that carries its own secrets, variables, and protection rules such as required reviewers. # Deployment Environment A deployment environment is a named target in GitHub Actions that carries its own secrets, variables, and protection rules such as required reviewers. A deployment environment is a named deployment target in GitHub Actions that a job points at with the `environment` key, and that carries its own secrets, its own variables, and its own protection rules such as required reviewers. A job bound to an environment stays in a waiting state until every protection rule on that environment passes, and only then does it reach a runner with the environment's secrets and variables in scope. The configuration lives in repository settings rather than in the workflow file. That split is the point: `staging` and `production` can demand different approvals and hand out different credentials while the YAML that references them stays one line apart. ## [Definition](#definition)Copy Anchor Link An environment is created under Settings, then Environments, in a repository. Names are matched case insensitively, may not exceed 255 characters, and must be unique within the repository. A workflow that references a name nobody created causes GitHub to create that environment on the spot, with no protection rules and no secrets attached, which is why a typo in the name produces a green deploy that skipped every gate you thought you had. An environment holds three kinds of thing. **Protection rules** decide whether a job that references the environment is allowed to start. **Environment secrets** are encrypted values that reach only jobs referencing the environment, and only after its rules pass. **Environment variables** are plaintext values readable through the `vars` context, again only from jobs that reference the environment. Secrets resolve at the lowest level that defines the name, so an environment secret wins over a repository secret and over an organization secret sharing that name. An environment stores up to 100 secrets ([secrets reference](https://docs.github.com/en/actions/reference/security/secrets), checked on 2026-08-13). The `environment` key takes a single value per job, written either as a name string or as an object with `name` and `url`. A run that ships to staging and then to production therefore needs two jobs, each referencing its own environment and clearing its own rules. ### [The protection rules and their documented limits](#the-protection-rules-and-their-documented-limits)Copy Anchor Link Five rule types are available. Their limits are fixed by GitHub rather than chosen per repository ([deployments and environments reference](https://docs.github.com/en/actions/reference/deployments-and-environments), checked on 2026-08-13). | Rule | What it holds the job for | Documented limit | | ---------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Required reviewers | A listed person or team approves the job | Up to 6 users or teams; one approval releases the job | | Wait timer | A fixed delay after the job is triggered | 1 to 43,200 minutes (30 days); the wait is not billable | | Deployment branches and tags | The run's GITHUB\_REF matches an allowed pattern | Three modes: no restriction, protected branches only, selected branches and tags | | Allow administrators to bypass | Nothing while enabled; disabling it removes the admin override | Enabled by default | | Custom deployment protection rules | A GitHub App returns an approval | Any number installed per repository, 6 enabled per environment | Two details in that table cause most of the surprises. Branch and tag patterns are matched against `GITHUB_REF`, and a wildcard stops at a slash, so `release/*` matches `release/1.4` while `release/*/*` is needed for `release/1.4/hotfix`. And required reviewers have a companion setting, prevent self-review, which stops the person who triggered the deployment from being the person who approves it. Plan availability differs by rule. Environments, environment secrets, and deployment branches work in public repositories on all current plans, and in private or internal repositories on GitHub Pro, Team, or Enterprise. Required reviewers, wait timers, and custom protection rules are limited to public repositories for accounts on Free, Pro, and Team. ### [What referencing an environment records](#what-referencing-an-environment-records)Copy Anchor Link A job that references an environment creates a deployment object carrying the environment name, then deployment status objects carrying the job state and, when the job supplies one, an `environment_url`. Those objects drive the deployments view on the repository and are readable through the REST API, so the deploy history for a service becomes queryable without parsing workflow logs ([managing environments](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments), checked on 2026-08-13). Setting `deployment: false` under the environment key borrows the secrets and variables while skipping the deployment object entirely. ## [Example](#example)Copy Anchor Link This workflow builds on every push to `main`, then hands the artifact to a second job bound to the `production` environment. The `production` environment has one required reviewer and a deployment branch rule allowing `main` only. Copy Text ``` name: deploy on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: ./scripts/build.sh - uses: actions/upload-artifact@v4 with: name: bundle path: dist/ deploy: needs: build runs-on: ubuntu-latest environment: name: production url: ${{ steps.release.outputs.url }} steps: - uses: actions/download-artifact@v4 with: name: bundle path: dist/ - id: release run: ./scripts/deploy.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} API_HOST: ${{ vars.API_HOST }} ``` Follow one push through it. The `build` job starts immediately because it references no environment. The `deploy` job then enters a waiting state, and GitHub opens a deployment against `production` and notifies the reviewer. `DEPLOY_TOKEN` is unreadable during that wait, because environment secrets are released only once the rules pass. | Stage | Deploy job state | Runner occupied | secrets.DEPLOY\_TOKEN readable | | --------------------------------------- | ------------------------------ | --------------- | ------------------------------ | | build running | Queued behind needs | No | No | | Branch rule checked against GITHUB\_REF | Waiting | No | No | | Reviewer notified, decision pending | Waiting | No | No | | Reviewer approves | In progress | Yes | Yes | | deploy.sh exits 0 | Success, deployment status set | Released | Job finished | The middle rows are the reason a gated deploy costs nothing while it waits. Approval time and wait timer time happen before the job is dispatched, so no runner is held open and no job minutes accrue. A pull request opened from a branch other than `main` never reaches those rows at all, because the branch rule rejects the deployment before the reviewer is asked. ### [Picking the environment at run time](#picking-the-environment-at-run-time)Copy Anchor Link The `name` value accepts an expression, with the `github`, `inputs`, `vars`, `needs`, `strategy`, and `matrix` contexts available. One job definition can therefore route itself to different environments and pick up different credentials: Copy Text ``` jobs: deploy: runs-on: ubuntu-latest environment: name: ${{ github.ref_name == 'main' && 'production' || 'staging' }} steps: - run: ./scripts/deploy.sh env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} ``` A push to `main` resolves the name to `production` and inherits the reviewer requirement. A push to any other branch resolves to `staging`, which can carry a wait timer of zero and a different `DEPLOY_TOKEN` value under the same secret name. ## [Related Terms](#related-terms)Copy Anchor Link * [How to structure deployment jobs on GitHub Actions](/guides/deployment-jobs-github-actions): splitting build from deploy, wiring `needs`, and keeping the gated job small enough that approval time covers only the deploy. * [Workflow permissions and the GITHUB\_TOKEN scopes a job gets](/glossary/workflow-permissions): the `permissions` key that decides what a deploy job may write, which is a separate control from the environment it references. * [How to stop a workflow from blocking deploys](/answers/how-do-i-stop-a-workflow-from-blocking-deploys): what to check when a deploy sits in a waiting state, and how to tell a protection rule apart from a queue problem. * [GitHub reference for deployments and environments](https://docs.github.com/en/actions/reference/deployments-and-environments): the documented behavior and limits of every protection rule listed above. * [WarpBuild runner security documentation](/docs/ci/security): how build secrets reach a job once an environment releases them. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What does the environment key do in a job? It binds one job to a named environment configured in repository settings. The job waits until every protection rule on that environment passes, then runs with that environment's secrets and variables in scope, and GitHub records a deployment object for the run. ### Does an environment secret override a repository secret with the same name? Yes. GitHub resolves a secret name at the lowest level that defines it, so for a job that references an environment, the environment value wins over a repository secret and over an organization secret of the same name. An environment can hold up to 100 secrets. ### What happens if a workflow names an environment that does not exist? GitHub creates it during the run with no protection rules and no secrets configured. Anyone who can edit a workflow file can create an environment this way, while configuring reviewers, timers, and branch rules on it requires repository admin access. --- # Docker Bake | WarpBuild URL: https://www.warpbuild.com/glossary/docker-bake Description: Docker Bake is the Buildx command that reads build targets from an HCL, JSON, or Compose file, so several images build from one declarative definition. # Docker Bake Docker Bake is the Buildx command that reads build targets from an HCL, JSON, or Compose file, so several images build from one declarative definition. Docker Bake is a Buildx feature that reads build targets from an HCL or JSON file, so several images build from one declarative definition instead of one command line per image. A single `docker buildx bake` invocation resolves that file, expands the targets it was asked for, and sends all of them to the same builder. Bake moves the arguments of a build out of the shell and into version control. The interesting consequences follow from that move: targets can inherit shared fields, a group name can stand for a set of images, and the same definition runs identically on a laptop and inside a GitHub Actions job. ## [Definition](#definition)Copy Anchor Link Bake is a command inside Buildx, invoked as `docker buildx bake`. Where `docker buildx build` describes one image through flags, bake describes any number of images through a file, and each described image is called a target. ### [What the file contains](#what-the-file-contains)Copy Anchor Link A bake file is written in HCL or JSON, and a Compose file counts as a bake definition too, so an existing `compose.yaml` with `build` sections can be built with bake without a rewrite. Bake looks for the conventional names in the working directory when no file is named: `docker-bake.hcl`, `docker-bake.json`, the matching `.override` variants, and Compose names such as `compose.yaml` and `docker-compose.yml`. The `-f` flag names a file explicitly, and repeating `-f` merges several files into one definition. Four block types make up the definition. | Block | What it declares | | -------- | ---------------------------------------------------------------------------------------- | | target | One build: context, Dockerfile path, tags, build args, platforms, cache settings, output | | group | A named list of targets that build together | | variable | A named value with a default that the environment can override | | function | An HCL function used inside attribute expressions | A target carries the fields a `docker buildx build` command would carry as flags. `context` and `dockerfile` locate the build, `tags` names the output, `args` supplies build arguments, `platforms` lists the architectures, and `cache-from` and `cache-to` point at cache backends. ### [How targets share fields](#how-targets-share-fields)Copy Anchor Link `inherits` is the attribute that keeps a large file short. A target listing `inherits = ["common"]` starts from that target's attributes and then overrides or extends them, so a registry prefix, a base image argument, and a platform list live in one place and apply to every image built from them. Groups sit above targets. A group names a set of targets, and a group named `default` is what bake builds when the command is given no positional argument. Passing a name on the command line builds that target or group instead, so one file can serve `bake app`, `bake tools`, and `bake` for everything. ### [How one invocation differs from several](#how-one-invocation-differs-from-several)Copy Anchor Link The mechanical difference between bake and a sequence of build commands shows up in four places. | Question | Repeated build commands | One bake invocation | | ------------------------------ | -------------------------------------- | ---------------------------------------------- | | Where tags and build args live | On the command line, once per image | In the file, as target attributes | | Shared fields | Copied into each command | Declared once and inherited | | Which builder runs the work | Whichever builder each command selects | One builder for every target in the run | | Shared base stage | Resolved separately per command | Resolved once, reused from the builder's cache | That last row is the reason bake matters for a repository holding several Dockerfiles. Targets built in one invocation go to the same builder, so a base stage two images share is built once and read from cache for the second target rather than rebuilt. Two flags are worth knowing before the first run. `--print` resolves the file and prints the result as JSON without building, which turns a question about variable expansion into a readable answer. `--set` overrides attributes at the command line with a target pattern, as in `--set *.platform=linux/amd64` or `--set api.tags=example/api:test`. ## [Example](#example)Copy Anchor Link A repository with an `api` service and a `worker` service can describe both images in one file. The shared fields sit in a `common` target that neither group builds directly: Copy Text ``` variable "REGISTRY" { default = "ghcr.io/example/monorepo" } variable "TAG" { default = "dev" } group "default" { targets = ["api", "worker"] } target "common" { context = "." args = { NODE_VERSION = "22" } platforms = ["linux/amd64", "linux/arm64"] } target "api" { inherits = ["common"] dockerfile = "services/api/Dockerfile" tags = ["${REGISTRY}/api:${TAG}"] } target "worker" { inherits = ["common"] dockerfile = "services/worker/Dockerfile" tags = ["${REGISTRY}/worker:${TAG}"] } ``` `docker buildx bake --print` expands the variables and the inheritance and shows what will actually be built: Copy Text ``` { "group": { "default": { "targets": ["api", "worker"] } }, "target": { "api": { "context": ".", "dockerfile": "services/api/Dockerfile", "args": { "NODE_VERSION": "22" }, "tags": ["ghcr.io/example/monorepo/api:dev"], "platforms": ["linux/amd64", "linux/arm64"] }, "worker": { "context": ".", "dockerfile": "services/worker/Dockerfile", "args": { "NODE_VERSION": "22" }, "tags": ["ghcr.io/example/monorepo/worker:dev"], "platforms": ["linux/amd64", "linux/arm64"] } } } ``` The workflow that builds both images is one step. Docker publishes `docker/bake-action` for this, and the `TAG` variable is supplied through the environment rather than through a flag, because a `variable` block reads the environment first: Copy Text ``` name: images on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/bake-action@v6 env: TAG: ${{ github.sha }} with: files: ./docker-bake.hcl targets: default push: true ``` That job pushes `api` and `worker` for both architectures, each tagged with the commit SHA. Building one image on demand needs no second workflow, only a different target name: Copy Text ``` - uses: docker/bake-action@v6 with: files: ./docker-bake.hcl targets: api push: true set: | api.platform=linux/amd64 ``` The same two commands run locally against the same file. `docker buildx bake` builds the `default` group, and `docker buildx bake api` builds one target, which is why the file rather than the workflow is the source of truth for how an image is built. ## [Related Terms](#related-terms)Copy Anchor Link * [How to use Docker Bake in a GitHub Actions workflow](/answers/how-do-i-use-docker-bake-in-github-actions): the bake file and workflow step written out end to end, including when bake replaces a matrix of separate build jobs. * [Building several images from one monorepo](/guides/monorepo-docker-builds-github-actions): change detection that selects which targets a commit builds, plus the cost model behind building the affected set. * [Docker Buildx, the plugin that provides the bake command](/glossary/buildx): builder instances, drivers, and the flags bake writes into each target. * [WarpBuild Docker builder documentation](/docs/ci/docker-builders): builder profiles and the action inputs that point a bake run at a builder outside the job. * [WarpBuild caching documentation](/docs/ci/features/caching): cache action inputs, key matching, and entry expiry. * [WarpBuild pricing](/pricing): per minute rates by runner type and by builder size. ## FAQ ### Which file does docker buildx bake read? By default bake looks in the working directory for docker-bake.hcl, docker-bake.json, their .override variants, and Compose files such as compose.yaml or docker-compose.yml, since a Compose file is a valid bake definition. Passing -f names the file explicitly, and repeating -f merges several files into one definition. ### How is bake different from running docker build twice? Two docker build commands carry their context, Dockerfile, tags, build args, and cache flags on the command line, and each one is a separate invocation. Bake keeps those fields in a file as named targets, and one invocation resolves the file, expands the requested targets or group, and sends them to the same builder together. ### How do I override a bake value without editing the file? A variable block reads its value from the environment, so exporting TAG=abc123 before the command changes every expression that references it. The --set flag overrides target attributes directly with a target pattern, as in --set \*.platform=linux/amd64, and --print renders the resolved definition as JSON without building anything. --- # Docker Layer Cache | WarpBuild URL: https://www.warpbuild.com/glossary/docker-layer-cache Description: A Docker layer cache is the store of previously built image layers that a build reuses when an instruction and the inputs it reads are unchanged. # Docker Layer Cache A Docker layer cache is the store of previously built image layers that a build reuses when an instruction and the inputs it reads are unchanged. A Docker layer cache is the store of previously built image layers that a build reuses when an instruction and every input that instruction reads are unchanged since an earlier build. When the builder finds a matching entry it skips running the instruction and takes the finished layer from the cache instead. Everything else about layer caching follows from that one rule: which edits cost a rebuild, how far down the image a rebuild spreads, and why the same Dockerfile can hit on one machine and miss on another. ## [Definition](#definition)Copy Anchor Link A Docker image is a stack of read-only filesystem layers plus a manifest that records their order. Each Dockerfile instruction that writes to the filesystem produces one layer, identified by the digest of its content. Instructions that only change image metadata, such as `WORKDIR`, `ENV`, `LABEL`, and `EXPOSE`, change the image config rather than adding a filesystem layer, and they still take part in cache matching. Before executing an instruction, the builder computes a cache key for it. BuildKit, the default builder in Docker Engine since version 23.0, derives that key from three inputs: 1. The digest of the parent layer that this instruction builds on. 2. The text of the instruction after variable substitution, so an `ARG` value that changes between builds changes the key of every instruction that references it. 3. The digests of any files the instruction reads out of the build context, including their file modes. If a layer carrying that key exists in a cache the builder can reach, the instruction is a hit and the build log marks the step `CACHED`. Otherwise the instruction runs and produces a new layer, which is then written to the cache under the computed key. Docker documents the key derivation and the cache backends in its [build cache reference](https://docs.docker.com/build/cache/), checked on 2026-08-13. ### [The cascade rule](#the-cascade-rule)Copy Anchor Link The parent digest is part of every child key, so the layers form a chain. The first instruction whose key changes invalidates its own layer and every layer after it, whatever those later instructions contain and however unrelated they look. A single character edited in a file copied at step 3 of a twelve-step Dockerfile rebuilds steps 3 through 12. This is why instruction order does more for build time than any other Dockerfile choice. Slow and stable work belongs near the top, fast and volatile work near the bottom, and the boundary between them is the line where the cache usually breaks. ### [What each instruction hashes](#what-each-instruction-hashes)Copy Anchor Link The cache key covers different inputs per instruction type. The table below lists the common ones and the change that most often moves the key. | Instruction | What the cache key covers | Common invalidation trigger | | ---------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | FROM | the resolved digest of the base image | the base image tag is republished and now resolves to a new digest | | RUN | the parent digest and the command string after substitution | any edit to the command, including whitespace, or any change above it | | COPY and ADD | the parent digest, the instruction text, and the contents and modes of every path matched | one byte changed in any matched file, a new file that matches the pattern, or a mode change | | ARG and ENV | the substituted value inside every instruction that references the variable | a value that differs per build, such as a commit SHA or a build timestamp | | RUN --mount=type=cache | the parent digest and the command string; the mounted directory stays outside the key | the layer still rebuilds on a miss, while the mounted directory survives and the work inside the layer is shorter | | COPY --from=stage | the parent digest and the digests of the files copied out of the named stage | a rebuild of the source stage that changes the copied output | | WORKDIR, LABEL, EXPOSE | the parent digest and the instruction text | a change to an instruction above them | Two entries in that table surprise people. File modes count, so a `chmod +x` on a script invalidates the `COPY` that carries it even though no byte of content moved. And a cache mount is excluded from the key on purpose: the instruction still reruns, and the package manager or compiler directory behind the mount is still populated, so the rerun does less work than a cold one. ### [Where the cache can live](#where-the-cache-can-live)Copy Anchor Link A layer cache is a store, and the store can sit in three places. A single build can read from more than one of them. **On the build machine.** The builder keeps layers in its local store, on disk next to the daemon or inside the buildx builder instance. Lookups are local and cost nothing beyond a disk read. The store is tied to that machine, so a workflow that gets a fresh machine per build starts every build cold. **In a registry.** A cache export writes cache manifests and blobs to a container registry, either alongside the image or under a separate tag, and a later build imports them. This is the `type=registry` backend, driven by `--cache-to` and `--cache-from`. It is portable across machines and costs a network round trip plus registry storage. An inline export (`type=inline`) embeds cache metadata in the image itself and covers only the layers of the final stage. A local export (`type=local`) writes the same data to a directory, which a workflow can then archive and restore through whatever file cache it already has. **On a dedicated builder.** A persistent remote builder holds its own layer store and keeps it between builds, so every client that connects to that builder reads the same store. No export or import step sits in the build path, and the cache lives with the builder rather than with the client that ran the build. This is the model behind [remote build caches](/glossary/remote-build-cache). The choice between the three is a trade between locality and portability. A local store is the fastest to read and the easiest to lose. A registry export survives any machine and pays for that with transfer time on every build. A dedicated builder keeps the store warm without a transfer, at the cost of running a builder that outlives the build. ### [What a layer cache does not cover](#what-a-layer-cache-does-not-cover)Copy Anchor Link A layer cache is keyed by Dockerfile instruction inputs and stores image layers. A dependency cache, sometimes called an [artifact cache](/glossary/artifact-cache), stores files under a key the workflow computes itself, usually a hash of a lockfile. The two solve neighboring problems and often appear in the same pipeline, and confusing one for the other leads to a build that restores a `node_modules` archive it then throws away inside the image. ## [Example](#example)Copy Anchor Link Take a small Node.js image. The instructions are ordered so that dependency installation sits above the application source. Copy Text ``` # syntax=docker/dockerfile:1 FROM node:22-slim WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY src ./src RUN npm run build CMD ["node", "dist/server.js"] ``` The first build runs every instruction and writes one cache entry per step. Run it again with no changes at all and every step is a hit: Copy Text ``` #5 [2/6] WORKDIR /app #5 CACHED #6 [3/6] COPY package.json package-lock.json ./ #6 CACHED #7 [4/6] RUN npm ci #7 CACHED #8 [5/6] COPY src ./src #8 CACHED #9 [6/6] RUN npm run build #9 CACHED ``` Now edit one line in `src/server.ts` and build again. Step 5 reads the contents of `src`, so its key moves and the step runs. Step 6 sits below it and inherits the new parent digest, so it runs too. Steps 1 through 4 are untouched, and `npm ci` is still served from the cache even though the source tree changed. Change `package-lock.json` instead, by adding a dependency, and the invalidation starts one step earlier. Step 3 reads that file, so step 3, step 4, step 5, and step 6 all run. The single lockfile edit costs a full dependency install plus a full application build. The table below shows the state of each step on the second build for four kinds of change. | Change since the last build | Step 3 COPY package.json package-lock.json | Step 4 RUN npm ci | Step 5 COPY src ./src | Step 6 RUN npm run build | | --------------------------------------- | ------------------------------------------ | ----------------- | --------------------- | ------------------------ | | Nothing | CACHED | CACHED | CACHED | CACHED | | One line in src/server.ts | CACHED | CACHED | runs | runs | | A dependency added to package-lock.json | runs | runs | runs | runs | | node:22-slim resolves to a new digest | runs | runs | runs | runs | Read the table by column and the ordering rule becomes concrete. Move the two source instructions above the two dependency instructions and the second row collapses into the third: every source edit would reinstall dependencies, because the reinstall would then sit below a step whose key moves on every commit. The fourth row is the case no Dockerfile edit prevents. A mutable base tag such as `node:22-slim` resolves to a new digest whenever the publisher pushes an update, and the whole image rebuilds from step 1\. Pinning the base image by digest trades that rebuild for an explicit update commit. ### [The same build inside a GitHub Actions workflow](#the-same-build-inside-a-github-actions-workflow)Copy Anchor Link Nothing above changes when the build runs in GitHub Actions, except that the machine is usually new for every job, so the local store is empty at the start of every run. The cache has to come from somewhere the job can reach: Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Build and push uses: docker/build-push-action@v6 with: context: . push: true tags: acme/api:${{ github.sha }} cache-from: type=registry,ref=acme/api:buildcache cache-to: type=registry,ref=acme/api:buildcache,mode=max ``` Three lines carry the caching behavior. `runs-on` names the label that routes the job to a machine. `cache-from` tells the builder where to look up keys before it runs an instruction. `cache-to` writes the resulting layers back, and `mode=max` exports the intermediate stage layers as well as the layers of the final image, which matters for any multi-stage Dockerfile whose build stage is the slow one. Drop the `cache-from` line and every run of this workflow rebuilds from step 1, because the only store the job can reach is the empty local one on a machine that did not exist a minute ago. Cache backends and the actions that drive them are covered in the [WarpBuild caching documentation](/docs/ci/features/caching), and the builder-side model, where the layer store lives with a persistent builder instead of a registry tag, is covered in the [remote Docker builders documentation](/docs/ci/docker-builders). ## [Related Terms](#related-terms)Copy Anchor Link * [Remote build cache](/glossary/remote-build-cache): what it means for a layer cache to live off the build machine and how a build reaches it. * [Artifact cache](/glossary/artifact-cache): the file-level cache keyed by the workflow, and how its lifetime differs from a layer cache. * [Docker layer caching on GitHub Actions](/guides/docker-layer-caching-github-actions): keeping a layer cache alive across runs that get a fresh machine each time. * [Why your Docker layer cache misses every run](/guides/docker-cache-invalidation-github-actions): finding the first uncached instruction and the four changes that usually sit there. * [Remote Docker builders documentation](/docs/ci/docker-builders): how a persistent builder keeps its own layer store between builds. * [WarpBuild pricing](/pricing): per minute rates for runners and Docker builders. ## FAQ ### What is a Docker layer cache? It is the store of previously built image layers that a build reuses when an instruction and every input that instruction reads are unchanged since an earlier build. The builder computes a key per instruction, looks the key up in the cache, and skips running the instruction when a layer with that key already exists. ### What invalidates a Docker layer cache? Any change to an instruction's cache key: the digest of the parent layer, the text of the instruction after variable substitution, or the contents and modes of the files that instruction reads from the build context. Because the parent digest is part of every child key, the first instruction that misses invalidates every instruction after it. ### Where is the Docker layer cache stored? In one of three places, and a single build can read from more than one. Layers can sit in the local store on the build machine, in a container registry as cache manifests and blobs written by a cache export, or on a dedicated builder that keeps its own layer store between builds and serves every client that connects to it. ### Is a Docker layer cache the same as a dependency cache? No. A layer cache stores built image layers keyed by the inputs of each Dockerfile instruction. A dependency or artifact cache stores files, such as a package manager directory, under a key the workflow computes itself. They have different keys, different lifetimes, and different restore paths. --- # Egress Costs | WarpBuild URL: https://www.warpbuild.com/glossary/egress-costs Description: Egress costs are the per-gigabyte charges a cloud provider applies to data leaving its network or crossing its zones and regions. Where they come from. # Egress Costs Egress costs are the per-gigabyte charges a cloud provider applies to data leaving its network or crossing its zones and regions. Where they come from. Egress costs are the charges a cloud provider applies to data that leaves its network, and to data that crosses a boundary inside it such as an availability zone or a region. They are metered per gigabyte moved rather than per request or per machine hour, so the charge follows the volume of bytes a system moves and appears on the cloud bill under a data transfer heading. The term shows up in GitHub Actions discussions because build and deploy jobs are heavy readers of cloud storage. A job that pulls a container image, a model file, or a release archive is moving gigabytes out of a store on a schedule set by how often a team merges. ## [Definition](#definition)Copy Anchor Link Egress is the outbound direction of data movement, measured at a boundary the provider defines. Egress cost is what the provider charges for crossing that boundary. Three properties describe it in every catalog. The unit is the gigabyte. Providers publish a rate per GB, apply it to the volume that crossed the boundary during the billing period, and the resulting line has no relationship to the number of requests, instances, or jobs involved. Ten thousand small reads and one large read of the same total volume produce the same charge. The direction is asymmetric. Data arriving from outside is normally accepted at no charge, and data leaving is metered. The asymmetry is why a workload that mostly writes into a cloud looks cheap and a workload that mostly reads out of one does not. The boundary is defined by the provider, not by intuition. The public internet is the obvious boundary. Providers also meter movement between their own regions, and often between availability zones inside a single region, so bytes that never touch the internet can still be billed as transfer. ### [The three usual charge types](#the-three-usual-charge-types)Copy Anchor Link Provider catalogs differ in naming, and the same three shapes recur. **Internet egress.** Bytes leaving the provider network toward the public internet or toward another provider. This is the highest published per-GB rate of the three and the one most people mean by the word egress. Providers commonly grant a small monthly allowance before the meter starts. **Cross-region and cross-zone transfer.** Bytes moving between two regions of the same provider, or between availability zones inside one region. The rates are lower than internet egress and the volumes are easy to generate by accident, because zone and region placement is a deployment detail rather than something a workload declares. **NAT data processing.** A per-GB fee for traffic passing through a managed network address translation service, usually alongside an hourly charge for the device itself. This one is different from the other two in an important way: the processing fee applies in both directions, so inbound bytes that would otherwise be free become metered once they route through NAT. A fourth item sits next to these without being egress: request or operation charges on object storage. Those meter API calls rather than gigabytes, and a workload with many small objects can pay more in operations than in transfer. ### [A published rate card, as one concrete example](#a-published-rate-card-as-one-concrete-example)Copy Anchor Link Rates below are AWS list prices for US East (N. Virginia), quoted with the page they come from and checked on 2026-08-13\. Every provider publishes an equivalent table, and the rates move, so treat this as the shape of the data rather than a constant. | Path | Published rate | Charge type | Source | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------- | | Registry to compute in the same Region | $0.00 per GB | Same-Region transfer | [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/) | | Registry to a destination in a different Region | Internet data transfer rates on both sides, priced at $0.09 per GB in the worked example on that page | Cross-region transfer | [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/) | | Out to the internet, first 100 GB each month | $0.00 per GB, aggregated across services and Regions | Internet egress allowance | [Amazon EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/) | | Managed NAT, data processed | $0.045 per GB, both directions | NAT data processing | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/) | | Managed NAT, device time | $0.045 per hour | Fixed hourly charge | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/) | Two readings of that table matter. The first row costs nothing, and the second row is the same operation with the reader placed somewhere else, so placement rather than payload decides whether a meter runs at all. The last two rows show that a network design choice, putting machines in private subnets behind a managed NAT, adds a per-GB charge to traffic that had none. Some paths can be taken off the meter by keeping the read inside the account network instead of routing it over the public internet. The WarpBuild [BYOC AWS configuration guide](/docs/ci/byoc/aws/config) recommends configuring S3 access directly on the VPC so machines reach the bucket without incurring data transfer charges, and notes that the bucket should sit in the same region as the stack. ### [Who bills it](#who-bills-it)Copy Anchor Link The provider that owns the network bills the egress. That is worth stating because it explains why the charge is easy to miss. An invoice for a source control or build service lists minutes and storage. The gigabytes moved by those minutes are billed separately by whichever cloud held the data, mixed into a data transfer line alongside production traffic. The two invoices also have different owners inside most companies, so nobody is looking at both at once. ## [Example](#example)Copy Anchor Link Take an ordinary deploy job. It authenticates to a container registry, pulls the application image, and hands it to a cluster. The workflow below uses a GitHub-hosted runner, which is the default starting point for most repositories. Copy Text ``` name: deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest permissions: id-token: write contents: read steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - run: docker pull "${{ steps.ecr.outputs.registry }}/api:${{ github.sha }}" - run: ./scripts/deploy.sh ``` Follow the bytes in the `docker pull` step. The image sits in a registry inside an AWS account in `us-east-1`. The machine running the step sits in a pool operated by GitHub, outside that account. The layers therefore leave the account across the internet path, and the account that owns the registry pays the internet data transfer rate on the volume. Nothing in the workflow file says so. There is no step that mentions transfer, no output that reports gigabytes, and no line in the GitHub Actions run summary that names a cost. The only visible trace is a line on the AWS bill under a usage type such as `DataTransfer-Out-Bytes`. ### [The same pull, three placements](#the-same-pull-three-placements)Copy Anchor Link The step and the image are identical in all three rows below. The only variable is where the machine that runs the step lives. | Machine placement | Path the layers take | Meter that counts it | | ------------------------------------------------------------ | ------------------------------------------- | ---------------------------------------------------------- | | Outside the account holding the registry | Registry to the internet | Internet egress, per GB above the monthly allowance | | Same account and Region, public subnet | Registry to compute in the Region | Same-Region transfer, $0.00 per GB on the ECR pricing page | | Same account and Region, private subnet behind a managed NAT | Registry to compute, through the NAT device | NAT data processing per GB, plus the hourly device charge | ### [What the volume works out to](#what-the-volume-works-out-to)Copy Anchor Link Assume a 1.4 GB image and 0.2 GB of release artifacts pulled per deploy job, 40 deploy jobs per weekday, and 22 weekdays in the month. All rates below are the AWS list prices from the table above, checked on 2026-08-13. Jobs per month: 40 times 22 equals 880. Gigabytes per job: 1.4 plus 0.2 equals 1.6\. Monthly volume: 880 times 1.6 equals 1,408 GB. | Placement | Arithmetic | Monthly transfer charge | | -------------------------------------------------------- | --------------------------------------------------------- | ----------------------- | | Outside the account | 1,308 GB billable after the 100 GB allowance, times $0.09 | $117.72 | | Same account and Region, public subnet | 1,408 GB times $0.00 | $0.00 | | Same account and Region, private subnet with managed NAT | 1,408 GB times $0.045, plus 730 hours times $0.045 | $96.21 | The billable figure in the first row credits the 100 GB monthly allowance once. An account whose production traffic already consumed the allowance pays the full 1,408 GB, which is $126.72. Scale it with your own inputs. Doubling the image size doubles the line. Doubling deploy frequency doubles it again. A matrix that fans out to six legs, each pulling the same image, multiplies it by six unless the legs share a resolved digest and hit cached layers. ### [Placement is a label, not a rewrite](#placement-is-a-label-not-a-rewrite)Copy Anchor Link GitHub routes a job to a machine by matching the labels in `runs-on`, and the label is an opaque string. Moving the same steps onto a fleet that sits inside the account holding the registry is therefore a one-line change: Copy Text ``` jobs: deploy: runs-on: warp-custom-deploy-use1 ``` Runners registered in your own cloud account through WarpBuild use the Runner ID prefixed with `warp-custom-`, and the region and VPC come from the stack the runner belongs to, as described in the [BYOC documentation](/docs/ci/byoc). Labels for the hosted fleet follow a different shape, such as `warp-ubuntu-latest-x64-4x`, where the suffix names the operating system, the architecture, and the machine size. The steps, the actions, and the image are unchanged in both cases. The row of the placement table that applies is what changed, and with it the meter that counts the pull. ## [Related Terms](#related-terms)Copy Anchor Link * [Where GitHub Actions egress charges come from and how to cut them](/guides/github-actions-egress-costs): the diagnosis steps, the workflow changes, and a worked AWS model. * [What container image pulls from ECR cost in GitHub Actions](/guides/ecr-pull-costs-github-actions): registry-specific rates, layer cache behavior, and the per-pull arithmetic. * [Artifact cache defined](/glossary/artifact-cache): the store a workflow reads dependencies and build outputs from between runs. * [Zero egress on the WarpBuild enterprise tier](/enterprise/zero-egress): the scope of the enterprise outcome and its worked cost model. * [BYOC documentation](/docs/ci/byoc): how a stack pins the region, VPC, and network placement of runners in your own cloud account. * [BYOC AWS configuration guide](/docs/ci/byoc/aws/config): VPC, subnet, S3 network access, and ECR access recommendations. * [WarpBuild pricing](/pricing): per minute runner rates by runner type. ## FAQ ### What are egress costs? Charges a cloud provider applies to data that leaves its network, or that crosses a boundary inside it such as an availability zone or a region. They are metered per gigabyte moved, and they land on the cloud bill rather than on a source control or build service invoice. ### Is inbound data charged as well? Usually no. Most providers accept inbound data at no charge and meter the outbound direction. Managed NAT is the common exception, because the per-gigabyte data processing fee applies to traffic in both directions. ### Why do egress costs show up in GitHub Actions? Because jobs read large objects out of cloud stores. A deploy job pulls a container image from a registry and release artifacts from object storage on every run, so the gigabytes scale with merge frequency rather than with end user traffic. ### How do I find egress charges on a cloud bill? Group the bill by usage type, look for the data transfer and NAT data processing line items, then filter by the account, region, or tag that holds your runners. A curve that tracks merge volume rather than production traffic belongs to the pipeline. --- # Ephemeral Runner | WarpBuild URL: https://www.warpbuild.com/glossary/ephemeral-runner Description: An ephemeral runner is a GitHub Actions runner that takes exactly one job and is destroyed afterwards, so no state from that job reaches the next job. # Ephemeral Runner An ephemeral runner is a GitHub Actions runner that takes exactly one job and is destroyed afterwards, so no state from that job reaches the next job. An ephemeral runner is a GitHub Actions runner that accepts exactly one job and is destroyed once that job finishes, so no files, caches, background processes, or credentials from that job carry into the job that runs next. The machine exists for a single unit of work, and its disk is discarded along with it. The word describes a lifecycle rule rather than a hardware choice. An ephemeral runner can be a virtual machine, a container, or a physical host, on Linux, macOS, or Windows, supplied by you or by a provider, and it stays ephemeral for as long as the one-job rule holds. ## [Definition](#definition)Copy Anchor Link Two properties define the term, and both have to hold. 1. **One job per runner.** The runner agent registers with GitHub, advertises its labels, claims a single job, runs that job's steps, reports the conclusion, then deregisters. 2. **Disposal after the job.** The compute and the storage the job used are thrown away instead of being handed to the job that comes next. A runner that satisfies only the first property is a weaker form. If the agent deregisters cleanly but the host it ran on stays up and a fresh agent starts in the same working directory, the next job still finds last job's files. The guarantee people want from the word comes from the second property, because that is the one that removes the disk. ### [How the agent knows to stop after one job](#how-the-agent-knows-to-stop-after-one-job)Copy Anchor Link The runner agent is open source and published at [github.com/actions/runner](https://github.com/actions/runner) under the MIT license. It is the same binary whether the machine belongs to GitHub, to you, or to a provider, and ephemeral behavior is a configuration flag on it: Copy Text ``` ./config.sh \ --url https://github.com/acme \ --token "$RUNNER_REGISTRATION_TOKEN" \ --labels self-hosted,linux,x64 \ --ephemeral ./run.sh ``` The `--ephemeral` flag is what changes the lifecycle. Labels are only routing keys, so writing `ephemeral` into the `--labels` list is a naming convention that changes nothing about behavior. A runner configured without the flag stays registered and keeps accepting jobs until someone stops it. ### [The lifecycle, in order](#the-lifecycle-in-order)Copy Anchor Link An ephemeral runner moves through the same sequence every time: 1. Something creates the machine: a controller reacting to a queued job, an autoscaling group, or a provider's scheduler. 2. The agent configures itself with a short-lived registration token and the `--ephemeral` flag, then opens an outbound long poll to GitHub and waits. 3. GitHub sends one job message to a runner whose labels satisfy the job's `runs-on` key. 4. The agent acknowledges the message, which claims the job so no other runner takes it, and spawns a worker process. 5. The worker downloads every action the job references, runs the steps in order, and streams logs back to GitHub. 6. The agent reports the conclusion, deregisters itself, and exits. 7. The creator of the machine deletes the machine and its disk. Step 7 is the step teams forget when they build this themselves. An agent that exits without the machine being deleted leaves a paid-for host sitting idle, and worse, leaves a disk that a later agent may reuse. ### [Ephemeral compared with long-lived runners](#ephemeral-compared-with-long-lived-runners)Copy Anchor Link A long-lived runner is the other lifecycle: one registration, many jobs, in whatever order the queue hands them over. Neither shape is correct in general, and the tradeoffs are consistent enough to tabulate. | Dimension | Ephemeral runner | Long-lived runner | | ------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- | | State between jobs | None; every job starts from the base image | Whatever the previous jobs left on disk | | Isolation | One job per machine, so a compromised job cannot read the next job's checkout | Jobs share a filesystem, a package cache, and often a Docker daemon | | Cancelled or crashed jobs | Machine is discarded with the mess inside it | Partial files and stray processes stay behind | | Maintenance | Rebuild the image; the fleet picks it up on the next job | Patch, prune disks, and restart agents on running hosts | | Reproducing a failure | Same starting state every run | Depends on the host's history | | Scaling to zero | Natural; no runner exists between jobs | Requires draining a runner mid-lifecycle | | Start of job work | Setup repeats on every job | Warm caches are already on disk | **State** is the property most people mean when they use the term. On an ephemeral runner, a `node_modules` directory, a Gradle cache, a pulled container image, and a leftover `.env` file all disappear at the job boundary. Anything a later job needs has to be published deliberately through artifacts, a cache action, or a prebuilt image. **Isolation** follows from the same rule. Two jobs that never share a machine cannot read each other's source, secrets exported to files, or credential helpers left configured by a login step. This is why untrusted contributions from forks and jobs handling production credentials are usually pinned to ephemeral fleets. **Maintenance** changes shape rather than disappearing. There is no fleet of hosts to patch, prune, or restart, and no disk that fills with Docker layers until jobs start failing on a full volume. In exchange, the image becomes the artifact you own: tooling versions live in an image build pipeline, and rolling out a change means publishing a new image rather than running a configuration tool across live hosts. ### [Where ephemeral runners show up](#where-ephemeral-runners-show-up)Copy Anchor Link GitHub-hosted runners are ephemeral by construction. Each job is placed on a freshly provisioned machine from GitHub's pools and that machine is discarded when the job ends, which is why a hosted job never finds a previous run's files. Self-hosted fleets choose. Actions Runner Controller, the Kubernetes controller GitHub publishes, watches the job queue and creates one ephemeral runner pod per queued job, which is what makes scale-to-zero safe: there is no risk of terminating a runner in the middle of a job, because a runner only exists while it has one. Runner providers register machines against your organization through the same GitHub registration API, so the lifecycle they implement is described in their own documentation rather than in the workflow file. ## [Example](#example)Copy Anchor Link The clearest demonstration is a workflow that tries to hand a file from one job to the next without using a documented mechanism. Copy Text ``` name: ephemeral-demo on: workflow_dispatch jobs: first: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Leave something behind run: | echo "written by job first" > "$HOME/marker.txt" npm install -g typescript docker pull alpine:3.20 second: needs: first runs-on: ubuntu-latest steps: - name: Look for the marker run: | if [ -f "$HOME/marker.txt" ]; then echo "state carried over" exit 1 fi echo "clean machine: marker absent" - name: Look for the globally installed tool run: command -v tsc || echo "tsc absent" - name: Look for the pulled image run: docker image inspect alpine:3.20 || echo "image absent" ``` Job `second` passes. The marker file is absent, `tsc` is absent, and the `alpine:3.20` image is absent from the local image store, because job `second` runs on a machine that was created after job `first` finished and has never seen job `first`. Two details in that file are worth reading carefully. The `needs: first` edge orders the two jobs; it does not connect their filesystems. And job `second` has no `actions/checkout` step, so it has no source code either: the repository that job `first` cloned went away with job `first`'s disk. Changing the label changes which fleet claims the job while the shape of the workflow stays the same: Copy Text ``` jobs: first: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: echo "written by job first" > "$HOME/marker.txt" second: needs: first runs-on: warp-ubuntu-latest-x64-4x steps: - run: test ! -f "$HOME/marker.txt" && echo "clean machine" ``` Label shapes like `warp-ubuntu-latest-x64-4x` encode an operating system, an architecture, and a machine size, and GitHub treats the whole string as one opaque routing key. When the fleet behind that label registers ephemeral runners, the marker check behaves exactly as it does above, because the lifecycle rule lives with the runner rather than with the workflow file. The [cloud runners documentation](/docs/ci/cloud-runners) describes the machine shapes behind each label, and the [runner security documentation](/docs/ci/security) describes the isolation model. ### [When you wanted the state to survive](#when-you-wanted-the-state-to-survive)Copy Anchor Link A clean machine on every job is the point of the term, and the cost of it is that setup work repeats. Four mechanisms move state across the boundary on purpose: * **Artifacts** carry files that a later job needs, uploaded by the producing job and downloaded by the consumer. * **Caches** carry directories that a later job would otherwise rebuild, keyed by a lockfile hash so a changed dependency set misses cleanly. * **Prebuilt images** move setup out of the job entirely by baking the toolchain into the machine image the runner boots from. * **Saved disk state** boots a new machine from an image captured during an earlier run, so the packages and pulled containers from that run are present at step one. [Snapshot runners](/runners/snapshot) work this way, and the job stays ephemeral because a fresh machine is still allocated and still destroyed. The time an ephemeral job spends between machine creation and the first useful step is a separate topic, covered under [cold start](/glossary/cold-start). Boot time, image pull time, and agent registration all land in that window, and it is the number to watch when a fleet moves from long-lived hosts to ephemeral ones. ## [Related Terms](#related-terms)Copy Anchor Link * [Self-hosted runner, defined](/glossary/self-hosted-runner): who supplies the machine, how registration scope works, and why a self-hosted runner can be either lifecycle. * [Do GitHub Actions runners keep state between jobs?](/answers/do-github-actions-runners-keep-state-between-jobs): the same question answered from the workflow author's side, with the mechanisms for moving state across the boundary. * [Snapshot runners and saved disk state](/runners/snapshot): how a job boots from a disk captured on an earlier run while the runner itself stays ephemeral. * [Cold start, defined](/glossary/cold-start): the time between machine creation and the first step of a job, and what sits inside it. * [WarpBuild runner security documentation](/docs/ci/security): the compute and storage isolation model for runner virtual machines. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### Is an ephemeral runner the same thing as a self-hosted runner? No. Self-hosted describes who supplies the machine, and ephemeral describes how long the machine lives. A self-hosted runner can be ephemeral or long-lived depending on how the agent was configured, and GitHub-hosted runners are ephemeral by construction because each job gets a fresh machine that is discarded when the job ends. ### How do I make a self-hosted runner ephemeral? Configure the runner agent with the --ephemeral flag, so the agent accepts one job and then deregisters and exits. The second half is up to whatever created the machine: a controller or provisioning script has to delete the virtual machine or container after the agent exits, otherwise the disk survives for the next agent that starts on that host. ### Do ephemeral runners lose my dependency cache? The machine's disk goes away with the machine, so anything warm has to be restored on purpose. The usual mechanisms are actions/cache for dependency directories, artifacts for files a later job needs, a prebuilt image that already contains the toolchain, or a runner that boots from saved disk state captured on an earlier run. ### Why does every job repeat the same setup steps on ephemeral runners? Because every job starts from the same base image with an empty workspace. A workflow of five jobs runs its package install, its checkout, and its container pulls five times unless caching, a prebuilt image, or restored disk state shortens the start of each job. --- # Ephemeral Storage | WarpBuild URL: https://www.warpbuild.com/glossary/ephemeral-storage Description: Ephemeral storage is disk tied to the lifetime of one instance, container, or pod, erased when that instance goes away. What it means for a GitHub Actions job. # Ephemeral Storage Ephemeral storage is disk tied to the lifetime of one instance, container, or pod, erased when that instance goes away. What it means for a GitHub Actions job. Ephemeral storage is disk space whose contents are tied to the lifetime of the compute instance that owns it, so the data is deleted when that instance stops, terminates, or is otherwise removed. Every major platform ships a version of it under a different name: instance store on a virtual machine, the writable layer on a container, an emptyDir volume on a Kubernetes pod. For a GitHub Actions job, ephemeral storage is the disk the job actually works on. The checkout, the package manager cache, and the build output all land there, and on a machine that is discarded after the job, all three go away with it. ## [Definition](#definition)Copy Anchor Link Ephemeral storage is storage whose durability boundary is the life of one compute unit rather than the life of the data. The boundary is set by the platform, documented per resource type, and it applies to the whole volume rather than to a particular directory on it. Three widely used definitions line up on the same rule. | Platform | Resource | When the data is deleted | Source | | ---------- | ------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | | AWS EC2 | Instance store volume | Persists across a reboot, and does not persist when the instance is stopped, hibernated, or terminated | [Data persistence for instance store volumes](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-store-lifetime.html) | | Docker | Writable container layer | "Data written to the container layer doesn't persist when the container is destroyed" | [Docker storage](https://docs.docker.com/engine/storage/) | | Kubernetes | emptyDir volume | "When a Pod is removed from a node for any reason, the data in the emptyDir is deleted permanently" | [Kubernetes volumes](https://kubernetes.io/docs/concepts/storage/volumes/) | All three checked on 2026-08-13. The AWS row carries the detail people are caught out by most often. A reboot keeps instance store data, while a stop, a hibernate, or a change of instance type does not, and AWS states that every block is cryptographically erased on stop, hibernate, or terminate. So a machine that appears to survive an operation can still come back with an empty disk. ### [What sits on the other side of the boundary](#what-sits-on-the-other-side-of-the-boundary)Copy Anchor Link Durable storage keeps data after the compute goes away. Network attached block volumes (Amazon EBS, Google Persistent Disk, Azure Managed Disks) have their own lifecycle and can outlive the machine they were attached to. Object storage, container registries, and package registries are durable by design. Kubernetes draws the same line with a PersistentVolume, which has a lifecycle independent of any pod. The practical test is a question about ownership. If deleting the compute deletes the bytes, the storage is ephemeral. ### [Ephemeral storage as a scheduled resource](#ephemeral-storage-as-a-scheduled-resource)Copy Anchor Link Kubernetes goes further and treats local ephemeral storage as a resource with requests and limits. It counts the emptyDir volumes, the container writable layers, and the container logs on a node, and the kubelet evicts a pod that exceeds its `ephemeral-storage` limit ([managing resources for containers](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/), checked on 2026-08-13). That is a different enforcement style from CPU throttling or an out-of-memory kill, and it is why a build that fills a disk inside a pod shows up as an eviction rather than as a failed write. ## [Example](#example)Copy Anchor Link A repository runs its test job on a GitHub-hosted Linux runner. GitHub documents that each of these runners is a new virtual machine, and that a standard runner carries 14 GB of SSD storage ([GitHub-hosted runners](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13). Copy Text ``` name: test on: push: branches: [main] jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ hashFiles('package-lock.json') }} - run: npm ci - run: npm run build - run: npm test - uses: actions/upload-artifact@v4 with: name: dist path: dist/ ``` Follow the disk through that job. `actions/checkout` writes the repository into the work directory on the machine's disk. `actions/cache` restores the npm cache into `~/.npm` on the same disk, `npm ci` expands `node_modules` there, and `npm run build` writes `dist/` there. At the end of the job the machine is discarded, and every one of those paths goes with it. The next run of the same workflow starts on a different machine with an empty disk. Two steps in that file exist because the disk is ephemeral. `actions/cache` uploads the cache to GitHub-hosted storage so a later job on a different machine can restore it, and GitHub removes entries that have not been accessed in over 7 days while holding a default limit of 10 GB per repository ([dependency caching](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13). `actions/upload-artifact` moves `dist/` off the machine for the same reason. ### [What survives one job](#what-survives-one-job)Copy Anchor Link | Written during the job | Location | Survives the job | | -------------------------------------------- | ---------------------------------- | ----------------------------------------- | | Checkout of the repository | Runner work directory | No | | node\_modules from npm ci | Runner work directory | No | | Restored npm cache in \~/.npm | Runner home directory | No | | Docker image layers built by the job | Local Docker storage on the runner | No | | Cache entry saved by actions/cache | GitHub-hosted cache storage | Yes, subject to the 7 day and 10 GB rules | | Artifact uploaded by actions/upload-artifact | GitHub-hosted artifact storage | Yes, for the configured retention period | | Image pushed to a registry | Container registry | Yes | A self-hosted runner changes the answer only when its machine outlives the job. A long lived self-hosted runner keeps its disk between jobs, which is where a stale `node_modules` directory or a half written file from a cancelled run comes from. A runner registered with `--ephemeral` takes one job and then goes away with its machine, which puts the disk back on the ephemeral side of the boundary and gives every job the same starting state. ## [Related Terms](#related-terms)Copy Anchor Link * [Local SSD storage physically attached to an instance](/glossary/local-ssd): the instance store device behind a `d` or `lssd` instance type suffix, and how its lifecycle differs from a network attached block volume. * [What happens to runner disks after a GitHub Actions job](/answers/what-happens-to-runner-disks-after-a-job): the disk lifecycle for hosted, self-hosted, and ephemeral runners once a job reports its conclusion. * [Ephemeral runners that accept one job and then deregister](/glossary/ephemeral-runner): the `--ephemeral` registration flag and why autoscaling controllers depend on it. * [Local SSD detection and mounting on Linux runners](/docs/ci/features/local-ssd): how local NVMe devices are detected, formatted, and mounted for the work directory. * [Runner security documentation](/docs/ci/security): compute isolation, storage volumes, and secret handling for runners. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is ephemeral storage? Ephemeral storage is disk space whose contents are bound to the lifetime of the compute that owns it. When the instance, container, or pod goes away, the data on that disk goes with it. AWS documents instance store volumes this way, Docker documents the writable container layer this way, and Kubernetes documents emptyDir volumes this way. ### Is ephemeral storage the same as a temporary directory? A temporary directory such as /tmp is a path convention inside one filesystem, and the operating system or a cleanup job decides when its contents are removed. Ephemeral storage describes the durability of the whole volume, so a file in a home directory on an ephemeral disk disappears at the same moment as a file in /tmp on that disk. ### What happens to files a GitHub Actions job writes to the runner disk? They sit in the runner work directory for the length of the job. GitHub-hosted runners give each job a new virtual machine, so anything that has to outlive the job leaves the machine first through an artifact upload, a cache upload, or a push to a registry or object store. --- # Fail Fast | WarpBuild URL: https://www.warpbuild.com/glossary/fail-fast Description: Fail-fast is the matrix strategy setting that cancels the remaining matrix jobs when one leg fails. What it defaults to, and when to switch it off. # Fail Fast Fail-fast is the matrix strategy setting that cancels the remaining matrix jobs when one leg fails. What it defaults to, and when to switch it off. Fail-fast is the matrix strategy setting that cancels the remaining jobs in a matrix as soon as one of its jobs fails. It lives under `jobs..strategy` in a workflow file, it defaults to `true`, and while it is on, the first failing combination ends the other combinations before they reach a conclusion. The setting decides how much a single run tells you. Cancelling early stops spending machine time on combinations that may be about to fail for the same reason, while letting every leg finish produces the complete failure list from one run. ## [Definition](#definition)Copy Anchor Link `fail-fast` is a boolean under a job's `strategy` key, alongside `matrix` and `max-parallel`. GitHub documents the default as `true` and describes the behavior as cancelling all in-progress and queued jobs in the matrix when any job in that matrix fails ([GitHub workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstrategyfail-fast), checked on 2026-08-13). Three details narrow what the word covers. The scope is one matrix. A workflow with three matrix jobs has three independent `fail-fast` decisions, and a failure in one job's matrix does not cancel the legs of another job's matrix. Jobs that list the failing job in `needs` are skipped for the usual dependency reason instead. The conclusion is `cancelled` rather than `failure`. A leg that was cancelled midway reports no test result, so the run view shows one red leg and several grey ones, and the grey ones are unknown rather than passing. The trigger is a failed job, so the setting interacts with `continue-on-error`. A leg marked `continue-on-error: true` that fails does not fail the workflow run, which is the documented way to carry an experimental combination inside a matrix that otherwise stops on failure. | Key | Where it sits | What it decides | | -------------------------------- | ----------------------------- | --------------------------------------------------------------------------- | | strategy.fail-fast | Under jobs..strategy | Whether the first failing leg cancels the rest of the matrix. Default true. | | strategy.max-parallel | Under jobs..strategy | How many legs of the matrix run at the same time. | | jobs..continue-on-error | On the job | Whether a failing job marks the workflow run as failed. | | steps\[\*\].continue-on-error | On a step | Whether a failing step marks its job as failed. | A matrix can expand to at most 256 jobs per workflow run, so the number of legs a single failure can cancel is bounded by that limit ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). ### [What each setting costs](#what-each-setting-costs)Copy Anchor Link With `fail-fast` on its default, a run stops at the first failure and the minutes already spent on the cancelled legs are still spent. If two combinations are broken, the second one appears only after the first is fixed, so the debugging loop is one run per failure. With `fail-fast: false`, every combination runs to completion and bills its own minutes, and one run produces the whole failure list. The trade is machine time against the number of round trips it takes to see the true state of the matrix. ## [Example](#example)Copy Anchor Link This workflow tests six combinations, three operating systems against two Node versions, with `fail-fast: false` so that every combination reports. Copy Text ``` name: test on: pull_request: jobs: test: strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] node: [20, 22] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: npm ci - run: npm test -- --reporter=junit --outputFile=reports/junit.xml - uses: actions/upload-artifact@v4 if: always() with: name: junit-${{ matrix.os }}-node${{ matrix.node }} path: reports/ ``` Two lines in that file exist because of the setting. `fail-fast: false` keeps the five healthy legs running when one breaks. `if: always()` on the upload step keeps the report from a leg that was cancelled or failed, since a step guarded only by the default success condition is skipped the moment the job stops being successful. The artifact name includes both matrix values because `actions/upload-artifact@v4` rejects two uploads under the same name in one run, and a matrix would otherwise send six uploads to one name. Now take a run where the Windows leg on Node 20 fails two minutes in and the macOS leg on Node 22 fails at four minutes, while the other four legs need six minutes each to pass. | Leg | With the default fail-fast: true | With fail-fast: false | | ------------------------------ | -------------------------------------- | ----------------------------------- | | ubuntu-latest, Node 20 | Cancelled at 2 minutes, result unknown | Passes at 6 minutes | | ubuntu-latest, Node 22 | Cancelled at 2 minutes, result unknown | Passes at 6 minutes | | macos-latest, Node 20 | Cancelled at 2 minutes, result unknown | Passes at 6 minutes | | macos-latest, Node 22 | Cancelled at 2 minutes, result unknown | Fails at 4 minutes, report uploaded | | windows-latest, Node 20 | Fails at 2 minutes, report uploaded | Fails at 2 minutes, report uploaded | | windows-latest, Node 22 | Cancelled at 2 minutes, result unknown | Passes at 6 minutes | | Total job minutes | 12 | 30 | | Failures visible after one run | 1 of 2 | 2 of 2 | The default run is cheaper by 18 job minutes and leaves one of the two real failures undiscovered. A developer fixes the Windows failure, pushes, and meets the macOS failure on the next run, so the wall clock cost of the second setting is measured against an extra full pipeline round trip rather than against zero. That trade shifts with what the matrix is for. A matrix that exists to prove one artifact builds everywhere can stop at the first failure, because the artifact is already unshippable. A matrix that shards a test suite has legs that fail for unrelated reasons, and cancelling the siblings throws away results the run had already produced. ## [Related Terms](#related-terms)Copy Anchor Link * [Build matrix and how combinations expand](/glossary/build-matrix): what the `matrix` key generates, how `include` and `exclude` reshape the set, and where the 256 job ceiling applies. * [Building and tuning a GitHub Actions matrix](/guides/github-actions-matrix-builds): configuring a matrix end to end, including `max-parallel`, artifact naming, and merging results from the legs. * [Flaky tests and why one leg fails on unchanged code](/glossary/flaky-test): the failure mode that makes a default `fail-fast` matrix cancel five healthy legs on the strength of one unreliable one. * [WarpBuild cloud runner catalog](/docs/ci/cloud-runners): the operating systems and sizes available for each `os` value a matrix expands. * [WarpBuild reports documentation](/docs/ci/features/reports): per job duration, queue time, and billed time for each leg of a matrix, with CSV export. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is fail-fast in a GitHub Actions matrix? It is the key under a job's strategy block that decides what happens to the rest of a matrix when one combination fails. With fail-fast set to true, the default, GitHub cancels every in-progress and queued job in that matrix as soon as the first one fails. With it set to false, each combination runs to its own conclusion and the run reports all of them. ### Does fail-fast default to true or false? True. A matrix with no fail-fast key behaves as if fail-fast were set to true, so the first failing combination cancels its siblings. Writing fail-fast false is the only way to keep every leg running. ### When should a matrix set fail-fast to false? When the value of the run is the full list of failures rather than the earliest one. Cross platform matrices, version compatibility matrices, and sharded test suites all fit, because a single leg cancelling the others hides how many combinations are broken and forces one run per fix. --- # Flaky Test | WarpBuild URL: https://www.warpbuild.com/glossary/flaky-test Description: A flaky test passes and fails on the same commit. The mechanisms behind flakiness, how to measure a flake rate, and how to confirm one in GitHub Actions. # Flaky Test A flaky test passes and fails on the same commit. The mechanisms behind flakiness, how to measure a flake rate, and how to confirm one in GitHub Actions. A flaky test is a test that passes on one execution and fails on the next against the same code, with nothing in the source changing between the two. The result is nondeterministic because the test depends on something the code under test does not control: elapsed time, state that another test left behind, or the order the suite happened to run in. Flakiness is a property of the test itself. A busier or slower machine changes how often a latent flake surfaces, so one suite can look stable on a laptop and unstable in GitHub Actions while the defect sits in the test the whole time. ## [Definition](#definition)Copy Anchor Link A test is flaky when repeated executions against an identical commit, an identical dependency lockfile, and an identical environment produce both pass and fail outcomes. That wording sets the boundary on either side. A test that fails on every execution is a failing test and points at a real defect. A test that passes on every execution is a passing test. Flakiness occupies the region between those two, and it is measured as a rate rather than reported as a state. The flake rate of a test is the fraction of executions on one commit that fail. A test that fails 3 executions out of 100 has a flake rate of 3 percent. Because the rate is a probability, one green re-run says almost nothing about the test, and one red run establishes only that the rate sits above zero. ### [The three usual mechanisms](#the-three-usual-mechanisms)Copy Anchor Link | Mechanism | What the test depends on | Signature across runs | | ---------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | Timing | An event completing inside a fixed wait, a scheduler slice, a clock rollover, or a timeout | The failure rate rises with machine load, and a longer wait makes the failure disappear | | Shared state | A fixture, database row, temp file, global mock, port, or environment variable that another test also touches | The failing test name moves between runs, and the test passes when executed alone | | Order dependence | Setup performed by a test that happens to run earlier in the sequence | The suite passes in declaration order and fails once the order is shuffled or sharded | **Timing.** The test asserts on a condition that becomes true shortly after it looks. Any suite carrying a fixed `sleep` already holds this defect, because the sleep hides it on an idle machine and exposes it on a loaded one. Real clocks add their own variants, including tests that break at a month boundary or across a daylight saving change. **Shared state.** Two tests read and write the same thing. The write from one lands between the write and the assertion of the other. Parallel execution turns this from a rare event into a frequent one, because work that used to happen in sequence now overlaps. **Order dependence.** A test relies on setup performed by a test that runs before it. The suite stays green while the ordering holds and turns red as soon as a framework shuffles the sequence, shards the suite across workers, or somebody deletes the earlier test. Calls to services outside the repository sit slightly apart from those three. A rate limit, a DNS failure, or a 502 from a third-party host produces the same pass-and-fail pattern, and the repair is a fake or a recorded response rather than a change to the test's own logic. ### [Why a single re-run is weak evidence](#why-a-single-re-run-is-weak-evidence)Copy Anchor Link Re-running is the cheapest response to a red build and the weakest evidence about it. Repetition is what turns a rate into a number. The table below assumes independent executions and gives the chance a suite survives a fixed number of repetitions. | Flake rate per execution | Chance one re-run is green | Chance 30 repetitions are all green | | ------------------------ | -------------------------- | ----------------------------------- | | 1 percent | 99 percent | 74 percent | | 5 percent | 95 percent | 21 percent | | 10 percent | 90 percent | 4 percent | | 20 percent | 80 percent | 0.1 percent | Two readings fall out of that arithmetic. A green re-run is the expected outcome at every rate in the table, so it carries very little information on its own. And 30 repetitions surface a 5 percent flake about four times out of five, which is why a repetition matrix is the standard way to confirm one. ## [Example](#example)Copy Anchor Link The clearest flake to reproduce is a shared fixture under parallel execution. This workflow runs a Python suite across four workers: Copy Text ``` name: test on: [push] jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - run: pip install -r requirements.txt - run: pytest -n 4 tests/ ``` Two tests in that suite share one session-scoped fixture, and each resets the balance on it before acting: Copy Text ``` # tests/conftest.py @pytest.fixture(scope="session") def account(): return Account.objects.get_or_create(id=1)[0] # tests/test_billing.py def test_charge(account): account.set_balance(100) charge(account, 40) assert account.balance == 60 # tests/test_refunds.py def test_refund(account): account.set_balance(100) refund(account, 25) assert account.balance == 125 ``` With a single worker both tests pass every time, because each one resets the row, acts on it, and asserts before the next test starts. Add `-n 4` and the two tests execute at the same moment against the same row. When `test_charge` calls `set_balance(100)` after `test_refund` has already applied its refund, the refund assertion reads 100 instead of 125: Copy Text ``` FAILED tests/test_refunds.py::test_refund - assert 100 == 125 ``` Which of the two reports the failure depends on which one loses the race, so the failing test name moves between runs. That is the shared-state signature from the table above, and it is the detail that separates this from a genuine defect in the refund code. Two checks confirm the diagnosis. Running the file on its own with `pytest tests/test_refunds.py` passes on every attempt, which clears the refund logic. Running the whole suite with `pytest -n 0 tests/` passes as well, which places the cause in the parallel execution rather than in either assertion. ### [Confirming the rate with a repetition matrix](#confirming-the-rate-with-a-repetition-matrix)Copy Anchor Link A matrix runs the same commit many times and reports every result. That needs `fail-fast` set to `false`, so one red combination leaves the remaining combinations running instead of cancelling them ([GitHub workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstrategyfail-fast), checked on 2026-08-13): Copy Text ``` jobs: repeat: strategy: fail-fast: false matrix: attempt: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - run: pip install -r requirements.txt - run: pytest -n 4 tests/test_billing.py tests/test_refunds.py ``` Ten attempts against a suite whose per-execution flake rate is 20 percent come back all green about 11 percent of the time, since 0.8 raised to the tenth power is 0.107\. Ten attempts are therefore plenty for an obvious flake and thin for a rare one. Widen the matrix until attempts multiplied by the estimated rate predicts at least a few failures. The repair for this particular case is to stop sharing the row. Give each test its own record, scope the fixture to the function instead of the session, or key the record on the worker ID that the parallel plugin exposes to each process. ## [Related Terms](#related-terms)Copy Anchor Link * [Find and fix flaky GitHub Actions jobs](/guides/flaky-github-actions-jobs): how to rank jobs by success rate, tell resource starvation apart from genuine nondeterminism, and open a shell on the machine that produced the failure. * [Fail fast in a GitHub Actions matrix](/glossary/fail-fast): the matrix setting that cancels the remaining combinations after the first failure, and the reason a repetition matrix turns it off. * [How to retry a failed step in GitHub Actions](/answers/how-do-i-retry-a-failed-step-in-github-actions): the retry options available at the step level and what a retry hides about the underlying rate. * [GitHub workflow syntax reference for strategy.fail-fast](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstrategyfail-fast): the documented behavior of the matrix setting used in the repetition example. * [WarpBuild reports documentation](/docs/ci/features/reports): success rate, duration percentiles, and queue time percentiles per repository, workflow, and job name. * [Action-Debugger documentation](/docs/ci/tools/action-debugger): the open source action that pauses a workflow on a step and opens an SSH session on the runner. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### How is a flaky test different from a broken test? A broken test fails on every execution against the same commit and points at a defect in the code or in the assertion. A flaky test produces both outcomes on that same commit, so it is described by a rate rather than by a state. A test that fails 3 times in 100 executions has a flake rate of 3 percent. ### Does running tests in parallel cause flakiness? Parallel execution exposes flakiness that was already present. Shared state and order assumptions are defects while the suite runs serially too, and parallelism widens the window in which two tests touch the same thing at once. Running the same suite with a single worker is the fastest check for whether shared state is the cause. ### How many times should I re-run a test to confirm it is flaky? Enough repetitions that the expected number of failures is at least a few, which is repetitions multiplied by the estimated flake rate. Thirty repetitions of a test that fails 5 percent of the time come back all green only about 21 percent of the time, so thirty attempts catch that test roughly four times in five. --- # GitHub Actions Runner | WarpBuild URL: https://www.warpbuild.com/glossary/github-actions-runner Description: A GitHub Actions runner is the machine that executes one workflow job, picked by the labels in runs-on. How runners register, match jobs, and bill. # GitHub Actions Runner A GitHub Actions runner is the machine that executes one workflow job, picked by the labels in runs-on. How runners register, match jobs, and bill. A GitHub Actions runner is the machine that executes one job from a GitHub Actions workflow. Which machine picks up that job is decided by the labels in the job's `runs-on` key, because GitHub hands a queued job only to an online runner that advertises every label the job asked for. Most of what confuses people about runners follows from that single matching rule. A job that never starts, a job that runs on the wrong hardware, and a job that finds a stale `node_modules` directory from a previous run are all label and lifecycle questions rather than workflow syntax questions. ## [Definition](#definition)Copy Anchor Link A runner has two parts. The first is a machine: a virtual machine, a container, or a physical host. The second is the runner agent installed on that machine. The agent is open source, published at [github.com/actions/runner](https://github.com/actions/runner) under the MIT license, and it is the same binary whether GitHub supplies the machine or you do. The agent is configured once with `config.sh` on Linux and macOS or `config.cmd` on Windows. Configuration takes a registration token, a registration URL, an optional runner group, and a list of labels. From then on the agent authenticates with credentials it stored locally, so the registration token is short lived and is used only at setup. ### [How a runner gets work](#how-a-runner-gets-work)Copy Anchor Link The agent opens an outbound HTTPS long poll to GitHub and waits. A runner therefore needs no inbound network access and no public address, which is why a runner works inside a private subnet behind a firewall that allows outbound connections only. When a job in a workflow becomes eligible to run, GitHub places it in a queue and sends a message to a runner that satisfies the job. The agent then does the following, in order: 1. Receives the job message on its open connection and acknowledges it, which claims the job so that no other runner takes it. 2. Spawns a worker process for the job so that a crash in the job does not take down the listener. 3. Downloads every action referenced by a `uses` step, resolving the ref to a commit and fetching the action's repository archive. 4. Runs each step in sequence, streaming stdout and stderr back to GitHub in chunks so that logs appear live in the run view. 5. Uploads any files the job asked for through `actions/upload-artifact` and any cache entries written by `actions/cache`. 6. Reports the job conclusion (success, failure, cancelled, or skipped) and returns to waiting, or deregisters if it was configured as ephemeral. One runner runs one job at a time. Concurrency across a repository comes from having more runners, not from a single runner accepting parallel jobs. A matrix of twelve jobs needs twelve eligible runners to run all twelve at once. ### [How labels route a job](#how-labels-route-a-job)Copy Anchor Link `runs-on` accepts three shapes. A single label is written as a string. Several labels are written as an array, and the runner must carry all of them. A third object form selects a runner group and then filters inside it: Copy Text ``` jobs: string-form: runs-on: ubuntu-latest array-form: runs-on: [self-hosted, linux, x64] group-form: runs-on: group: production-runners labels: [linux, x64] ``` Matching is a subset test in one direction. Every label in `runs-on` must appear on the runner. Labels the runner carries beyond that set are ignored, so a runner advertising `self-hosted`, `linux`, `x64`, and `large` is eligible for a job requesting `[self-hosted, linux]`. A runner advertising only `self-hosted` and `linux` is skipped for a job requesting `[self-hosted, linux, x64]`. Labels are opaque strings. GitHub does not verify that a runner labeled `x64` is an x86-64 machine, or that a runner labeled `large` has more than two cores. The label is a routing key chosen by whoever registered the runner, and keeping it honest is the registrar's job. ### [Registration scope and runner groups](#registration-scope-and-runner-groups)Copy Anchor Link A runner registers at one of three scopes: a single repository, an organization, or an enterprise. Scope decides which workflows are allowed to queue jobs on that runner. A repository-scoped runner is invisible to every other repository, which is the most common reason a correct-looking label never matches. Runner groups add a second filter on top of scope. An organization can place a fleet in a group and then restrict the group to named repositories, or to public repositories only, or to selected workflows. The object form of `runs-on` shown above is how a job targets a specific group by name. GitHub documents a queue limit for jobs waiting on self-hosted runners: a job that has been queued for 24 hours is cancelled ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). A job stuck behind a label typo therefore fails silently a day later rather than failing fast. ### [Persistent runners and ephemeral runners](#persistent-runners-and-ephemeral-runners)Copy Anchor Link Lifecycle divides runners again, and the split matters more than machine size for build correctness. **Persistent runners** stay registered and accept job after job. Whatever the previous job left on disk is still there: a populated package manager cache, Docker layers, a checked out repository, and also a half written file from a job that was cancelled. Warm caches make repeat builds faster. Leaked state makes failures hard to reproduce, because the failing input is the machine's history rather than the commit. **Ephemeral runners** are started with `--ephemeral`. The agent accepts exactly one job, then deregisters and exits. Every job begins on a machine with a known starting state. Ephemeral registration is what makes autoscaling correct. A controller watches the queue depth, starts one runner per queued job, and lets each runner disappear when its job ends, with no risk that a runner is terminated mid job. Actions Runner Controller, GitHub's Kubernetes based controller, works this way, and so do managed runner providers. ### [The three hosting categories](#the-three-hosting-categories)Copy Anchor Link Every runner falls into one of three categories. The categories differ in who owns the machine and who operates the agent, and nothing else about the matching rules changes between them. | Category | Who supplies the machine | Who operates the agent | Typical labels | | ------------- | --------------------------------------------- | ------------------------------------------------------ | ------------------------------------------- | | GitHub-hosted | GitHub | GitHub | ubuntu-latest, windows-latest, macos-latest | | Self-hosted | You, in your own cloud account or data center | You, or a controller such as Actions Runner Controller | self-hosted plus custom labels you choose | | Managed | A runner provider | The provider, through the same GitHub registration API | Provider specific labels | **GitHub-hosted runners** are provisioned fresh for each job from GitHub's pools and discarded when the job finishes. The machine sizes, the preinstalled software, and the regions are fixed by GitHub. Larger runner shapes are available on paid plans and are selected by their own labels. **Self-hosted runners** are machines you supply and register. You choose the operating system, the machine size, the preinstalled tooling, the disk, and the network placement. You also own patching, image builds, autoscaling, and the idle cost of any machine that is up but not running a job. **Managed runners** sit between the two. A provider supplies the machines and registers them against your organization through the same registration API a self-hosted runner uses. Because registration is identical, GitHub treats them as self-hosted runners, and moving a workflow between fleets is a change to one `runs-on` line rather than a rewrite of the workflow. ## [Example](#example)Copy Anchor Link This workflow sends its job to a GitHub-hosted Linux runner. `ubuntu-latest` is the label GitHub documents for its default hosted Linux image. Copy Text ``` name: test on: push: branches: [main] jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test ``` When that push lands, GitHub queues one job named `unit-tests`, selects a machine from its hosted Linux pool, and streams the log lines from `npm ci` and `npm test` into the run view. The machine is destroyed afterwards. ### [Claiming the same job with a different runner](#claiming-the-same-job-with-a-different-runner)Copy Anchor Link Changing the label changes which fleet claims the job. Nothing else in the file moves: Copy Text ``` jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test ``` The job is now queued for a runner advertising the label `warp-ubuntu-latest-x64-4x`. A runner carrying that label picks up the message, claims the job, and runs the same six steps. Labels of this shape encode the operating system, the architecture, and the machine size, so the label alone tells a reader which machine the job lands on. The table below maps the Linux labels in the WarpBuild runner catalog to the machine each one selects. Sizes are from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Label | OS | vCPU | RAM | Storage | Alias | | --------------------------- | ------------ | ---- | ----- | --------- | ------------------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | warp-ubuntu-2404-x64-2x | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | warp-ubuntu-2404-x64-4x | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | warp-ubuntu-2404-x64-8x | | warp-ubuntu-latest-arm64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | warp-ubuntu-2404-arm64-2x | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | warp-ubuntu-2404-arm64-4x | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | warp-ubuntu-2404-arm64-8x | Architecture is part of the label rather than part of the workflow body, so a matrix can fan the same steps across both architectures: Copy Text ``` jobs: build: strategy: matrix: runner: - warp-ubuntu-latest-x64-4x - warp-ubuntu-latest-arm64-4x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - run: make build ``` One image detail is worth knowing before a job is moved across architectures: the Ubuntu 24.04 ARM64 image sets the work directory to `/runner/_work`, while GitHub's own runners use `/home/runner/work/`. A step that hardcodes the GitHub path fails on the ARM64 image. The [Linux ARM64 runner page](/runners/linux-arm64) lists the rest. ### [Labels that carry options](#labels-that-carry-options)Copy Anchor Link Some fleets accept options appended to the label with a semicolon. The label is still one opaque routing string to GitHub, and the provider parses the suffix on its side: Copy Text ``` jobs: integration: runs-on: warp-ubuntu-latest-x64-2x;snapshot.key=my-project-snapshot steps: - uses: actions/checkout@v4 - run: ./scripts/integration-test.sh ``` That form asks for a runner booted from a saved machine image rather than a cold base image. The [snapshot runners answer page](/answers/what-are-snapshot-runners) covers when a job benefits from one, and the [snapshot runner page](/runners/snapshot) lists the label options. ### [How runners are billed](#how-runners-are-billed)Copy Anchor Link Billing follows the hosting category, and the three models are structurally different. GitHub-hosted runners are billed by GitHub per minute of job time, rounded up per job, at published list prices. Plans include an allowance of standard runner minutes for private repositories, and jobs in public repositories run at no charge on standard runners. Beyond the allowance, and for every larger runner shape, the meter runs from the first minute. Take a repository that runs 1,000 jobs a month at 6 minutes each, which is 6,000 job minutes. Applying GitHub's published list prices gives the following monthly bill for the job time alone. Rates are from [GitHub's Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the plan pages at [github.com/pricing](https://github.com/pricing), checked on 2026-08-13. | GitHub-hosted runner | Shape | List price per minute | 6,000 minutes | | ---------------------------------------- | -------------- | --------------------- | ------------- | | ubuntu-latest on private repositories | 2 vCPU, 8 GB | $0.006 | $36.00 | | 4-core Linux larger runner | 4 vCPU, 16 GB | $0.012 | $72.00 | | 8-core Linux larger runner | 8 vCPU, 32 GB | $0.022 | $132.00 | | 16-core Linux larger runner | 16 vCPU, 64 GB | $0.042 | $252.00 | | ubuntu-24.04-arm on private repositories | 2 vCPU, 8 GB | $0.005 | $30.00 | | 4-core Windows larger runner | 4 vCPU, 16 GB | $0.022 | $132.00 | | macOS ARM64 xlarge | 5 vCPU, 14 GB | $0.102 | $612.00 | Two things fall out of that table. Machine size drives the bill more than job count does, so a workload that finishes in fewer minutes on a bigger machine does not automatically cost less. And platform matters more than size: the same 6,000 minutes cost $36.00 on standard hosted Linux and $612.00 on the hosted macOS ARM64 shape. Self-hosted runners carry no GitHub per minute charge. You pay your cloud provider for instance time instead, and the unit is wall clock hours rather than job minutes: Copy Text ``` monthly cost = instances x hours running x hourly instance rate ``` The gap between those two units is where self-hosted budgets go wrong. Those same 6,000 job minutes are 100 hours of work. A single always-on runner is up for roughly 730 hours in a 30 day month, so the fleet is idle about 86 percent of the time while still being billed. Scaling to zero between jobs closes the gap, which is the argument for ephemeral runners plus a queue-driven controller. Storage, network transfer, and image build time sit on top. Managed runners are billed per minute of job time by the provider, in the GitHub-hosted style, while the operational model stays in the self-hosted style. Taking the label from the example above as the reference point, `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13\. Current per minute rates by runner type are on the [WarpBuild pricing page](/pricing), and the [quick start guide](/docs/ci/quick-start) walks through pointing a first workflow at a managed fleet. ## [Related Terms](#related-terms)Copy Anchor Link * [Linux ARM64 runners and the labels they register with](/runners/linux-arm64): the ARM64 label shapes, image differences, and the work directory change to watch for. * [What snapshot runners are](/answers/what-are-snapshot-runners): how a runner boots from a saved machine image instead of a cold base image. * [Snapshot runner labels and options](/runners/snapshot): the `snapshot.enabled` and `snapshot.key` label suffixes and their retention window. * [Can GitHub Actions run ARM64 jobs](/answers/can-github-actions-run-arm64): the ARM64 options available to a workflow and how the label selects them. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the full catalog of runner labels with sizes, images, and per minute rates. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What does a GitHub Actions runner actually do? A runner is a machine plus the open source runner agent installed on it. The agent registers with GitHub, advertises a set of labels, holds an outbound long poll waiting for work, then runs the steps of one job and streams logs and the final conclusion back to GitHub. ### How does GitHub decide which runner gets a job? GitHub matches the labels in the job's runs-on key against the labels each online runner in scope advertises. A runner is eligible only when it carries every label the job requested. Extra labels on the runner are ignored, and the first eligible runner to pick up the message claims the job. ### What is the difference between GitHub-hosted, self-hosted, and managed runners? GitHub-hosted runners are provisioned and discarded by GitHub from its own pools. Self-hosted runners are machines you supply and register yourself. Managed runners are machines a provider supplies and registers through the same GitHub registration API, so the workflow change is a label change. ### Why is my GitHub Actions job stuck in the queue with no logs? No online runner in scope advertises every label in runs-on. The usual causes are a typo in a label, a runner registered at the repository scope when the workflow lives in another repository, a runner group that excludes the repository, or a fleet with every runner already busy. --- # GitHub App | WarpBuild URL: https://www.warpbuild.com/glossary/github-app Description: A GitHub App is an installable integration with its own identity and scoped permissions that acts on a repository without using a human account. # GitHub App A GitHub App is an installable integration with its own identity and scoped permissions that acts on a repository without using a human account. A GitHub App is an installable integration with its own identity and its own scoped permissions, which acts on a repository without using a human account. An account owner installs the app, chooses which repositories it can reach, and approves the permission set the app declared, after which the app authenticates as itself and its activity is attributed to the app rather than to a person. That separation is why most automation on GitHub is built as an app. An app survives the offboarding of the engineer who set it up, its permissions are written down and reviewable, and the credentials it uses at runtime last an hour. ## [Definition](#definition)Copy Anchor Link A GitHub App has three moving parts: a registration, one or more installations, and a short chain of credentials. **Registration** happens once, under a user account or an organization. The registration fixes the app's name, its webhook URL and webhook secret, the repository, organization, and account permissions it asks for with an access level of none, read, or write on each, the webhook events it subscribes to, and whether other accounts may install it. Registration produces an App ID, a client ID, and a private key that the app owner downloads and stores. **Installation** is a separate act performed by whoever owns the target account. The installer picks all repositories or a named list, reviews the declared permissions, and accepts. GitHub creates an installation with its own installation ID. If the app later asks for a permission it did not declare before, every existing installation has to approve the change before the new permission takes effect, so the permission set cannot widen quietly. **Credentials** come in a chain, and each link is narrower than the one above it. Details below are from GitHub's [authentication with a GitHub App documentation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app), checked on 2026-08-13. 1. The app signs a JSON Web Token with its private key. That JWT identifies the app itself, has to expire within 10 minutes of issue, and is accepted only on app level endpoints such as listing installations and minting tokens. 2. The app exchanges the JWT for an installation access token at `POST /app/installations/{installation_id}/access_tokens`. The installation token expires after one hour and carries the installation's permissions across the installation's repositories. The request can narrow it further, down to a subset of the repositories or a subset of the permissions. 3. A user access token, obtained through the web flow, acts on behalf of a signed-in person. Every request made with it is limited by the app's permissions and by what that person can already reach, whichever is tighter. Because the app is its own actor, its writes are attributed to a bot account named after the app, in the form `app-slug[bot]`. Commits, comments, checks, and audit log entries all carry that name. The app consumes no seat and no license. One familiar credential is already an example of this shape. The `GITHUB_TOKEN` available inside a workflow run is an installation token minted automatically for the run, scoped to the repository that owns the workflow, and revoked when the job finishes. ## [Example](#example)Copy Anchor Link A platform that supplies self-hosted runners has to learn that a job is queued and then attach a machine to the right pool. As a GitHub App, that is a small and legible permission set. The permission names below are GitHub's own, from the [permissions required for GitHub Apps reference](https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps) and the [webhook events reference](https://docs.github.com/en/webhooks/webhook-events-and-payloads), checked on 2026-08-13. | Permission or event | Access | What it buys | | --------------------------------- | -------------- | ------------------------------------------------------------------- | | Repository: Metadata | Read | Mandatory on every app. Basic repository information. | | Repository: Actions | Read | Read workflow runs and jobs, and receive the workflow\_job webhook. | | Repository: Administration | Read and write | Manage runners registered at the repository scope. | | Organization: Self-hosted runners | Read and write | Manage runners and runner groups at the organization scope. | | Webhook event: workflow\_job | Subscribed | Delivery on queued, in\_progress, and completed for each job. | The `workflow_job` payload carries the job's `labels` array, so the platform reads which pool a job is asking for before any machine has been started. The set leaves out repository Contents, so an app configured this way has no read access to source code. Inside a workflow, an app is normally used to get a token that reaches further than `GITHUB_TOKEN` does. GitHub publishes an action for the exchange: Copy Text ``` name: sync-manifest on: push: branches: [main] jobs: update-downstream: runs-on: ubuntu-latest steps: - uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ vars.RELEASE_APP_ID }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: platform-manifests - uses: actions/checkout@v4 with: repository: ${{ github.repository_owner }}/platform-manifests token: ${{ steps.app-token.outputs.token }} - run: ./scripts/bump-image-tag.sh - run: | git commit -am "bump image tag" git push ``` The first step turns an App ID and a private key into an installation token limited to one repository. The checkout and the push use that token, so the commit lands in `platform-manifests` attributed to the app's bot account, and the credential is dead an hour later. Doing the same job with a personal access token would put an engineer's identity on every automated commit and leave a long-lived credential in the secret store. ### [Installation tokens against personal access tokens](#installation-tokens-against-personal-access-tokens)Copy Anchor Link | Property | Installation access token | Personal access token | | --------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------- | | Acts as | the app, shown as app-slug\[bot\] | the human who created it | | Lifetime | one hour, minted on demand | chosen by the creator, up to no expiry for classic tokens | | Repository reach | the repositories chosen at install, narrowable per token | what the owner can reach, or a selected list for fine-grained tokens | | Permission set | declared by the app, approved by the installer | chosen by the owner and changeable at any time | | When the owner leaves the company | unaffected | revoked with the account | | Rate limit budget | per installation, scaling with the size of the organization | 5,000 requests per hour shared across everything that person runs | | Audit trail | attributed to the app | attributed to the person | Rate limit figures are from GitHub's [rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api), checked on 2026-08-13. An app registered against a GitHub Enterprise instance follows the same shape, with the registration living on the enterprise and shared across the organizations inside it. The [GitHub Enterprise App setup documentation](/docs/ci/ghe) walks through one such flow, including the enterprise slug, the host, and the credentials copied back from the App settings page. ## [Related Terms](#related-terms)Copy Anchor Link * [Personal access tokens and how their reach differs from an app](/glossary/personal-access-token): the human-owned credential an app installation replaces, and the scopes it carries. * [GITHUB\_TOKEN, the installation token minted for each workflow run](/glossary/github-token): the app credential that already exists inside every job, and the permissions block that shapes it. * [Whether managed runners change your GitHub permissions model](/answers/does-using-managed-runners-change-my-github-permissions-model): which permissions an installed runner platform holds and which it does not. * [GitHub Enterprise App setup documentation](/docs/ci/ghe): registering an app on a GitHub Enterprise instance and installing it on an organization. * [Runner security documentation](/docs/ci/security): how runner isolation, storage, and secrets handling are documented. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### How is a GitHub App different from a personal access token? A GitHub App is an actor in its own right. It has its own identity, its own private key, and a permission set that an installer approves at install time, so its actions appear in the audit log under the app rather than under a person. A personal access token carries the identity and the reach of the human who created it, and it stops working when that account is suspended or removed. ### How long does a GitHub App installation token last? One hour. The app signs a JSON Web Token with its private key, exchanges that for an installation access token, and the installation token expires 60 minutes later. The JWT itself has to expire within 10 minutes of issue, so both credentials in the chain are short lived by design (GitHub authentication documentation, checked on 2026-08-13). ### What permissions does a GitHub App need to manage self-hosted runners? Read on repository Metadata, read on repository Actions so it can receive the workflow\_job webhook and read run state, read and write on repository Administration to manage repository level runners, and read and write on the organization Self-hosted runners permission for organization level runners and runner groups. Repository Contents is not part of that set. --- # GITHUB_TOKEN | WarpBuild URL: https://www.warpbuild.com/glossary/github-token Description: GITHUB_TOKEN is the GitHub App installation token created at the start of every workflow job, scoped to one repository and expiring when the job ends. # GITHUB\_TOKEN GITHUB\_TOKEN is the GitHub App installation token created at the start of every workflow job, scoped to one repository and expiring when the job ends. GITHUB\_TOKEN is the credential GitHub creates at the start of every workflow job so that steps can authenticate to GitHub as the repository the job is running in. It is a GitHub App installation access token whose permissions stop at that repository, and it expires when the job finishes ([GitHub documentation on GITHUB\_TOKEN](https://docs.github.com/en/actions/concepts/security/github%5Ftoken), checked on 2026-08-13). Nothing has to be created or rotated to get one. The questions worth answering about GITHUB\_TOKEN are what it is allowed to do inside a job, how long it lasts, and why a workflow that pushed a commit with it did not trigger the workflow that watches for pushes. ## [Definition](#definition)Copy Anchor Link Three properties describe the credential. * **Created per job.** GitHub fetches a fresh installation access token before each job begins. Two jobs in the same run hold two different tokens, and a rerun gets another one. * **Scoped to one repository.** The token authenticates as the GitHub Actions app installed on the repository that contains the workflow. It reaches no other repository in the organization. * **Short lived.** Expiry is tied to the job rather than to a calendar. The token dies when the job ends, and it has a ceiling even when the job keeps running. ### [Where the token appears in a job](#where-the-token-appears-in-a-job)Copy Anchor Link Steps read the token as `${{ secrets.GITHUB_TOKEN }}`, the same syntax used for secrets an operator stored. It never appears in the repository secrets list, because GitHub mints it rather than storing it. An action can also read the token through the `github.token` context even when the workflow does not pass it in, which is why granting narrow permissions matters more than controlling which steps receive the value ([GitHub tutorial on authenticating with GITHUB\_TOKEN](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication), checked on 2026-08-13). Shell steps usually export it as `GH_TOKEN` for the GitHub CLI or send it in an `Authorization: Bearer` header for REST calls. ### [What the token is allowed to do](#what-the-token-is-allowed-to-do)Copy Anchor Link Two settings decide the starting point. An organization or repository owner picks either the permissive default, which grants read and write across all scopes, or the restricted default, which grants read access to the `contents` and `packages` scopes alone. A new organization starts restricted ([GitHub organization settings for Actions](https://docs.github.com/en/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization), checked on 2026-08-13). The `permissions` key in the workflow file then adjusts that starting point for a whole workflow or for one job. Each scope takes `read`, `write`, or `none`, and `write` includes `read`. Naming any scope sets every scope left unnamed to `none`, so a short block is a tight block. The table below lists the scopes reached for most often, with what `write` unlocks ([GitHub workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax), checked on 2026-08-13). | Scope | What write allows a step to do | Typical step that needs it | | -------------------- | ------------------------------------------- | ------------------------------------------------------ | | contents | Create a release, push a commit or tag | actions/checkout needs read; a release job needs write | | issues | Comment on, label, or close an issue | Triage automation | | pull-requests | Label, comment on, or update a pull request | A bot that posts build output on a pull request | | checks | Create and update check runs | A test reporter publishing results | | statuses | Set a commit status | A job reporting a status back to a commit | | packages | Upload and publish to GitHub Packages | A publish step pushing a package or container image | | deployments | Create a deployment | A job recording a deploy against an environment | | actions | Cancel a workflow run, read run metadata | A workflow that cancels older runs | | security-events | Update a code scanning alert | A scanner uploading SARIF results | | id-token | Request an OpenID Connect token | A step exchanging that token with a cloud provider | | attestations | Generate an artifact attestation | A build signing what it produced | | vulnerability-alerts | Read only, write is invalid | A job listing Dependabot alerts | ### [Limits worth knowing before the first API call](#limits-worth-knowing-before-the-first-api-call)Copy Anchor Link | Limit | Value | Where it bites | | -------------------------------------------------- | ------------------------------------------- | -------------------------------------------------- | | Expiry on a GitHub-hosted runner | Job end, 6 hours maximum | Long integration suites near the job ceiling | | Expiry on a self-hosted runner | Job end, refreshable for 24 hours | Jobs allowed to run up to 5 days | | REST rate limit | 1,000 requests per hour per repository | Matrix jobs that all poll the API | | REST rate limit, GitHub Enterprise Cloud resources | 15,000 requests per hour per repository | Large organizations on Enterprise Cloud | | Workflow triggering | Events raised by the token start no new run | A push step that expects the push workflow to fire | Sources for the table: the [GITHUB\_TOKEN concept page](https://docs.github.com/en/actions/concepts/security/github%5Ftoken) and the [REST rate limit reference](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api), both checked on 2026-08-13. The last row causes the most confusion. A job that commits with the token and expects a downstream workflow to pick the commit up sits waiting forever, because GitHub suppresses those events to stop a workflow from triggering itself. `workflow_dispatch` and `repository_dispatch` are always exempt, and `pull_request` events of type `opened`, `synchronize`, or `reopened` produce runs that wait for a maintainer to approve them. ## [Example](#example)Copy Anchor Link This workflow labels every new issue by calling the REST API with the token, and it states the two scopes the job needs. Copy Text ``` name: label-new-issues on: issues: types: [opened] jobs: triage: runs-on: ubuntu-latest permissions: contents: read issues: write steps: - name: Add a triage label run: | curl --request POST \ --url "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/labels" \ --header "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ --header "Accept: application/vnd.github+json" \ --data '{"labels":["triage"]}' \ --fail ``` Read the `permissions` block as the whole allowance for this job. `issues: write` is what makes the POST succeed. `contents: read` covers reading repository content. Every other scope is set to `none` because the block names two, so the same token fails a request to publish a package or create a deployment while this job runs. Writing the block pays off even when the organization default is already restrictive. The workflow file then records what the job needs, a reviewer sees the allowance in the diff, and the job keeps working after someone flips the organization default to permissive. A second pattern sets the floor at the top of the file and raises it per job: Copy Text ``` name: release on: push: tags: ["v*"] permissions: {} jobs: build: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v4 - run: make build publish: needs: build runs-on: ubuntu-latest permissions: contents: read packages: write id-token: write steps: - uses: actions/checkout@v4 - run: ./scripts/publish.sh ``` `permissions: {}` at the top sets every scope to `none` for any job that stays silent. The `build` job asks for read access to check out the tag. The `publish` job adds `packages: write` to push the artifact and `id-token: write` so a step can request an OpenID Connect token and exchange it with a cloud provider for short lived credentials, which removes the need for a stored cloud key. ## [Related Terms](#related-terms)Copy Anchor Link * [Workflow permissions and how each scope narrows the token](/glossary/workflow-permissions): the `permissions` key, its scope values, and how workflow level and job level blocks interact. * [How to set permissions for the GITHUB\_TOKEN in a workflow](/answers/how-do-i-set-permissions-for-the-github-token): the smallest correct block for common job shapes, with the settings that override it. * [OIDC token exchange and the id-token permission that enables it](/glossary/oidc-token-exchange): what a workflow trades the OpenID Connect token for, and how a cloud provider validates the claims. * [WarpBuild runner security documentation](/docs/ci/security): how runner isolation, storage, and secrets handling are documented. * [WarpBuild quick start](/docs/ci/quick-start): pointing a first workflow at a managed runner fleet. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the GITHUB\_TOKEN in GitHub Actions? GitHub creates a GITHUB\_TOKEN secret at the start of each workflow job. It is a GitHub App installation access token for the GitHub Actions app installed on the repository, its permissions are limited to the repository that contains the workflow, and steps read it as secrets.GITHUB\_TOKEN or through the github.token context. ### When does the GITHUB\_TOKEN expire? The token expires when the job finishes, or at its effective maximum lifetime if the job is still running. On GitHub-hosted runners the maximum job execution time is 6 hours, so the token lives 6 hours at most. On self-hosted runners a job may run up to 5 days, but the installation token can be refreshed for only 24 hours, so a longer job needs a different credential. ### Why did a push made with the GITHUB\_TOKEN not start another workflow run? Events raised by the GITHUB\_TOKEN do not create new workflow runs, which is how GitHub prevents recursion. The exceptions are workflow\_dispatch and repository\_dispatch, which always create runs, and pull\_request events with the opened, synchronize, or reopened activity types, which create runs in an approval-required state. --- # AWS Graviton | WarpBuild URL: https://www.warpbuild.com/glossary/graviton Description: AWS Graviton is the ARM64 processor family AWS designs for its own instances, so anything a service ships to a Graviton instance has to be built for arm64. # AWS Graviton AWS Graviton is the ARM64 processor family AWS designs for its own instances, so anything a service ships to a Graviton instance has to be built for arm64. AWS Graviton is the family of 64-bit ARM processors that AWS designs and installs in its own instances, so an instance type carrying a `g` after its generation number executes the ARM64 instruction set instead of x86-64\. Teams meet the term when a service moves onto those instances and every artifact it ships, from container images to compiled binaries, has to be produced for `arm64`. A Graviton instance carries no x86-64 translation layer. Whatever a service ships is either compiled for `arm64` or emulated at a cost in speed, which is why Graviton usually surfaces as a build question before it surfaces as an operations question. ## [Definition](#definition)Copy Anchor Link Graviton processors are designed by Annapurna Labs, an AWS subsidiary, and are built on Arm Neoverse cores. They are sold only as the CPU behind particular AWS instance types rather than as parts, so the term names a processor family and, by extension, the set of instance shapes that use it ([AWS Graviton](https://aws.amazon.com/ec2/graviton/), checked on 2026-08-13). To a compiler, a package manager, or a container registry, a Graviton instance is an ARM64 Linux machine like any other. Four generations have shipped so far. AWS keeps the current generation and the full instance family list on the page linked above, and older families are retired over time. | Generation | Announced | Arm core | Example instance families | | ---------- | --------- | ----------- | ------------------------- | | Graviton | 2018 | Cortex-A72 | A1 | | Graviton2 | 2019 | Neoverse N1 | M6g, C6g, R6g, T4g, X2gd | | Graviton3 | 2021 | Neoverse V1 | C7g, M7g, R7g | | Graviton4 | 2023 | Neoverse V2 | M8g, C8g, R8g, X8g | ### [Reading the instance type name](#reading-the-instance-type-name)Copy Anchor Link The processor is visible in the instance type string, which AWS assembles from four parts ([EC2 instance type naming conventions](https://docs.aws.amazon.com/ec2/latest/instancetypes/instance-type-names.html), checked on 2026-08-13). Taking `m7gd.2xlarge` apart: | Part | Value | Meaning | | ---------- | ------- | ------------------------------------------------------------ | | Family | m | general purpose, as against c compute, r memory, t burstable | | Generation | 7 | seventh generation of that family | | Attributes | g | AWS Graviton processor, as against i for Intel and a for AMD | | Attributes | d | local NVMe instance store attached | | Size | 2xlarge | the vCPU and memory step within the family | Position matters when reading the letters. A `g` among the attribute letters names the processor, while a `g` in the leading position names an accelerated computing family and leaves the CPU architecture unstated. The safest check is the architecture recorded against the AMI or the launch template rather than the letters alone. ### [What ARM64 means to a build](#what-arm64-means-to-a-build)Copy Anchor Link One architecture goes by two names across tooling, which accounts for most of the confusion in build scripts. The Linux kernel and RPM-based distributions report `aarch64`, while Docker, Go, and Debian-family distributions write `arm64`. Both refer to the same instruction set, and an artifact tagged with one runs on a machine reporting the other. | Where the value appears | Graviton target | x86-64 target | | -------------------------------------- | ------------------------- | ------------------------- | | uname -m | aarch64 | x86\_64 | | Docker and OCI platform string | linux/arm64 | linux/amd64 | | Go GOARCH | arm64 | amd64 | | Rust target triple | aarch64-unknown-linux-gnu | x86\_64-unknown-linux-gnu | | Debian and Ubuntu package architecture | arm64 | amd64 | | Python wheel platform tag | manylinux\_2\_28\_aarch64 | manylinux\_2\_28\_x86\_64 | Interpreted code moves between the two without a rebuild. Native code does not, and native code hides inside dependency trees: a Python wheel, a Node native addon, and a JVM library with a bundled shared object all pin an architecture inside an artifact that otherwise looks portable. AWS maintains language-by-language porting notes in the open source [aws-graviton-getting-started](https://github.com/aws/aws-graviton-getting-started) repository. ## [Example](#example)Copy Anchor Link A service runs as a container on ARM64 instances, so its GitHub Actions workflow has to publish a `linux/arm64` image. This workflow builds on GitHub's hosted ARM64 Linux runner, whose label is `ubuntu-24.04-arm` ([GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13), then reads back the architecture the registry recorded. Copy Text ``` name: publish on: push: branches: [main] jobs: image: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 - run: uname -m - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . platforms: linux/arm64 push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} - name: Confirm the published architecture run: | docker buildx imagetools inspect \ ghcr.io/${{ github.repository }}:${{ github.sha }} ``` The `uname -m` step prints `aarch64`, which confirms the machine before anything is compiled. The inspect step ends the run with the platform the registry holds: Copy Text ``` Name: ghcr.io/example/app:9c1f2ab MediaType: application/vnd.oci.image.manifest.v1+json Digest: sha256:5c8d1a2b3e4f50617283940a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e Manifests: Platform: linux/arm64 ``` Two properties of that workflow are worth naming. The compiler runs on the same architecture the service deploys to, so an `#ifdef` guarding an x86 intrinsic, a dependency with no `aarch64` wheel, or a test that depends on x86 memory ordering fails in the pull request instead of after deploy. And the published artifact is checked rather than assumed, because a `platforms` value dropped during a refactor produces an `amd64` image that passes every build step and then fails at startup on the instance with `exec format error`. The same build runs on an x64 machine by adding `docker/setup-qemu-action@v3` before the buildx step and keeping `platforms: linux/arm64`. Every instruction in the build then goes through emulation, which lengthens compile-heavy steps and breaks a handful of toolchains outright, so emulation is the fallback when no ARM64 machine is available rather than the default. When the same service also deploys to x86-64 hosts, the workflow builds each architecture and joins the results into one manifest list, so a single tag serves both. The job that produces the ARM64 half still belongs on an ARM64 machine for the reasons above. ## [Related Terms](#related-terms)Copy Anchor Link * [Building for Graviton targets on GitHub Actions](/guides/graviton-builds-github-actions): moving the build and test jobs of a pipeline that deploys to Graviton onto native ARM64 machines, with the image and path differences to expect. * [Linux ARM64 runners for GitHub Actions jobs](/runners/linux-arm64): the ARM64 runner labels, machine sizes, and images available to a workflow. * [Instance type, and how the name encodes processor and size](/glossary/instance-type): the rest of the naming scheme the table above pulls apart. * [AWS Graviton](https://aws.amazon.com/ec2/graviton/): the upstream generation list and the instance families built on each one. * [EC2 instance type naming conventions](https://docs.aws.amazon.com/ec2/latest/instancetypes/instance-type-names.html): the family letters, generation numbers, and attribute letters in full. * [WarpBuild cloud runner documentation](/docs/ci/cloud-runners): the runner labels available for x64 and ARM64 GitHub Actions jobs. * [WarpBuild AWS BYOC configuration](/docs/ci/byoc/aws/config): the AWS account prerequisites for running GitHub Actions jobs on instances in your own account. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### How do I tell whether an EC2 instance type uses Graviton? Read the letters between the generation number and the size. A g there means the instance runs on an AWS Graviton processor, so m7g, c8g, and r8gd are all ARM64 shapes. The leading letter is the instance family and follows a different rule, so a g in the first position names an accelerated computing family and says nothing about the CPU architecture. ### Is Graviton the same thing as ARM64? Graviton is one implementation of the ARM64 architecture. ARM64, which the Linux kernel reports as aarch64, is the instruction set, and Graviton is the processor family AWS builds to run it. A binary or container image produced for linux/arm64 runs on a Graviton instance and on other ARM64 machines as well. ### Do I have to rebuild container images to run on Graviton? Yes, when the current image contains only a linux/amd64 manifest. A Graviton instance has no x86-64 translation layer, so an amd64-only image fails at startup with an exec format error. The fix is a linux/arm64 build, published either as an arm64 image or as one entry in a manifest list that also carries the amd64 image. --- # IAM Instance Profile | WarpBuild URL: https://www.warpbuild.com/glossary/iam-instance-profile Description: An IAM instance profile is a container for one IAM role that an EC2 instance assumes, so code on the machine gets rotating AWS credentials with no stored keys. # IAM Instance Profile An IAM instance profile is a container for one IAM role that an EC2 instance assumes, so code on the machine gets rotating AWS credentials with no stored keys. An IAM instance profile is an AWS container that holds one IAM role and can be attached to an EC2 instance, so that anything running on the machine can assume that role. The instance metadata service on the attached machine serves temporary credentials for the role, which means code on the instance authenticates to AWS without an access key stored on disk, in an environment variable, or in a secrets store. For GitHub Actions this matters when jobs run on self-hosted runners backed by EC2\. The runner machine can carry its permissions in its instance profile, and the workflow file that runs on it holds no AWS credentials at all. ## [Definition](#definition)Copy Anchor Link An instance profile is a thin wrapper around a role. Five pieces have to line up before an instance can use one. | Object | What it holds | Who reads it | | ------------------------------ | ----------------------------------------- | ---------------------------------------------- | | IAM role | A name and an ARN, nothing executable | IAM | | Trust policy on the role | Which principals may assume the role | AWS STS, when the assume call is made | | Permissions policy on the role | The allowed actions and resources | Every AWS API the instance calls | | Instance profile | Exactly one role, plus its own ARN | EC2, at attach time | | iam:PassRole grant | Permission to hand this role to a service | IAM, when the instance is launched or modified | The trust policy is what makes the role usable by a machine. It has to name the EC2 service as the principal: Copy Text ``` { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "ec2.amazonaws.com" }, "Action": "sts:AssumeRole" } ] } ``` A role with the right permissions policy and the wrong trust policy attaches cleanly and then fails at credential time, which is a common first mistake. An instance profile can contain only one role ([AWS documentation on instance profiles](https://docs.aws.amazon.com/IAM/latest/UserGuide/id%5Froles%5Fuse%5Fswitch-role-ec2%5Finstance-profiles.html), checked on 2026-08-13). A machine that needs two distinct permission sets therefore needs one merged role, or two machines. When a role is created in the AWS console for an EC2 use case, the console creates an instance profile with the same name automatically. When a role is created through the API, the CLI, or Terraform, the profile is a separate resource that has to be created and then associated: Copy Text ``` aws iam create-instance-profile --instance-profile-name ci-runner-profile aws iam add-role-to-instance-profile \ --instance-profile-name ci-runner-profile \ --role-name ci-runner-role ``` ### [How the credentials reach the process](#how-the-credentials-reach-the-process)Copy Anchor Link The instance metadata service, reachable from the instance at the link local address `169.254.169.254`, exposes the credentials under a path keyed by the role name. With IMDSv2 the read takes a session token first: Copy Text ``` TOKEN=$(curl -sX PUT "http://169.254.169.254/latest/api/token" \ -H "X-aws-ec2-metadata-token-ttl-seconds: 300") curl -s -H "X-aws-ec2-metadata-token: ${TOKEN}" \ "http://169.254.169.254/latest/meta-data/iam/security-credentials/ci-runner-role" ``` The response is a small JSON document with `AccessKeyId`, `SecretAccessKey`, `Token`, and `Expiration`. AWS rotates those credentials and publishes the replacement set before the current one expires, so a long lived process on the instance keeps working without any rotation code of its own ([AWS documentation on IAM roles for Amazon EC2](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html), checked on 2026-08-13). Almost nothing calls that endpoint by hand. The AWS SDKs and the AWS CLI walk a default credential provider chain: environment variables first, then the shared credentials file, then the container credential endpoint, then the instance metadata service. When a build step runs `aws s3 cp` on an instance with a profile attached and no credentials configured anywhere else, the chain falls through to the metadata service and the call succeeds with no configuration in the workflow. ### [The scope of the grant](#the-scope-of-the-grant)Copy Anchor Link The permissions belong to the machine and not to any one process on it. Every job, every step, and every side process on that instance shares the same identity for as long as the profile is attached. Two consequences follow. The first is that the permission set has to be sized for the least trusted thing that will ever run on the machine. On a runner that executes code from pull requests, that is a strong argument for a narrow policy over a convenient one. The second is that a profile is a per fleet decision. Instances that need write access to a deployment bucket and instances that only read test fixtures belong in separate fleets with separate profiles, so that neither inherits the other's reach. An instance profile can be attached at launch or associated with a running instance later, and swapped with `aws ec2 replace-iam-instance-profile-association`. Equivalent mechanisms exist on the other major clouds: a Google Cloud VM runs as an attached service account, and an Azure virtual machine carries a managed identity. The shape is the same in all three cases, where the machine has an identity and the platform serves short lived credentials for it. ## [Example](#example)Copy Anchor Link A self-hosted GitHub Actions runner fleet needs to download a test fixture archive from one S3 bucket. The permissions policy on the role covers that bucket and nothing else: Copy Text ``` { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:GetObject", "s3:ListBucket"], "Resource": [ "arn:aws:s3:::example-test-fixtures", "arn:aws:s3:::example-test-fixtures/*" ] } ] } ``` The role is created with the EC2 trust policy above, the policy is attached, the role goes into the `ci-runner-profile` instance profile, and the profile is associated with the runner instances. The workflow then reads the fixtures with no credential configuration: Copy Text ``` name: integration on: push: branches: [main] jobs: integration-tests: runs-on: [self-hosted, linux, x64] steps: - uses: actions/checkout@v4 - name: Confirm the identity the runner is using run: aws sts get-caller-identity - name: Download fixtures run: | aws s3 cp \ s3://example-test-fixtures/seed.tar.gz \ "${RUNNER_TEMP}/seed.tar.gz" - run: ./scripts/load-fixtures.sh "${RUNNER_TEMP}/seed.tar.gz" ``` There is no `aws-actions/configure-aws-credentials` step, no `AWS_ACCESS_KEY_ID` secret, and no `permissions` grant for an identity token. The `aws sts get-caller-identity` step prints an assumed role ARN of the form `arn:aws:sts::111122223333:assumed-role/ci-runner-role/i-0abc123def456789a`, where the session name is the instance ID, which makes CloudTrail entries traceable back to the machine that made the call. Now change one line and watch the boundary hold. A step that reaches for a different bucket: Copy Text ``` - run: aws s3 cp ./build.log s3://example-deploy-artifacts/build.log ``` fails with `An error occurred (AccessDenied) when calling the PutObject operation`, because the policy names one bucket and grants read actions only. The failure comes from IAM rather than from anything in the workflow file, so a contributor cannot widen the runner's reach by editing the workflow. Two limits are worth stating plainly. Instance profiles work only on EC2 instances, so a job running on a hosted runner outside your account cannot use one. And because the identity belongs to the machine, a workflow that needs a per job or per branch identity uses an OIDC token exchange instead, where the branch condition is enforced in the role's trust policy against a claim GitHub signed. ## [Related Terms](#related-terms)Copy Anchor Link * [The instance metadata service that serves the credentials](/glossary/instance-metadata-service): the `169.254.169.254` endpoint, the IMDSv2 token flow, and why SSRF hardening matters on a runner. * [Attaching instance profiles to runners in your own AWS account](/byoc/aws/instance-profiles): profile ARNs, the `iam:PassRole` prerequisite, and per fleet scoping. * [Can an IAM role be attached to a BYOC runner](/answers/can-i-attach-an-iam-role-to-a-byoc-runner): the short answer and the configuration path. * [OIDC token exchange](/glossary/oidc-token-exchange): the per job alternative, where the credential is scoped to a repository and branch rather than to a machine. * [Instance profile setup documentation](/docs/ci/byoc/aws/instance-profile): the `iam:PassRole` policy and the verification command. * [AWS BYOC security hardening documentation](/docs/ci/byoc/aws/security): least privilege policy examples, IMDSv2, and network controls for runner instances. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is an IAM instance profile? An IAM instance profile is an AWS container that holds a single IAM role and can be attached to an EC2 instance. Once attached, the instance metadata service on that machine hands out temporary credentials for the role, and the AWS SDKs and CLI pick them up automatically, so no access key has to be written to disk or stored as a secret. ### What is the difference between an IAM role and an instance profile? The role carries the permissions policy and the trust policy. The instance profile is the wrapper that lets EC2 pass that role to a machine. An instance profile holds at most one role, and the console creates a profile with the same name as the role, which is why the two are often confused. ### Why does attaching an instance profile fail with a PassRole error? The identity launching or modifying the instance needs iam:PassRole on the role inside the profile. Handing a role to a machine is a privilege escalation path, so AWS requires that grant separately from ec2:RunInstances, usually scoped with a condition on iam:PassedToService equal to ec2.amazonaws.com. --- # Image Digest | WarpBuild URL: https://www.warpbuild.com/glossary/image-digest Description: An image digest is the SHA-256 hash of an image manifest, so it names one exact image. How a digest differs from a tag, and how to pin one in a workflow. # Image Digest An image digest is the SHA-256 hash of an image manifest, so it names one exact image. How a digest differs from a tag, and how to pin one in a workflow. A Docker image digest is the SHA-256 hash of an image manifest, written as `sha256:` followed by 64 hexadecimal characters, and it identifies one exact image. A tag such as `app:1.4.0` is a separate thing: a name the registry stores beside a manifest, and a later push can move that name onto different content, while a digest always resolves to the bytes it was computed from. That difference decides what a deploy actually ships. A pull by tag asks the registry what the name points at right now. A pull by digest asks for specific content, and the registry either serves that content or fails. ## [Definition](#definition)Copy Anchor Link A digest is a content address. The OCI image specification defines it as `algorithm:encoded`, with SHA-256 as the algorithm every registry and client is expected to support ([OCI image spec descriptor](https://github.com/opencontainers/image-spec/blob/main/descriptor.md), checked on 2026-08-13). The hashed document is the manifest, a small JSON file that lists the image config blob and every layer blob by its own digest and size. Because the manifest names its parts by hash, a change anywhere in the image propagates upward: editing a file changes a layer, the changed layer gets a new layer digest, that new digest appears in the manifest, the manifest bytes change, and the manifest digest changes with them. One 64-character string therefore covers the whole image transitively. ### [The identifiers a build produces](#the-identifiers-a-build-produces)Copy Anchor Link An image build produces several hashes, and they are easy to confuse because they are all rendered in the same `sha256:` form. | Identifier | Hashed document | Where it shows up | Usable in a pull reference | | --------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------- | | Manifest digest | The manifest JSON for one platform | docker push output, RepoDigests in docker inspect, the registry Docker-Content-Digest response header | Yes | | Index digest | The image index JSON listing one manifest per platform | The tag of a multi-platform build, docker buildx imagetools inspect | Yes | | Config digest | The image config JSON blob | The IMAGE ID column of docker images | No | | Layer digest | One compressed layer blob | docker buildx imagetools inspect --raw, registry blob paths | No | | Tag | Nothing is hashed; the registry stores a name that maps to a digest | The TAG column of docker images | Yes, and the result can change between pulls | The config digest is the row that catches people out. `docker images` prints it as the image ID, it is computed on the machine that built the image, and pasting it after an `@` in a pull reference fails, because the registry indexes manifests rather than config blobs. ### [Why a tag can move and a digest cannot](#why-a-tag-can-move-and-a-digest-cannot)Copy Anchor Link The registry API stores a manifest under a name and accepts a `PUT` for either a tag or a digest reference. Pushing to a tag that already exists rewrites the mapping, and the previous manifest stays addressable by its own digest until it is garbage collected ([OCI distribution spec](https://github.com/opencontainers/distribution-spec/blob/main/spec.md), checked on 2026-08-13). Nothing in the specification makes a version-shaped tag permanent. `v1.4.0` is a convention, and a retag or a force push can point it at a different build tomorrow. Several registries offer tag immutability as an opt-in setting on a repository, which is a registry feature rather than a property of tags in general. `latest` carries no special meaning either; it is the tag a client assumes when a reference omits one. A digest has the opposite property by construction. Recomputing the hash of the manifest you received is how a client verifies that it got what it asked for, so serving different bytes under the same digest fails verification at the pulling end. ### [Where a digest can be used](#where-a-digest-can-be-used)Copy Anchor Link Any place that accepts an image reference accepts the `name@sha256:...` form: * `docker pull ghcr.io/acme/api@sha256:...` pins a pull ([Docker CLI reference](https://docs.docker.com/reference/cli/docker/image/pull/), checked on 2026-08-13). * `FROM node:22@sha256:...` pins a base image in a Dockerfile. The tag stays readable for humans, and the digest is what resolves. * `image: ghcr.io/acme/api@sha256:...` pins a Kubernetes pod spec ([Kubernetes images documentation](https://kubernetes.io/docs/concepts/containers/images/), checked on 2026-08-13). Copying an image between registries preserves the digest as long as the manifest bytes travel unchanged, which is what `docker buildx imagetools create` and registry mirroring tools do. Rebuilding the image and pushing it somewhere else produces a different digest, even from the same commit, because timestamps and layer compression differ. ## [Example](#example)Copy Anchor Link A release workflow builds and pushes an image, then a second job deploys it. `docker/build-push-action` exposes the digest the registry returned as a step output, so the deploy job can reference the exact manifest the build job created ([docker/build-push-action outputs](https://github.com/docker/build-push-action#outputs), checked on 2026-08-13). Copy Text ``` name: release on: push: tags: ["v*"] jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write id-token: write attestations: write outputs: digest: ${{ steps.push.outputs.digest }} steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: push uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }} - uses: actions/attest-build-provenance@v2 with: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true deploy: needs: build runs-on: ubuntu-latest steps: - run: | kubectl set image deployment/api \ api=ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }} ``` The build job publishes the tag for humans and passes the digest to the deploy job. The attestation step takes the same digest, so the signed provenance record is bound to that manifest rather than to the moving name. Now suppose something touches the registry between the two jobs: a rerun of an older release workflow, a manual `docker push` from a laptop, or a promotion script that retags a staging build. The table traces what each deploy reference resolves to. | Event between build and deploy | Deploy that references :v1.4.0 | Deploy that references @sha256:... | | --------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | Nothing happens | The image this run built | The image this run built | | Another push moves v1.4.0 onto a different build | The other build, with no warning in the deploy log | The image this run built | | The tag is deleted | The pull fails with a manifest-unknown error | The image this run built, while the manifest is retained | | The registry garbage collects the untagged manifest | Unaffected | The pull fails, which surfaces the retention policy as an error rather than as a silent substitution | The second row is the case worth designing against, because both deploys succeed and only one of them ships what was tested. The same reasoning applies upward, to base images. Reading the digest behind a tag takes one command: Copy Text ``` docker buildx imagetools inspect node:22 --format '{{.Manifest.Digest}}' ``` Pasting that value into `FROM node:22@sha256:` freezes the base image for every future build of the Dockerfile, and a dependency update tool can bump both the tag and the digest together when a new base is available. ## [Related Terms](#related-terms)Copy Anchor Link * [How a container registry stores manifests, blobs, and tags](/glossary/container-registry): the storage model that makes a digest addressable and a tag rewritable. * [Provenance attestations and the digest they are bound to](/glossary/provenance-attestation): what the signed record covers, and why the subject is a digest. * [Building an image once and deploying the same digest to every environment](/guides/build-once-deploy-many-github-actions): the workflow pattern that carries a digest from build through staging to production. * [OCI image spec descriptor](https://github.com/opencontainers/image-spec/blob/main/descriptor.md): the upstream definition of the digest format and the algorithms registries accept. * [WarpBuild Docker builders documentation](/docs/ci/docker-builders): builder profiles and the actions that point a workflow build at a builder. * [WarpBuild security documentation](/docs/ci/security): how runners are isolated and how build storage is handled. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between an image digest and a tag? A digest is computed from the bytes of the image manifest, so the same digest always resolves to the same content. A tag is a name the registry stores beside a manifest, and a later push can point that name at a different manifest. Pulling by tag asks the registry what the name means right now; pulling by digest asks for specific content. ### Is the IMAGE ID from docker images the same as the digest? No. The IMAGE ID column shows the digest of the image config blob, which is computed locally when the image is built. The registry digest is computed over the manifest document that lists the config and the layers. The two values are different hashes of different documents, and only the manifest digest works in a pull reference. ### Does a digest still work for a multi-platform image? Yes. A multi-platform build publishes an image index that lists one manifest per platform, and the index itself has a digest. A client pulling that index digest reads the platform entries and fetches the manifest matching its own architecture, so one digest covers every platform in the build. --- # Incremental Build | WarpBuild URL: https://www.warpbuild.com/glossary/incremental-build Description: An incremental build reuses outputs from the previous build and recompiles only what changed. How the record works, and why a clean job rebuilds everything. # Incremental Build An incremental build reuses outputs from the previous build and recompiles only what changed. How the record works, and why a clean job rebuilds everything. An incremental build is a build that reuses the outputs of the previous build and recompiles only the units whose inputs changed. It works by keeping a record of what produced each output, then comparing that record against the current sources so every unchanged unit can be skipped. The record is the whole mechanism. On a developer machine it survives because the build directory stays on disk between builds. In GitHub Actions the job starts on a machine that is provisioned for that job and deleted when the job ends ([GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13), so the next run has nothing to compare against and compiles the whole tree. ## [Definition](#definition)Copy Anchor Link An incremental build has three parts, and losing any one of them turns the next build into a full build. 1. **Outputs from an earlier build**, still on disk: object files, generated headers, compiled classes, linked binaries. 2. **A record of the inputs that produced each output.** Depending on the tool this is the modification time of the output file itself, a separate fingerprint file written alongside it, or a hash stored in a cache directory. 3. **A staleness rule** that reads the record and decides, per unit, whether to reuse the output or rebuild it. Deleting the outputs forces a full compile. Deleting the record forces a full compile even when the outputs are all present, because the build system has no way to prove they are current and treats an unprovable output as stale. ### [The two staleness rules](#the-two-staleness-rules)Copy Anchor Link Build systems answer the staleness question in one of two ways, and the difference decides how a build behaves after a fresh checkout. **Timestamp rules** compare modification times. An output is current when every input that feeds it has a modification time older than the output. The comparison is cheap because the filesystem already stores the numbers, and it is exact only while the timestamps mean what the tool assumes they mean. **Content rules** compare hashes. The tool hashes the source text, the compiler identity, and the flag set, then looks for an output stored under that hash. Modification times are irrelevant, so a file whose timestamp moved but whose bytes did not still resolves to the same entry. The table below shows where each build system keeps its record and which rule it applies. Documentation links are the upstream project references, checked on 2026-08-13. | Build system | Where the record lives | Staleness rule | | --------------------------------------------------------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | [GNU make](https://www.gnu.org/software/make/manual/html%5Fnode/Rule-Introduction.html) | the output files themselves | A target is rebuilt when any prerequisite has a newer modification time. | | [Ninja](https://ninja-build.org/manual.html) | .ninja\_log and .ninja\_deps in the build directory | Modification times plus the exact command line that produced the output, so a changed flag rebuilds. | | [Cargo](https://doc.rust-lang.org/cargo/guide/build-cache.html) | target//.fingerprint | One fingerprint file per unit, holding source modification times, flags, and dependency versions. | | [Go](https://pkg.go.dev/cmd/go#hdr-Build%5Fand%5Ftest%5Fcaching) | the build cache directory reported by go env GOCACHE | A hash of the inputs, with modification times ignored entirely. | | [ccache](https://ccache.dev/manual/latest.html) | the cache directory reported by ccache --show-config | A hash of the preprocessed source, the compiler, and the flags. | | [MSBuild](https://learn.microsoft.com/en-us/visualstudio/msbuild/incremental-builds) | the Inputs and Outputs attributes declared on each target | The newest input modification time against the oldest output modification time. | ### [Why a fresh workspace breaks the timestamp rule](#why-a-fresh-workspace-breaks-the-timestamp-rule)Copy Anchor Link A checkout writes every file it creates with the current time, because the commit does not carry modification times and the tool has nothing else to write. A restored archive behaves the opposite way: `tar` sets each extracted file back to the modification time it had when the archive was written. Put those two together in one job and the ordering inverts. Every source file is stamped with the moment the job started, while every restored object file carries a stamp from an earlier day. A timestamp rule reads that and marks the entire tree out of date, which is the correct answer to the question it was asked. Content rules are unaffected, which is why a Go build or a ccache-backed C build recovers from a restore while a make or Cargo build does not. ## [Example](#example)Copy Anchor Link This workflow builds a Rust crate on a clean job. Cargo writes its outputs and its fingerprint database into `target/`, and both live only as long as the machine does. Copy Text ``` name: build on: push jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: cargo build --release ``` Every push runs the same three operations: a fresh machine, a fresh clone, an empty `target/`. Cargo finds no fingerprint files, so it compiles every crate in the dependency graph and the workspace itself, then the machine is deleted along with everything it produced. Now add a cache step so `target/` survives, and trace what changes. Copy Text ``` jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 with: path: target key: ${{ runner.os }}-cargo-target-${{ hashFiles('Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo-target- - run: cargo build --release ``` The three runs below start from an empty store and change one source file between run 2 and run 3. | Run | target/ at the start | Source modification times | What Cargo does | | --- | ----------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | 1 | empty | all set to the checkout time | Compiles every unit and writes a fingerprint per unit. The cache step saves target/. | | 2 | restored, fingerprints intact | all set to this run's checkout time | Every source is newer than the fingerprint recorded for it, so every unit is rebuilt despite the outputs being present. | | 3 | restored, one source edited | all set to this run's checkout time | Same result as run 2\. The edit is invisible, because the other files already looked edited. | Run 2 is the shape that surprises people. The outputs are on disk, the fingerprints are on disk, and the build still compiles everything, because the checkout moved the source timestamps forward past the record. A build directory that persists on the machine between runs avoids the inversion entirely: the sources keep the timestamps they had when they were last written, the outputs keep theirs, and the ordering the build system depends on holds. ## [Related Terms](#related-terms)Copy Anchor Link * [Making incremental builds work on GitHub Actions](/guides/incremental-builds-github-actions): the workspace shapes that keep a build directory and its timestamps intact across runs, and which build systems recover from a plain cache restore. * [Compiler cache and how it differs from an incremental build](/glossary/compiler-cache): the content-keyed store of compiled objects that survives a timestamp reset, and where the two mechanisms overlap. * [Snapshot runner labels and what the saved image keeps](/runners/snapshot): the label suffixes that boot a job from a saved machine image instead of a base image. * [WarpBuild snapshot runner documentation](/docs/ci/features/snapshot-runners): the `snapshot.enabled` and `snapshot.key` label options, the save action inputs, and the documented limitations. * [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners): how a hosted runner is provisioned for one job and deleted afterwards. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between an incremental build and a cached build? An incremental build reuses the outputs of the previous build in place, using a record the build system wrote next to them. A cached build copies files from a remote store into a fresh workspace. The second one restores the outputs, and it restores the record only when the cache path covers the directory holding it, which is why a restored tree can still compile from scratch. ### Why does my build recompile everything even after a cache restore? Most likely the staleness rule reads timestamps. A checkout writes every source file with the current time, while a restored output keeps the modification time it had when the archive was created. Every source then looks newer than the object built from it, so the build system correctly marks the whole tree out of date. ### Is an incremental build safe for a release artifact? Treat it as unsafe unless the build system keys on content rather than timestamps. Incremental state accumulates whatever earlier builds left behind, including outputs from source that no longer exists in the tree. Release and tagged builds are usually run from a clean tree so the artifact is reproducible from the commit alone. --- # Inline Cache | WarpBuild URL: https://www.warpbuild.com/glossary/inline-cache Description: Inline cache is Docker build cache metadata embedded in the image you push, so pulling that image also supplies the cache hints for the next build. # Inline Cache Inline cache is Docker build cache metadata embedded in the image you push, so pulling that image also supplies the cache hints for the next build. Inline cache is Docker build cache metadata that BuildKit embeds inside the image it pushes, so the image and the hints needed to reuse its layers travel as one artifact. A later build imports it by pointing `--cache-from` at that image reference, and any builder able to pull the image can read the cache. The limit sits in what the embedded metadata can describe. Inline cache records the layers present in the final image, so work done in a stage that was dropped along the way leaves nothing for the next build to match against. ## [Definition](#definition)Copy Anchor Link Inline cache is one of the cache export backends supported by BuildKit, selected with `--cache-to type=inline` on the build command or `cache-to: type=inline` in an action input. Where other backends write the cache manifest to a separate object, inline writes it into the configuration blob of the image the build produces ([Docker inline cache backend documentation](https://docs.docker.com/build/cache/backends/inline/), checked on 2026-08-13). Three properties follow from that placement. 1. The build has to produce an image. Inline cache is available with the image and registry exporters, so a build that only writes a tarball or a local directory has nowhere to put the metadata. 2. There is no second artifact to manage. Retention, storage cost, and access control are whatever the image repository already applies to the tag. 3. The recorded set is bounded by the image. A layer that never reaches the final image cannot be described by metadata that lives inside it. ### [What each cache backend stores](#what-each-cache-backend-stores)Copy Anchor Link | Backend | Where the cache manifest is written | Cache modes it supports | Extra artifact to manage | | -------- | ------------------------------------------------- | ----------------------- | -------------------------------------------- | | inline | The configuration blob of the pushed image | min only | None, the image tag is the cache | | registry | A separate manifest at a cache reference you name | min and max | One cache reference plus its storage | | local | A directory on the machine that ran the build | min and max | A directory that has to outlive the build | | gha | The GitHub Actions cache service | min and max | A store with its own size limit and eviction | The mode column is where inline cache differs in practice. `min` mode exports the layers of the resulting image. `max` mode exports the layers produced by every step, including steps in stages that were used and discarded. Inline cache supports `min` mode alone, so a build that needs the wider record has to write to one of the other backends ([Docker cache backend reference](https://docs.docker.com/build/cache/backends/), checked on 2026-08-13). ### [Reading an inline cache back](#reading-an-inline-cache-back)Copy Anchor Link The import side uses the registry backend, because the metadata sits inside an image in a registry. A build imports it with `--cache-from type=registry,ref=`, and BuildKit reads the cache records out of the image configuration before deciding which steps to run. The reference has to be one a previous build pushed with inline export, which in most workflows is the image tag for the default branch. Two consequences are worth planning around. Overwriting the tag replaces the cache, so a floating reference such as `:latest` holds whatever the last push to it produced. And a pull request that changes an early instruction misses every step downstream of that change, because the digest chain diverges from the recorded one at the first difference. ### [The build argument form](#the-build-argument-form)Copy Anchor Link Before Buildx flags were common, the same behavior was requested through a build argument: `docker build --build-arg BUILDKIT_INLINE_CACHE=1` on an engine running BuildKit. That form still works and produces the same embedded metadata. Contents of `RUN --mount=type=cache` directories stay on the builder under every backend, so a package manager download directory is absent from an inline export the same way it is absent from a registry export. ## [Example](#example)Copy Anchor Link This Dockerfile compiles in one stage and ships a second, which is the shape where inline cache behaves differently from a dedicated cache export. Copy Text ``` # syntax=docker/dockerfile:1 FROM node:22 AS build WORKDIR /src COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:1.27-alpine COPY --from=build /src/dist /usr/share/nginx/html ``` The workflow below builds it on a GitHub Actions runner, pushes the image, and asks for inline cache on the way out. The next run imports the same reference. Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:latest cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:latest cache-to: type=inline ``` One `docker push` moves the image and its cache metadata together. There is no second reference in the registry, and a developer who pulls `:latest` on a laptop can build against the same cache with the same `--cache-from` line. ### [What the second run hits and what it misses](#what-the-second-run-hits-and-what-it-misses)Copy Anchor Link Assume the first run pushed the image and a later push edits one file under `src/`. | Step | Recorded by inline export | Second run behavior | | -------------------------------------- | ------------------------------------ | ------------------------------------------ | | COPY package.json package-lock.json ./ | No, the build stage is discarded | Runs again | | RUN npm ci | No, the build stage is discarded | Runs again, downloading the dependency set | | COPY . . and RUN npm run build | No, the build stage is discarded | Runs again | | FROM nginx:1.27-alpine | Yes, the layer is in the final image | Matched from the imported cache | | COPY --from=build /src/dist ... | Yes, the layer is in the final image | Rebuilt, since the copied files changed | The `npm ci` line is the cost. It belongs to a stage the final image does not contain, and inline metadata has no place to record a layer that the image never carries. Switching the export to `cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max` records the `build` stage as well, at the price of a separate cache reference, an import download before the build, and an export upload after it. That trade between what the cache describes and what each run pays to move it is the reason both backends exist. ## [Related Terms](#related-terms)Copy Anchor Link * [Registry cache and how a separate cache reference stores build layers](/glossary/registry-cache): the backend that writes a cache manifest of its own and supports `max` mode. * [Choosing a Buildx cache backend on GitHub Actions](/guides/buildx-cache-backends-github-actions): how inline, registry, and builder-local caches behave inside a workflow, with the failure signature each one leaves in a job log. * [Image digests and how a build pins the exact image it read](/glossary/image-digest): why a floating tag makes an inline cache reference ambiguous across runs. * [Docker inline cache backend documentation](https://docs.docker.com/build/cache/backends/inline/): the upstream reference for the export flag, its exporter requirements, and its mode support. * [WarpBuild Docker builders documentation](/docs/ci/docker-builders): builder profiles and the actions that point a workflow at a builder. * [WarpBuild caching documentation](/docs/ci/features/caching): cache usage inside GitHub Actions workflows. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between inline cache and a registry cache export? Inline cache writes the cache manifest into the configuration of the image you push, so the image is the cache. A registry cache export writes a separate manifest to its own reference, which can describe every step of the build including stages that never reach the final image. Inline needs no second reference to manage and describes less of the build. ### Why does my multi-stage build still rebuild with inline cache? Inline cache supports min mode only, and min mode records the layers of the resulting image. A builder stage that compiles a binary and hands one file to a slim runtime stage leaves no record behind, so the compile step runs again on the next build while the runtime layers match. ### How do I turn inline cache on in a Docker build? With Buildx, pass cache-to type=inline on a build that pushes an image, then import it on the next build with cache-from type=registry pointed at that image reference. With docker build on an engine running BuildKit, the equivalent is the build argument BUILDKIT\_INLINE\_CACHE set to 1. --- # Instance Metadata Service | WarpBuild URL: https://www.warpbuild.com/glossary/instance-metadata-service Description: The instance metadata service is the link-local endpoint a cloud instance queries for its own metadata and role credentials. How IMDSv1 and IMDSv2 differ. # Instance Metadata Service The instance metadata service is the link-local endpoint a cloud instance queries for its own metadata and role credentials. How IMDSv1 and IMDSv2 differ. The instance metadata service (IMDS) is a local HTTP endpoint that a cloud virtual machine queries to read facts about itself: its instance id, its instance type, the region and availability zone it runs in, its network addresses, and the temporary credentials of any identity role attached to it. It answers on a link-local address that is reachable only from inside that one instance, which is why no hostname, no credential, and no network route is needed to call it. Every major cloud exposes one, and every cloud SDK reads it. A build agent that never mentions an access key still ends up authenticated because the SDK walked its default credential chain down to the metadata endpoint and found a role there. ## [Definition](#definition)Copy Anchor Link The metadata service listens on the link-local IPv4 address `169.254.169.254` on port 80, with an IPv6 form of `fd00:ec2::254` on AWS. Link-local means the address is never routed: a packet sent to it is answered by the hypervisor on the host running that instance, so the same URL returns different data on every machine and returns nothing at all from outside. Two things live behind that address, and they carry very different risk. The first is descriptive metadata. Instance id, instance type, placement, AMI or image id, hostname, private and public IPv4 addresses, and any user data script passed at launch. Reading it tells a caller where it is running. The second is credentials. When a role is attached to the instance, the service issues short-lived credentials for that role on request. On AWS those live under `/latest/meta-data/iam/security-credentials/` and return an access key id, a secret access key, a session token, and an expiry timestamp as JSON. The SDK refreshes them before the expiry without the workload doing anything. That second capability is what makes the endpoint interesting to an attacker. Any code path on the instance that can be tricked into fetching an attacker-supplied URL, the class of bug called server-side request forgery, can be pointed at `169.254.169.254` and made to return role credentials in the HTTP response body. ### [The two request protocols](#the-two-request-protocols)Copy Anchor Link IMDSv1 is a request and response protocol. A plain `GET` to the metadata path returns the value. Nothing distinguishes a fetch made by the workload from a fetch made by a forged request that the workload was tricked into issuing. IMDSv2 is session oriented. The caller first sends a `PUT` to `/latest/api/token` with a header naming how long the session should last, receives an opaque token in the response body, and then attaches that token as a header on every metadata `GET`. Three properties follow from that shape ([AWS instance metadata service documentation](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html), checked on 2026-08-13): | Property | IMDSv1 | IMDSv2 | | ------------------------------------ | ------------------------- | ------------------------------------- | | First request | GET /latest/meta-data/... | PUT /latest/api/token | | Auth on data requests | None | X-aws-ec2-metadata-token header | | Session lifetime | Not applicable | 1 to 21600 seconds, set by the caller | | X-Forwarded-For on the token request | Not applicable | Rejected | | Default response hop limit | Instance default | 1 | | Reachable by a forged plain GET | Yes | No | The `PUT` requirement blocks the common forgery shape, because a URL-fetching bug usually issues a `GET` and cannot set request headers. Rejecting token requests that carry `X-Forwarded-For` blocks the reverse-proxy shape, where a misconfigured proxy on the instance relays an outside request inward. The hop limit is an IP time-to-live on the token response: at the default of 1, the response dies at the instance boundary, so a container on a bridge network or a downstream host never receives a usable token. An instance can be set to accept both protocols or to require IMDSv2, in which case unauthenticated `GET` requests are refused. Requiring the token version is the setting that closes the forgery path; supporting it while still answering v1 does not. ### [The same idea on other clouds](#the-same-idea-on-other-clouds)Copy Anchor Link The pattern is not unique to one provider. Azure serves its [Instance Metadata Service](https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service) at the same `169.254.169.254` address under `/metadata/instance`, and rejects any request that omits the `Metadata: true` header. Google Cloud serves its [metadata server](https://cloud.google.com/compute/docs/metadata/overview) at `metadata.google.internal` and requires a `Metadata-Flavor: Google` header. Both header requirements exist for the reason IMDSv2 exists: a forged plain `GET` cannot set them. ## [Example](#example)Copy Anchor Link A GitHub Actions job running on a self-hosted runner in a cloud account inherits whatever role is attached to that instance. This job reads the region and the attached role name from the metadata service using the token protocol, then lets the AWS CLI resolve credentials through the same endpoint. Copy Text ``` name: build on: push: branches: [main] jobs: package: runs-on: [self-hosted, linux, x64] steps: - uses: actions/checkout@v4 - name: Read instance metadata run: | TOKEN=$(curl -sS -X PUT "http://169.254.169.254/latest/api/token" \ -H "X-aws-ec2-metadata-token-ttl-seconds: 300") curl -sS -H "X-aws-ec2-metadata-token: $TOKEN" \ "http://169.254.169.254/latest/meta-data/placement/region" curl -sS -H "X-aws-ec2-metadata-token: $TOKEN" \ "http://169.254.169.254/latest/meta-data/iam/security-credentials/" - name: Push the image run: | aws ecr get-login-password --region us-east-1 \ | docker login --username AWS --password-stdin "$REGISTRY" docker build -t "$REGISTRY/app:$GITHUB_SHA" . docker push "$REGISTRY/app:$GITHUB_SHA" env: REGISTRY: ${{ vars.ECR_REGISTRY }} ``` The same job written against IMDSv1 drops the `PUT` and the header: Copy Text ``` - name: Read instance metadata (v1 shape) run: | curl -sS "http://169.254.169.254/latest/meta-data/placement/region" ``` On an instance that requires IMDSv2, that second form returns HTTP 401 and the step fails. The `aws ecr get-login-password` step in the first job keeps working either way, because current AWS SDK and CLI releases request a token first and fall back only when the instance still allows v1. Two failure modes show up repeatedly in GitHub Actions when the token version is required: | Symptom | Cause | Fix | | ---------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- | | Every metadata curl returns 401 | Step issues a plain GET with no token | Issue the PUT first and pass the token header | | Steps work directly but fail inside a container: job | Response hop limit of 1 stops the token at the instance boundary | Raise the hop limit to 2, or pass credentials into the container explicitly | The second row is worth a workflow-level note. A job that sets [jobs..container](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions) runs its steps inside a Docker container on the runner, which adds a network hop between the step and the host. Nothing about the workflow file changes; the same commands that succeeded as host steps start timing out or returning 401 inside the container. ## [Related Terms](#related-terms)Copy Anchor Link * [Requiring IMDSv2 on BYOC runner instances](/byoc/imdsv2): how the token version is required on runners in your own cloud account, and what to check in build steps before turning it on. * [IAM instance profile, defined](/glossary/iam-instance-profile): the container that attaches a role to an instance, which is what puts credentials behind the metadata endpoint in the first place. * [Does BYOC work with IMDSv2 required](/answers/does-byoc-work-with-imdsv2): the direct answer for teams running runners in their own cloud account under a policy that mandates the token version. * [AWS BYOC security hardening](/docs/ci/byoc/aws/security): the security checklist for runner instances, including least-privilege instance profiles and egress restrictions. * [AWS BYOC runner configuration](/docs/ci/byoc/aws/config): where the IMDSv2 requirement is set on a runner. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between IMDSv1 and IMDSv2? IMDSv1 answers a plain GET to the link-local address with no authentication step. IMDSv2 is session oriented: the caller first sends a PUT to /latest/api/token with a time-to-live header, receives a session token, and puts that token in a header on every later GET. A request that cannot issue the PUT and read the response headers, which is the shape most server-side request forgery bugs take, cannot reach the metadata. ### Why does a container on the instance fail to read instance metadata? IMDSv2 token responses carry an IP time-to-live, called the response hop limit, that defaults to 1\. A packet leaving the instance for a container on a bridge network crosses one hop, so the token response expires before it arrives. Raising the hop limit to 2 lets containers reach the service, and leaving it at 1 keeps metadata inside the instance. ### What does the metadata service return that a build might need? The instance identity fields (instance id, instance type, region, availability zone, private and public IPv4 addresses) and, when a role is attached to the instance, temporary credentials under /latest/meta-data/iam/security-credentials/. Cloud SDKs read that credential path automatically as the last step of their default credential chain. --- # Instance Type | WarpBuild URL: https://www.warpbuild.com/glossary/instance-type Description: An instance type is a named cloud machine shape that fixes vCPU count, memory, storage, and network capability. How the name encodes each of them. # Instance Type An instance type is a named cloud machine shape that fixes vCPU count, memory, storage, and network capability. How the name encodes each of them. An instance type is a named machine shape published by a cloud provider, and the name fixes the vCPU count, the memory size, the storage attached to the machine, and the network capability of every instance launched under it. Choosing one is how you decide what a GitHub Actions job runs on when the runner is a cloud virtual machine. The vCPU count is the attribute most people read and the only one many people read. Two instance types can carry the same vCPU count and still finish the same workflow at different times, because storage and network are part of the shape as well. ## [Definition](#definition)Copy Anchor Link An instance type is the unit a cloud provider sells compute in. Each entry in the catalog has a name, and the name resolves to a fixed allocation of four resources. 1. **vCPU count.** The number of virtual processors the guest sees, usually one hardware thread of a host core each. The count sets the ceiling on how much work runs in parallel, which is what a test runner reads when it decides how many workers to fork. The [vCPU entry](/glossary/vcpu) covers how the mapping to physical hardware works. 2. **Memory.** A fixed amount of RAM, generally held at a constant ratio to the vCPU count inside a family. On AWS the general purpose `m` family pairs 4 GiB of memory with each vCPU, the compute optimized `c` family pairs 2 GiB, and the memory optimized `r` family pairs 8 GiB. 3. **Storage.** Two different things travel under this word. Network attached block volumes (Amazon EBS, Google Persistent Disk, Azure Managed Disks) are configured separately from the instance type and can be resized or retyped without changing it. Instance store devices, also called local SSD or ephemeral disk, are physically attached to the host and exist only on instance types whose names say so. 4. **Network capability.** A bandwidth ceiling that scales with size inside a family. Smaller sizes in a family are commonly published as a burst figure, so a small shape reaches its headline number for a limited window rather than continuously. ### [Reading the name](#reading-the-name)Copy Anchor Link Every major provider encodes the shape into the string, and the encoding is documented. | Provider | Example name | How to read it | | ------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------- | | AWS | m6id.2xlarge | Series m (general purpose), generation 6, options i (Intel processors) and d (instance store volumes), size 2xlarge | | Google Cloud | c4a-standard-4-lssd | Family c4a, standard memory ratio, 4 vCPU, lssd suffix for attached Local SSD | | Azure | Standard\_D8ds\_v5 | Family D (general purpose), 8 vCPU, d (local temp disk), s (premium storage capable), version v5 | The AWS options field is where storage hides. AWS documents `d` as instance store volumes and `i` as Intel processors, so the difference between `m6i` and `m6id` is one letter that adds a physical NVMe device to the machine ([EC2 instance type naming conventions](https://docs.aws.amazon.com/ec2/latest/instancetypes/instance-type-names.html), checked on 2026-08-13). Google Cloud spells the same idea out with an `lssd` suffix ([Local SSD disks](https://cloud.google.com/compute/docs/disks/local-ssd), checked on 2026-08-13), and Azure uses the additive feature letter `d` ([Azure VM naming conventions](https://learn.microsoft.com/en-us/azure/virtual-machines/vm-naming-conventions), checked on 2026-08-13). Sizes inside a family step through `large`, `xlarge`, `2xlarge`, `4xlarge` and upward, with vCPU and memory roughly doubling at each step. Google Cloud puts the vCPU count in the name directly, which removes the guesswork. ### [What the name leaves out](#what-the-name-leaves-out)Copy Anchor Link An instance type fixes the hardware shape and nothing above it. The operating system image, the preinstalled toolchain, the size and type of the boot volume, and the software configuration are all separate choices, so two machines of the same instance type can behave differently on the same workflow. Availability is separate again: a given instance type exists in some regions and availability zones and is absent from others, and account quotas are counted per vCPU per family per region rather than per machine. ## [Example](#example)Copy Anchor Link A team runs GitHub Actions jobs on self-hosted Linux runners in AWS and registers two fleets, one on `m6i.2xlarge` and one on `m6id.2xlarge`. Both shapes carry 8 vCPU and 32 GiB of memory. Only the second has a local NVMe device. Each fleet advertises a label naming its instance type, and `runs-on` routes a job to a runner that carries every label listed ([Choosing the runner for a job](https://docs.github.com/en/actions/using-jobs/choosing-the-runner-for-a-job) and [Using labels with self-hosted runners](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/apply-labels), checked on 2026-08-13). Copy Text ``` name: build on: push: branches: [main] jobs: unit-tests: runs-on: [self-hosted, linux, x64, m6i-2xlarge] steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test image: runs-on: [self-hosted, linux, x64, m6id-2xlarge] steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - run: docker build -t app:${{ github.sha }} . ``` ### [Same vCPU count, different storage](#same-vcpu-count-different-storage)Copy Anchor Link | Attribute | m6i.2xlarge | m6id.2xlarge | | ------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------- | | vCPU | 8 | 8 | | Memory | 32 GiB | 32 GiB | | Instance store | None, EBS only | 1 x 474 GB NVMe SSD | | Where the work directory lands | A network attached EBS volume | The directly attached NVMe device, once formatted and mounted | | Baseline storage performance | gp3 includes 3,000 IOPS and 125 MiB/s, and more costs extra | Set by the local device | Sources: [AWS general purpose instance specifications](https://docs.aws.amazon.com/ec2/latest/instancetypes/gp.html) and [Amazon EBS General Purpose SSD volumes](https://docs.aws.amazon.com/ebs/latest/userguide/general-purpose.html), both checked on 2026-08-13. The storage row is what moves the wall clock on a build. Checking out a large repository, running `npm ci`, and extracting Docker layers all write tens of thousands of small files, and that pattern draws on IOPS rather than on processor time. On the EBS backed shape those writes spend the volume's baseline budget of 3,000 IOPS and 125 MiB/s, and raising the budget is a separate volume configuration with its own cost. On the instance store shape the same writes land on a device attached to the host, and the volume budget stops being the limit. The tradeoff runs the other way for durability. Instance store contents are tied to the life of the instance and disappear when it stops or terminates, so anything that has to survive the run goes to an artifact upload, a cache upload, or a registry push. A machine also has to format and mount the device before a job benefits from it, because an image that never touches the device leaves the work directory on the boot volume and pays the same IOPS budget as the shape without local storage. The `d` suffix costs more per hour than the plain shape, which is the reason to point the storage heavy jobs at it and leave the rest where they are. ## [Related Terms](#related-terms)Copy Anchor Link * [What one vCPU maps to on the host machine](/glossary/vcpu): how a vCPU count relates to physical cores and threads, and what that means for build parallelism. * [Local SSD storage attached to an instance](/glossary/local-ssd): the instance store device that a `d` or `lssd` suffix adds, and how its lifecycle differs from a block volume. * [Choosing instance types for runners in your own AWS account](/byoc/aws): where instance type selection appears when GitHub Actions runners launch inside your own account. * [AWS configuration for a runner stack](/docs/ci/byoc/aws/config): the AWS prerequisites, including the region quotas to check for the instance types you pick. * [Local SSD handling on Linux runner images](/docs/ci/features/local-ssd): how instance store devices are detected, formatted, and mounted for the work directory. * [EC2 instance type naming conventions](https://docs.aws.amazon.com/ec2/latest/instancetypes/instance-type-names.html): the upstream table of series letters, generation numbers, and option letters. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a cloud instance type? An instance type is a catalog entry a cloud provider publishes under a name such as m6id.2xlarge or c4a-standard-4-lssd. The name maps to a fixed shape: a vCPU count, a memory size, the storage physically attached to the machine, and a network bandwidth ceiling. Every instance launched with that name gets the same shape. ### Do two instance types with the same vCPU count run a build at the same speed? Only when the rest of the shape matches. vCPU count is one of four attributes, so two shapes with 8 vCPU each can differ in memory ratio, network bandwidth, and whether the machine has a directly attached NVMe device. A build that writes many small files is often bound by the storage attribute while the vCPU attribute looks identical on both. ### Does the instance type decide the disk a GitHub Actions job writes to? Partly. The instance type decides whether the machine has an instance store device at all, because only names carrying the matching suffix include one. The boot volume behind the work directory is a separate launch setting with its own size, type, and performance, so two machines of the same instance type can still present different disks to a job. --- # Job Summary | WarpBuild URL: https://www.warpbuild.com/glossary/job-summary Description: A GitHub Actions job summary is a markdown panel a job writes to its own run page by appending to the GITHUB_STEP_SUMMARY file. Rules, limits, and an example. # Job Summary A GitHub Actions job summary is a markdown panel a job writes to its own run page by appending to the GITHUB\_STEP\_SUMMARY file. Rules, limits, and an example. A GitHub Actions job summary is a markdown panel that a job publishes to its own run page. A step creates one by appending GitHub flavored markdown to the file whose path sits in the `GITHUB_STEP_SUMMARY` environment variable, and GitHub renders the collected markdown on the workflow run summary page ([GitHub workflow commands reference](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions), checked on 2026-08-13). The panel exists so that a result can be read without opening logs. Test counts, a coverage delta, the list of packages a monorepo rebuilt, and the digest of an image a job pushed are all facts a reader wants in three seconds, and a log view turns each of them into a scroll and a text search. ## [Definition](#definition)Copy Anchor Link A job summary is markdown produced by the steps of one job and rendered on the run summary page under that job. The mechanism is a plain file. GitHub sets `GITHUB_STEP_SUMMARY` in the environment of every step, pointing at a path on the runner filesystem. Anything that can append to a file can write a summary: `echo` inside a bash `run` block, `Add-Content` in PowerShell, a Python script, a compiled binary, or a JavaScript action. Writing a summary takes no API call and no token, so the same step works on a GitHub-hosted runner and on a self-hosted runner. Four rules govern that file, and GitHub documents each of them in the workflow commands reference linked above. 1. `GITHUB_STEP_SUMMARY` is unique for each step in a job, so two steps write to two different files. 2. Each step is restricted to a maximum of 1MiB. If a step adds more, the upload for that step fails and an error annotation is created. The job conclusion is unaffected. 3. Isolation is enforced between steps, so malformed markdown from one step cannot break markdown rendering for the steps that follow it. 4. When the job finishes, the summaries for all steps in the job are grouped into a single job summary and shown on the workflow run summary page. Appending and overwriting are ordinary shell operations on that path. `>>` adds to what the current step has already written. `>` replaces it, clearing the content of the current step while leaving every other step alone. The PowerShell pair is `Add-Content` to append and `Set-Content` to replace. Rendering follows GitHub flavored markdown, so headings, tables, task lists, fenced code blocks, links, and collapsible `
` blocks all work. ### [The neighboring output surfaces](#the-neighboring-output-surfaces)Copy Anchor Link A workflow has four places to put output, and telling them apart makes the choice easy. | Surface | Where a reader finds it | How a step produces it | | ----------- | ------------------------------------------------------------- | ------------------------------------------------------------------------- | | Step log | Log view for the job, one collapsible block per step | Anything written to stdout or stderr | | Annotation | Top of the run page and inline in the pull request files view | The ::error and ::warning workflow commands, or an action that emits them | | Artifact | Download list on the run page | actions/upload-artifact | | Job summary | Run summary page, grouped under the job | Appending markdown to GITHUB\_STEP\_SUMMARY | Logs carry the line by line record. Annotations point a reviewer at a file and a line. Artifacts move files off the runner for later download. A job summary carries a short rendered report that a person reads at a glance. ### [The toolkit builder](#the-toolkit-builder)Copy Anchor Link Actions written in JavaScript usually reach the same file through the `core.summary` builder in `@actions/core` rather than through shell redirection. The builder exposes `addHeading`, `addTable`, `addList`, `addLink`, `addCodeBlock`, `addDetails`, `addImage`, `addQuote`, `addSeparator`, and `addRaw`, and `write()` flushes the buffer into the file ([actions/toolkit core README](https://github.com/actions/toolkit/blob/main/packages/core/README.md), checked on 2026-08-13). Passing `{ overwrite: true }` to `write()` replaces the current step's content instead of adding to it. ## [Example](#example)Copy Anchor Link This workflow runs a unit test suite and then writes the counts into the run page as a markdown table, so the outcome is readable without opening the log. Copy Text ``` name: test on: push: branches: [main] jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - name: Run unit tests run: npm test -- --reporters=json --outputFile=results.json - name: Publish test counts if: always() run: | passed=$(jq '.numPassedTests' results.json) failed=$(jq '.numFailedTests' results.json) skipped=$(jq '.numPendingTests' results.json) { echo "## Unit tests" echo "" echo "| Result | Count |" echo "| --- | --- |" echo "| Passed | $passed |" echo "| Failed | $failed |" echo "| Skipped | $skipped |" } >> "$GITHUB_STEP_SUMMARY" ``` Three details in that last step decide whether the panel appears. `if: always()` keeps the summary step running after the test step fails. A failing run is the run where the counts are worth reading, and without that condition the job stops before the panel is written. The braces group the `echo` calls so the whole block is appended through one redirection. Redirecting on every line works as well and opens the file once per line. The blank line after the heading is required. A markdown table has to start its own block, so a table glued directly under a heading line renders as plain text. The run summary page then shows a heading and this table under the `unit-tests` job: | Result | Count | | ------- | ----- | | Passed | 412 | | Failed | 1 | | Skipped | 3 | The same panel from a JavaScript action, using the builder instead of shell redirection: Copy Text ``` const core = require('@actions/core'); await core.summary .addHeading('Unit tests', 2) .addTable([ [ { data: 'Result', header: true }, { data: 'Count', header: true }, ], ['Passed', String(passed)], ['Failed', String(failed)], ['Skipped', String(skipped)], ]) .write(); ``` Two habits keep summaries useful. Keep them at report size, because piping a full log tail into the file crosses the 1MiB step limit on a noisy run and loses the whole panel for that step. And remember that a matrix fans the panel out with the jobs: a matrix of twelve jobs writes twelve separate summaries, each grouped under its own job on the run summary page, so a per-job heading that names the matrix value keeps the run page readable. ## [Related Terms](#related-terms)Copy Anchor Link * [How to write a job summary in a GitHub Actions workflow](/answers/how-do-i-write-a-job-summary): the shell and JavaScript forms, with the quoting and ordering rules that trip people up. * [Build a weekly GitHub Actions health report](/guides/weekly-ci-health-report-github-actions): rolling per-run summaries up into a scheduled report across a repository. * [Build duration percentile](/glossary/build-duration-percentile): the P75 and P90 statistics that a health report puts next to run counts. * [WarpBuild reports documentation](/docs/ci/features/reports): per-job duration and queue time percentiles collected across runs, with CSV export. * [WarpBuild quick start](/docs/ci/quick-start): pointing a first workflow at a managed runner fleet. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a GitHub Actions job summary? A job summary is a markdown panel that a job publishes to the workflow run summary page. A step writes one by appending GitHub flavored markdown to the file path held in the GITHUB\_STEP\_SUMMARY environment variable, and GitHub renders the result under that job on the run page. ### How large can a job summary be? Each step is limited to 1MiB. If a step adds more than that, the upload for the step fails and an error annotation is created, while the job conclusion stays unchanged. Long output belongs in the logs or in an uploaded artifact rather than in the summary. ### Do several steps in one job overwrite each other's summaries? No. GITHUB\_STEP\_SUMMARY is unique for each step, so two steps write to two different files, and steps are isolated so malformed markdown in one cannot break rendering for the next. When the job finishes, the summaries for all its steps are grouped into a single job summary. --- # Job Timeout | WarpBuild URL: https://www.warpbuild.com/glossary/job-timeout Description: A job timeout is the maximum number of minutes a GitHub Actions job may run before the platform cancels it, set with timeout-minutes on a job or a step. # Job Timeout A job timeout is the maximum number of minutes a GitHub Actions job may run before the platform cancels it, set with timeout-minutes on a job or a step. A job timeout is the maximum number of minutes a GitHub Actions job is allowed to run before the platform cancels it. Workflows set it with the `timeout-minutes` key on a job or on a single step, and the work ends as soon as that limit is reached. The default is generous, which is why the key matters. A job with no `timeout-minutes` anywhere runs for up to 360 minutes, so one step hanging on a dead network call holds a machine for six hours before anything stops it. ## [Definition](#definition)Copy Anchor Link `timeout-minutes` appears at two levels of workflow YAML, and each level is documented separately. GitHub's [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidtimeout-minutes) defines the job level key as the maximum number of minutes to let a job run before GitHub automatically cancels it, with a default of 360\. The [step level key](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstepstimeout-minutes) is the maximum number of minutes to run a step before the process is killed, and its maximum value is 360 on both GitHub-hosted and self-hosted runners. Both keys take positive integers, and fractional values are rejected. A timeout is a wall clock ceiling on execution. It counts the time a runner spends on the job, so the interval a job spent waiting for a free runner falls outside it and is measured as [queue time](/glossary/queue-time) instead. ### [The ceilings a job runs into](#the-ceilings-a-job-runs-into)Copy Anchor Link Four platform limits sit around the two keys. Values checked on 2026-08-13 against the workflow syntax reference and the [GitHub Actions limits page](https://docs.github.com/en/actions/reference/limits). | Ceiling | Value | Source | | ---------------------------------------- | ----------- | ------------------------- | | Job timeout-minutes default | 360 minutes | Workflow syntax reference | | Step timeout-minutes maximum | 360 minutes | Workflow syntax reference | | Job execution time, GitHub-hosted runner | 6 hours | GitHub Actions limits | | Job execution time, self-hosted runner | 5 days | GitHub Actions limits | | Job queue time, self-hosted runner | 24 hours | GitHub Actions limits | | Workflow run time | 35 days | GitHub Actions limits | The lowest applicable ceiling decides the outcome. GitHub documents that a `timeout-minutes` value larger than the runner's job execution time limit has no effect past that limit, because the job is cancelled when the execution limit is met. A job on a GitHub-hosted runner asking for `timeout-minutes: 600` still ends at six hours. One further ceiling applies to long jobs on self-hosted runners. The `GITHUB_TOKEN` issued for a run expires after at most 24 hours, so a job configured to run beyond that can lose its credentials before its timeout ever fires. ### [What the key controls](#what-the-key-controls)Copy Anchor Link A timeout ends work rather than explaining it. The job stops without a success result, which is the signal a hung job needs to produce so that a person or an alert can react to it. Steps guarded with `if: always()` still run while the job is being cancelled, so a diagnostic upload placed at the end of a job survives the timeout and leaves evidence behind. Each job carries its own timeout. In a matrix, every generated job gets the value independently, so one leg hanging cancels that leg at the limit while the others continue. ## [Example](#example)Copy Anchor Link This job sets a backstop at the job level and a tighter limit on the one step that talks to a service which sometimes never becomes ready. Copy Text ``` name: integration on: push: jobs: integration-tests: runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 - run: npm ci - name: Wait for service and run suite timeout-minutes: 10 run: ./scripts/integration-test.sh - name: Collect logs if: always() run: docker compose logs > service.log - uses: actions/upload-artifact@v4 if: always() with: name: service-logs path: service.log ``` When the service never comes up, the script blocks. The step process is killed at ten minutes and the job fails there. The two `if: always()` steps still run, so the logs that explain the hang are uploaded and the next person has something to read. The job level `timeout-minutes: 30` covers everything else, including a checkout or an `npm ci` that stalls on a package registry. The difference between those two keys shows up in machine time. Assume the checkout and install take two minutes before the hanging step begins. | Configuration | When the hang stops | Machine minutes for one hung job | | ------------------------------------ | ------------------------- | -------------------------------- | | No timeout-minutes anywhere | At the 360 minute default | 360 | | Job timeout-minutes: 30 only | At 30 minutes | 30 | | Job 30 plus step timeout-minutes: 10 | At roughly 12 minutes | 12 | Twenty hung jobs in a month is 7,200 minutes of machine time in the first row and 240 minutes in the last. The feedback loop moves by the same factor: a developer waiting on that check learns about the failure after twelve minutes instead of six hours. A value that is too tight is its own failure mode. Pick the step limit from the observed p95 duration of that step plus headroom rather than from the average, because a healthy but slow run that gets killed and retried consumes more machine time than the timeout saved. ## [Related Terms](#related-terms)Copy Anchor Link * [How to set a timeout on a GitHub Actions job](/answers/how-do-i-set-a-timeout-on-a-github-actions-job): the job and step keys to add, and the default behavior they replace. * [Long running jobs on GitHub Actions and the ceilings they hit](/guides/long-running-jobs-github-actions): how to split work that legitimately runs for hours into stages that hand state forward. * [Queue time and how it differs from execution time](/glossary/queue-time): the interval before a runner starts the job, which sits outside every timeout on this page. * [GitHub workflow syntax reference for timeout-minutes](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidtimeout-minutes): the documented default and the step level maximum. * [WarpBuild common issues documentation](/docs/ci/common-issues): what to check when a job sits unclaimed instead of running long. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the default timeout for a GitHub Actions job? 360 minutes. GitHub's workflow syntax reference documents 360 as the default for jobs..timeout-minutes, so a job with no timeout-minutes key runs for up to six hours before the platform cancels it. ### Does a job timeout include the time a job spends waiting for a runner? No. timeout-minutes measures execution, which starts when a runner picks the job up. Waiting for a free runner is governed separately, and GitHub cancels a job that has been queued for a self-hosted runner for 24 hours. ### Can a step have a shorter timeout than the job that contains it? Yes, and that is the useful pattern. A step level timeout-minutes kills the step process at its own limit while the job level value stays as a backstop for everything else. The step maximum is 360 minutes on both GitHub-hosted and self-hosted runners, and both keys take positive integers only. --- # KVM | WarpBuild URL: https://www.warpbuild.com/glossary/kvm Description: KVM is the Linux kernel virtualization interface, exposed as the device /dev/kvm, that lets a process run a guest with hardware acceleration on the host CPU. # KVM KVM is the Linux kernel virtualization interface, exposed as the device /dev/kvm, that lets a process run a guest with hardware acceleration on the host CPU. KVM, short for Kernel-based Virtual Machine, is the virtualization interface built into the Linux kernel and exposed to user space as the character device `/dev/kvm`. A process opens that device, asks the kernel for a virtual machine and its virtual CPUs, and the guest's instructions then execute on the physical CPU through the hardware virtualization extensions instead of being interpreted one at a time in software. Two facts explain almost every KVM question that starts in a GitHub Actions log. The device has to exist in the environment where the job runs, and the user running the job has to be able to open it. Programs that want hardware acceleration check both, and quietly continue in software emulation when either check fails. ## [Definition](#definition)Copy Anchor Link KVM is a set of kernel modules: `kvm.ko` for the architecture independent core, plus `kvm-intel.ko` or `kvm-amd.ko` for the vendor specific extensions, Intel VT-x and AMD-V. Loading them turns the running Linux kernel into a hypervisor and creates the misc device `/dev/kvm`, character major 10, minor 232. KVM supplies the CPU and memory half of a virtual machine. The user space program supplies the rest: the memory layout, the virtual disks and network interfaces, the firmware, and the loop that drives execution. QEMU, crosvm, cloud-hypervisor, and the Android emulator are all user space programs that reach the kernel through the same device. The interface is a small set of `ioctl` calls, documented in the [kernel's KVM API reference](https://docs.kernel.org/virt/kvm/api.html): | Call | Issued on | Result | | ------------------------------ | ----------------- | --------------------------------------------------------- | | KVM\_GET\_API\_VERSION | /dev/kvm | Version handshake before anything else | | KVM\_CREATE\_VM | /dev/kvm | A file descriptor representing one virtual machine | | KVM\_SET\_USER\_MEMORY\_REGION | The VM descriptor | Maps a region of the process's address space as guest RAM | | KVM\_CREATE\_VCPU | The VM descriptor | A file descriptor representing one virtual CPU | | KVM\_RUN | A vCPU descriptor | Runs guest code on the physical CPU until a VM exit | `KVM_RUN` is where the acceleration lives. The kernel switches the physical CPU into guest mode and the guest's ordinary instructions execute at hardware speed. When the guest touches something the hypervisor has to mediate, such as a device register or a halt instruction, the CPU exits back to the kernel, which returns from `KVM_RUN` with an exit reason such as `KVM_EXIT_IO`, `KVM_EXIT_MMIO`, or `KVM_EXIT_HLT` in the shared `kvm_run` structure. The user space program services that exit and calls `KVM_RUN` again. This differs from the translation work described under [QEMU emulation](/glossary/qemu-emulation). Translation reads foreign architecture instructions and rewrites them for the host CPU. KVM leaves the guest instructions alone, because they are already native to the CPU underneath, so its job is confined to mode switching, memory mapping, and interrupt delivery. ### [The three states a program can find](#the-three-states-a-program-can-find)Copy Anchor Link A program probing for acceleration is really testing two conditions, and the failure signatures differ: | State of /dev/kvm | What an open call returns | What the program usually does | | --------------------------------------------------------------- | --------------------------------- | ----------------------------------------- | | Device absent, no virtualization extensions in this environment | ENOENT, no such file or directory | Falls back to software emulation | | Device present, permissions deny the caller | EACCES, permission denied | Falls back to software emulation | | Device present and openable | A file descriptor | Runs the guest with hardware acceleration | The permission case catches people out because the device is right there in the listing. Distributions ship a udev rule that creates the node as `crw-rw---- root:kvm`, so a process whose user is outside the `kvm` group gets `EACCES` from a device that plainly exists. Nesting is the other half. A GitHub Actions runner is already a virtual machine, so the kernel inside it can only load the KVM modules when the hypervisor below it exposes the virtualization extensions to that guest. That capability is [nested virtualization](/glossary/nested-virtualization), and where it is unavailable, no configuration inside the job can create `/dev/kvm`. ## [Example](#example)Copy Anchor Link The Android emulator is the workload most GitHub Actions users meet KVM through. This job prints the state of the device, applies the standard permission rule, and then runs instrumentation tests: Copy Text ``` name: instrumentation on: pull_request: jobs: emulator: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Report KVM availability run: | if [ -e /dev/kvm ]; then ls -l /dev/kvm id -nG else echo "no /dev/kvm in this environment" fi - name: Allow the job user to open /dev/kvm run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 arch: x86_64 script: ./gradlew connectedCheck ``` When the device is openable, the report step prints the node and the emulator boots with hardware acceleration: Copy Text ``` $ ls -l /dev/kvm crw-rw-rw- 1 root kvm 10, 232 Aug 13 09:14 /dev/kvm ``` When it is not, the emulator does not fail the step. It reports the probe result and continues in software: Copy Text ``` ProbeKVM: This user doesn't have permissions to use KVM (/dev/kvm). Disabling Linux hardware acceleration. emulator: ... -accel off ... ``` That `-accel off` on the emulator command line is the signal worth grepping for, because the job still passes and only the wall clock time gives the fallback away. The [android-emulator-runner README](https://github.com/ReactiveCircus/android-emulator-runner#running-hardware-accelerated-emulators-on-linux-runners) documents the permission step as a requirement on Linux runners, and GitHub's [changelog on hardware accelerated Android virtualization](https://github.blog/changelog/2023-02-23-hardware-accelerated-android-virtualization-on-actions-windows-and-linux-larger-hosted-runners/) records which hosted runner classes gained the underlying capability, both checked on 2026-08-13. Any other KVM dependent workload reads the same way. A libvirt or QEMU step that boots a full guest, a kernel test that runs under a virtual machine, and an emulator all start by opening `/dev/kvm`, and all of them degrade to software execution rather than erroring when the open fails. ## [Related Terms](#related-terms)Copy Anchor Link * [Nested virtualization, the layer that puts /dev/kvm inside a GitHub Actions runner](/glossary/nested-virtualization): what the hypervisor below has to expose before a guest kernel can load the KVM modules at all. * [GitHub Actions runners that expose /dev/kvm to the job](/runners/nested-virtualization): which runner classes support the capability and the label shape that turns it on. * [Can GitHub Actions run an Android emulator?](/answers/can-github-actions-run-android-emulator): the end to end answer for emulator jobs, including the permission step and sizing. * [QEMU emulation](/glossary/qemu-emulation): the software translation path used when the guest architecture differs from the host. * [Linux KVM API documentation](https://docs.kernel.org/virt/kvm/api.html): the upstream reference for the device, the ioctls, and the exit reasons. * [WarpBuild nested virtualization documentation](/docs/ci/features/nested-virtualization): the support matrix and configuration reference. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner catalog with sizes, images, and architectures. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What does /dev/kvm actually do? It is the entry point to the kernel's virtualization interface. A process opens the device, issues KVM\_CREATE\_VM and KVM\_CREATE\_VCPU to get file descriptors for a virtual machine and its virtual CPUs, then calls KVM\_RUN to execute guest instructions on the physical CPU through the hardware virtualization extensions. Control returns to the process on a VM exit, which is how device access and other privileged operations get handled in user space. ### Why is /dev/kvm missing inside a GitHub Actions job? A hosted runner is itself a virtual machine, so the device appears in the guest only when the layer underneath exposes the CPU virtualization extensions to that guest. That is nested virtualization. Without it the kvm modules have no hardware to program, the device node is never created, and an open call returns ENOENT. ### What is the difference between KVM and QEMU? KVM is a kernel interface that runs same architecture guest code directly on the host CPU. QEMU is a user space program that emulates machines and devices, and it can either translate foreign architecture instructions in software or hand same architecture guest code to KVM for hardware acceleration. Programs such as the Android emulator use KVM the same way, through /dev/kvm. --- # Local SSD | WarpBuild URL: https://www.warpbuild.com/glossary/local-ssd Description: A local SSD is storage physically attached to the host running an instance. Why it beats network attached volumes for build scratch space, and why it is wiped. # Local SSD A local SSD is storage physically attached to the host running an instance. Why it beats network attached volumes for build scratch space, and why it is wiped. A local SSD is a solid state drive physically attached to the host machine that runs a cloud instance, reached over the host's own bus rather than across the storage network. Two properties follow from that placement: it is faster than a network attached volume on the same machine, and everything written to it is lost when the instance stops or is deleted. That pairing is what makes local SSD a good fit for a GitHub Actions job. A job checks out a repository, unpacks caches, writes object files and container layers, then hands back an artifact and a conclusion. Almost every byte it writes is scratch, and scratch is exactly what a disk with no durability guarantee is for. ## [Definition](#definition)Copy Anchor Link Each cloud provider ships the same idea under a different name. * **AWS** calls it an instance store and documents it as temporary block level storage located on disks that are physically attached to the host computer ([Amazon EC2 instance store](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/InstanceStorage.html), checked on 2026-08-13). * **GCP** calls it Local SSD: disks physically attached to the server that hosts the VM, whose data persists only until the instance is stopped or deleted ([About Local SSD disks](https://cloud.google.com/compute/docs/disks/local-ssd), checked on 2026-08-13). * **Azure** exposes local NVMe devices on storage optimized VM families such as Lsv3 ([Lsv3 series](https://learn.microsoft.com/en-us/azure/virtual-machines/lsv3-series), checked on 2026-08-13). The wording differs and the two defining characteristics hold across all three. The device sits inside the machine that runs your workload, so reads and writes do not cross the network fabric that carries traffic to a network attached volume. And the capacity belongs to the instance, so there is no replication behind it, no snapshot of it, and no way to reattach it somewhere else. ### [Local SSD against a network attached volume](#local-ssd-against-a-network-attached-volume)Copy Anchor Link Both present as block devices to the operating system, and a mounted filesystem on one looks like a mounted filesystem on the other. The operational differences show up everywhere else. | Property | Local SSD | Network attached block volume | | -------------------------------------- | --------------------------------------------- | --------------------------------------------- | | Where the device lives | On the host running the instance | In a storage service reached over the network | | Survives an instance stop or delete | No | Yes | | Detach and reattach to another machine | No | Yes | | Snapshots and backups | None | Supported by the provider | | Resize after launch | No, capacity is fixed by the shape | Usually yes | | How it is selected | By choosing an instance type that includes it | As an independent volume resource | | What it suits | Scratch space for one workload run | State that outlives the machine | ### [The instance type decides whether a machine has one](#the-instance-type-decides-whether-a-machine-has-one)Copy Anchor Link Local devices are part of the hardware a shape is sold with, so the selection happens at launch and nowhere else. Each provider encodes it in the machine name. | Provider | How local storage is selected | Example shapes | Reference | | -------- | ----------------------------------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------- | | AWS | Storage optimized families built around instance store, plus a d suffix added to other families | i3, c5d, m6id, r6id | [EC2 instance type names](https://docs.aws.amazon.com/ec2/latest/instancetypes/instance-type-names.html) | | GCP | Machine types ending in \-lssd bundle Local SSD with the shape | c3-standard-8-lssd, c4a-standard-4-lssd | [About Local SSD disks](https://cloud.google.com/compute/docs/disks/local-ssd) | | Azure | Storage optimized VM families that carry local NVMe | Lsv3, Lasv3 | [Lsv3 series](https://learn.microsoft.com/en-us/azure/virtual-machines/lsv3-series) | None of the three allow a local device to be added to a running machine, which is the practical constraint behind the whole term. A fleet whose builds are disk bound has to be moved onto a different shape, and the vCPU count and memory move with it. The [instance type](/glossary/instance-type) entry covers how those attributes travel together. ### [Reboot, stop, and the difference between them](#reboot-stop-and-the-difference-between-them)Copy Anchor Link The lifetime rules are narrower than "the data is temporary" suggests. AWS documents that instance store data survives a reboot and is lost when the instance stops, hibernates, or terminates, or when the underlying drive fails. GCP documents the same boundary for Local SSD: the data lasts until the VM is stopped or deleted. A runner that is destroyed after each job never reaches that boundary in an interesting way, because the machine and the disk end together. The distinction matters for a long lived machine that is stopped overnight to save money and comes back with an empty device the next morning. The [ephemeral storage](/glossary/ephemeral-storage) entry covers the lifecycle rules in full. ### [Several devices on one machine](#several-devices-on-one-machine)Copy Anchor Link Larger shapes expose more than one local device rather than one bigger drive. The common treatment is to stripe them into a single RAID 0 array with a tool such as `mdadm` before formatting, so sequential throughput adds up across the members. RAID 0 offers no redundancy, and losing a member loses the array. For scratch space that is already discarded at the end of the job, that tradeoff costs nothing. ## [Example](#example)Copy Anchor Link Take a self hosted Linux runner launched on a shape with local NVMe, where the device is formatted at boot and mounted at `/mnt/local`, and the runner work directory is placed underneath it. Everything `actions/checkout` writes then lands on the attached device, because `GITHUB_WORKSPACE` sits inside that work directory. The workflow below sends the rest of the job's writes to the same mount by pointing the toolchain's cache and output directories at it. Copy Text ``` name: build on: push: branches: [main] jobs: build: runs-on: [self-hosted, linux, x64] env: CARGO_HOME: /mnt/local/cargo CARGO_TARGET_DIR: /mnt/local/target steps: - uses: actions/checkout@v4 - name: Show where the job is writing run: | lsblk -o NAME,SIZE,MOUNTPOINT df -h "$GITHUB_WORKSPACE" /mnt/local - uses: actions/cache@v4 with: path: | /mnt/local/cargo/registry /mnt/local/target key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} - run: cargo build --release --locked - run: cargo test --release --locked ``` Running that job on a machine with the mount in place puts the checkout, the dependency registry, and the compiler output on the local device. Here is where each write in the job actually lands. | Write during the job | Path | Device | | ----------------------------------------------- | -------------------------------------------------- | ---------------------------- | | Repository checkout | $GITHUB\_WORKSPACE under the runner work directory | Local SSD | | Dependency registry and build output | /mnt/local/cargo, /mnt/local/target | Local SSD | | Cache archive unpacked by actions/cache | The same two paths | Local SSD | | Container layers written by a docker build step | /var/lib/docker on the boot disk, unless moved | Network attached boot volume | | Uploaded artifacts and the saved cache archive | Sent over the network at the end of the job | Remote service | The fourth row is the one that surprises people. The Docker daemon keeps its layer store under its data root, which defaults to the boot disk, so an image build keeps writing to the network attached volume even though the workspace moved. Moving the daemon is a machine provisioning change rather than a workflow change: Copy Text ``` { "data-root": "/mnt/local/docker" } ``` The last row is the reason a cache service is still part of the picture. `actions/cache` restores an archive into those paths at the start of the job and uploads a new archive at the end, and that archive is what carries state to the next job. The local SSD holds the unpacked copy for the length of one job, while the durable copy lives in the cache service ([caching dependencies](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows), checked on 2026-08-13). The workloads that gain the most from this arrangement are the ones dominated by file operations: a checkout of a large repository, unpacking a multi gigabyte cache archive, container layer writes, and link steps that touch thousands of object files. A workflow whose runtime is spent inside a single compiler process on a small source tree sees very little from it. ## [Related Terms](#related-terms)Copy Anchor Link * [GitHub Actions runners backed by local SSD storage](/runners/local-ssd): the runner shapes that carry a local device and the placements where the mount applies. * [Ephemeral storage and the instance lifetime it is tied to](/glossary/ephemeral-storage): the lifecycle rules behind the stop, terminate, and reboot boundaries above. * [Instance types and the storage each shape carries](/glossary/instance-type): how vCPU, memory, and attached storage travel together in one machine shape. * [Amazon EC2 instance store](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/InstanceStorage.html): the AWS definition, the lifetime rules, and the families that include one. * [About Local SSD disks on GCP](https://cloud.google.com/compute/docs/disks/local-ssd): the GCP definition and the machine types that bundle the device. * [WarpBuild local SSD documentation](/docs/ci/features/local-ssd): how the device is detected, formatted, and mounted on BYOC runners. * [WarpBuild BYOC configuration for GCP](/docs/ci/byoc/gcp/config): disk options and instance type selection for a GCP runner set. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a local SSD on a cloud instance? A local SSD is a solid state device attached to the physical host that runs the instance, reached over the host bus rather than over the storage network. Cloud providers call it an instance store on AWS, Local SSD on GCP, and local NVMe on Azure VM families that carry it. ### Is data on a local SSD kept when the machine stops? No. The device belongs to the instance, so the contents are gone once the instance stops, hibernates, or terminates. AWS documents that the data survives a reboot and is lost on stop or terminate, and GCP documents that Local SSD data persists only until the VM is stopped or deleted. ### How do I get a local SSD on a GitHub Actions runner? By launching the runner on an instance type that includes one, then mounting it at the runner work directory. Local devices cannot be attached after launch, so the shape chosen for the runner set decides whether the job has one at all. --- # MCP Server | WarpBuild URL: https://www.warpbuild.com/glossary/mcp-server Description: An MCP server implements the Model Context Protocol and exposes a typed set of tools and data sources that an AI assistant can call over JSON-RPC 2.0. # MCP Server An MCP server implements the Model Context Protocol and exposes a typed set of tools and data sources that an AI assistant can call over JSON-RPC 2.0. An MCP server is a program that implements the Model Context Protocol and exposes a typed set of tools and data sources that an AI application can call over JSON-RPC 2.0 messages. The protocol is what makes the connection portable: any host that speaks MCP can attach to any server that speaks MCP, so the integration between an assistant and a system is written once on the server side instead of once per assistant. The practical consequence is a change in who does the work. Instead of a person opening a dashboard, reading numbers off it, and pasting them into a chat window, the assistant asks the server for the same numbers in a structured form and reasons over the result. ## [Definition](#definition)Copy Anchor Link The specification names three roles ([Model Context Protocol specification](https://modelcontextprotocol.io/specification), checked on 2026-08-13). * **Host.** The application a person is actually using: an editor, a chat client, or an agent runtime. It owns the conversation and decides which servers are attached. * **Client.** The connector inside the host. The host runs one client per server, and that client owns the connection and the message exchange. * **Server.** The program that wraps a system and describes what it offers. A server is a local process the host launches, or a remote service addressed by URL. A server offers any combination of three features. The names are fixed by the protocol, and each one has a different controller. | Server feature | Who decides it is used | Example content for a GitHub Actions server | | -------------- | ---------------------------- | --------------------------------------------------------- | | Tools | The model, during a turn | list\_workflow\_runs, get\_job\_logs, rerun\_failed\_jobs | | Resources | The host application, by URI | A workflow file, a job log, a runner inventory | | Prompts | The user, by picking one | A saved "triage the last failed run" template | Tools are the feature most people mean when they say MCP server, because tools are the ones a model invokes on its own. Resources and prompts are pulled in by the application or the person. ### [What makes the surface typed](#what-makes-the-surface-typed)Copy Anchor Link Discovery is a single request. The client sends `tools/list` and the server answers with every tool it currently offers. Each tool definition carries a `name`, an optional human readable `title`, a `description`, an `inputSchema`, and an optional `outputSchema`. Both schemas are JSON Schema, defaulting to draft 2020-12 when no `$schema` field is present ([Tools, MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/server/tools), checked on 2026-08-13). That schema is the contract. The model fills in arguments to match it, the server validates what arrives, and the response comes back as a `content` array with an `isError` flag rather than as a page of HTML. A tool list is also stable by design: the specification says the set must not vary per connection, though it may narrow according to the authorization presented on the request. ### [How a host reaches a server](#how-a-host-reaches-a-server)Copy Anchor Link Two transports are defined. A **stdio** server is launched by the host as a subprocess and exchanges messages over stdin and stdout, which suits anything reading local files or local tooling. A **Streamable HTTP** server lives at a URL, serves many users from one deployment, and is the usual shape for a server that fronts a hosted API. A remote server needs its own answer to authentication, because the connection now crosses a network boundary. The specification builds authorization on OAuth 2.1 with server discovery and client registration, and many deployments accept a bearer token in an `Authorization` header as the simpler path. Either way the credential belongs to the host and the server. The model sees tool names, schemas, and results. The specification also asks implementations to keep a person in the loop, showing which tools are exposed and offering a confirmation before a tool runs. A server that can restart infrastructure deserves that prompt. ## [Example](#example)Copy Anchor Link Take a question that comes up on any repository with a slow test suite: which runs of the `integration` workflow on `main` failed in the last day, and how long did each one take. The workflow itself is ordinary. Copy Text ``` name: integration on: push: branches: [main] jobs: api-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: ./scripts/integration-test.sh ``` Answering that by hand means opening the Actions tab, filtering by branch and status, opening each failed run, and reading the timing off the job view. Answering it by scraping means writing code against page markup that changes without warning. With an MCP server attached, the host holds a connection to a server that fronts the workflow run data. Configuration in the host is a URL plus a header. Copy Text ``` { "mcpServers": { "actions": { "url": "https://mcp.example.com/mcp", "headers": { "Authorization": "Bearer " } } } } ``` On connect, the client asks what the server offers, and the reply describes the tool and its arguments. Copy Text ``` { "jsonrpc": "2.0", "id": 1, "result": { "resultType": "complete", "tools": [ { "name": "list_workflow_runs", "title": "List workflow runs", "description": "List recent runs of one workflow in a repository, newest first.", "inputSchema": { "type": "object", "properties": { "repository": { "type": "string", "description": "owner/repo" }, "workflow": { "type": "string", "description": "Workflow file name, such as integration.yml" }, "branch": { "type": "string" }, "status": { "type": "string", "enum": ["success", "failure", "cancelled"] }, "limit": { "type": "integer", "maximum": 100 } }, "required": ["repository"] } } ] } } ``` The model now has enough to answer the question without being told the argument names in advance. It emits one call, and the client sends it. Copy Text ``` { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "list_workflow_runs", "arguments": { "repository": "example-org/example-repo", "workflow": "integration.yml", "branch": "main", "status": "failure", "limit": 5 } } } ``` The server authenticates to the upstream system with the credential it was configured with, queries it, and returns text the model can read. Copy Text ``` { "jsonrpc": "2.0", "id": 2, "result": { "resultType": "complete", "content": [ { "type": "text", "text": "3 failed runs of integration.yml on main in the last 24 hours:\nrun 8421 started 03:11Z, api-tests failed after 12m41s\nrun 8419 started 01:52Z, api-tests failed after 12m08s\nrun 8412 started 22:40Z, api-tests failed after 11m55s" } ], "isError": false } } ``` Underneath, a server like this one is calling a documented endpoint, usually [list workflow runs for a repository](https://docs.github.com/en/rest/actions/workflow-runs) in the GitHub REST API, checked on 2026-08-13\. The protocol adds the discovery step and the argument schema on top of that endpoint, which is the part that lets a model use it correctly on the first try. Two properties fall out of the exchange above. Results carry identifiers, so `run 8421` is a link a person can open and verify, and a wrong answer is caught rather than believed. And several servers can be attached at once, so an assistant that holds one server for workflow runs and another for the runner fleet can join their results in a single turn without either server knowing the other exists. ## [Related Terms](#related-terms)Copy Anchor Link * [Querying GitHub Actions data through an MCP server](/guides/github-actions-mcp-server): connecting a host to a runner fleet, the configuration block, and worked prompts. * [Can an AI agent query my GitHub Actions metrics](/answers/can-an-ai-agent-query-my-github-actions-metrics): what an agent can read, what it can change, and the scopes involved. * [Running AI agents against GitHub Actions](/solutions/ai-agents-github-actions): the workload shape of agent driven jobs and how they are sized. * [WarpBuild MCP server documentation](/docs/ci/mcp): the server URL and the host configuration steps. * [API key automation documentation](/docs/ci/api-keys/automation): the API endpoints and scopes an MCP connected key reaches. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is an MCP server? An MCP server is a program that implements the Model Context Protocol and offers three kinds of capability to an AI application: tools the model can call, resources the application can read, and prompt templates a user can pick. The application discovers what a server offers by asking it, so no integration code is written for that one pairing. ### How is an MCP server different from a REST API? An MCP server usually sits in front of an API rather than replacing it. The difference is discovery and shape. A client sends one tools/list request and gets back every operation with a JSON Schema for its arguments, in a format every MCP host already parses, so the same server works in any host that speaks the protocol. ### Does the language model connect to the server itself? No. The host application holds the connection through a client, sends the tools/call request, and returns the result to the model. Credentials stay with the host and the server, and the specification says implementations should keep a human able to deny a tool invocation. --- # Monorepo | WarpBuild URL: https://www.warpbuild.com/glossary/monorepo Description: A monorepo is one version control repository holding several projects that share tooling and one change history, and what that shape means for GitHub Actions. # Monorepo A monorepo is one version control repository holding several projects that share tooling and one change history, and what that shape means for GitHub Actions. A monorepo is a single version control repository that holds several projects, which share one set of tooling and one change history. A change touching two of those projects lands as one commit, and a change to a shared project is visible to everything that depends on it at that same commit rather than after a package release. The layout changes what a build has to decide. A repository is the unit a workflow trigger fires on, so a commit anywhere in the tree is an event that could run every job in the repository, and the useful question becomes how much of the tree that commit actually invalidated. ## [Definition](#definition)Copy Anchor Link Three properties are what make a repository a monorepo. Losing any one of them turns the layout into a set of separate projects that happen to share a directory. 1. **One commit graph over every project.** There is one history, one set of branches, and one set of tags. A revert of a cross project change is one revert. 2. **Shared tooling at the root.** One workspace manifest or lockfile set, one formatter config, one linter config, and one place to change any of them. A tool upgrade is a single commit that every project picks up together. 3. **Source dependencies rather than published ones.** A project imports its neighbour from the tree at the current commit. There is no version number between them and no window where a consumer is on an older release. What a monorepo leaves open is as important as what it fixes. It says nothing about deployable units, release cadence, team ownership, or language. Repositories of this shape routinely ship many artifacts on independent schedules, and the source layout is what is shared. | Dimension | Monorepo | One repository per project | | --------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------ | | A change spanning two projects | One commit, one review, one merge | One pull request per repository, ordered by hand | | Dependency version a consumer uses | Whatever is in the tree at that commit | The published version that consumer pinned | | Upgrading a shared library | Consumers updated in the same commit | A release, then one bump per consumer, on each consumer's schedule | | Tooling config | One root config applied to every project | One config per repository, drifting apart over time | | Blast radius of a broken shared library | Every dependent project, at once | Only the consumers that have bumped | | What a workflow trigger sees | Every push to the repository, whatever moved | Only pushes to that one project | | Finding every caller of a function | One search over the tree | One search per repository, and only for the versions in use | ### [What changes for GitHub Actions](#what-changes-for-github-actions)Copy Anchor Link Workflow triggers are repository events. A workflow with a bare `on: push` key runs its jobs for a typo fix in a README exactly as it does for a change to the code every project imports. The trigger has no view of the dependency edges inside the tree, so the workflow supplies that view or pays for the full pipeline on every commit. GitHub Actions offers three places to narrow the work, and they operate at different times: * **`paths` and `paths-ignore` on the trigger.** The filter is evaluated before the run is created, so a filtered out workflow produces no run at all ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax), checked on 2026-08-13). A branch protection rule that requires a check from a workflow that never runs waits on a status that never arrives. * **`if` conditions on a job.** The run is created and the job is evaluated and then skipped, so the check reports a conclusion and branch protection is satisfied. The tradeoff is that the condition is expression syntax evaluated against the event payload. * **A change detection job.** One job computes which projects a commit affected, writes the list to `outputs`, and downstream jobs consume it through a matrix. This is the only one of the three that can follow dependency edges, because it runs real code against the checkout. Two limits bound the third option. A matrix expands to at most 256 jobs in one workflow run ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), and an empty matrix list makes the downstream job fail rather than skip, so the change detection job needs a defined behavior for a commit that affects nothing. ## [Example](#example)Copy Anchor Link Take a repository with five projects and four dependency edges: Copy Text ``` packages/utils shared library, imported by services/api and apps/web packages/ui component library, imported by apps/web services/api service, imports packages/utils apps/web app, imports packages/ui and packages/utils docs/ documentation site, imports nothing ``` This workflow builds and tests all of it on every pull request: Copy Text ``` name: build on: pull_request: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run build --workspaces - run: npm test --workspaces ``` Four commits, and the work each one genuinely requires against the work this workflow does: | Commit edits | Projects that must be rebuilt | Why | Work this workflow does | | -------------------------- | ----------------------------- | ----------------------------- | ------------------------- | | docs/getting-started.md | docs | Nothing imports the docs site | Builds and tests all five | | apps/web/src/App.tsx | web | Nothing imports web | Builds and tests all five | | packages/ui/src/Button.tsx | ui, web | web imports ui | Builds and tests all five | | packages/utils/src/date.ts | utils, api, web | Both consumers import utils | Builds and tests all five | Row four is the case the layout exists for and the case that costs the most. A change to a shared library has to rebuild and retest every project that imports it, because at this commit there is no published version standing between them, and the consumers are compiled against the source as edited. Row one is the case worth removing. A documentation typo triggers a full build and full test run of four projects that cannot possibly have changed behavior. A per project workflow with a `paths` filter removes it, at a price: Copy Text ``` name: api on: pull_request: paths: - "services/api/**" - "packages/utils/**" - ".github/workflows/api.yml" jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test --workspace services/api ``` The filter has to list `packages/utils/**` because the inputs of `api` include everything `api` imports. That list is a copy of the dependency graph, maintained by hand, in every workflow file. The day someone adds an import of `packages/ui` to `api` and forgets to edit this filter, a change to `ui` merges without `api` ever being tested, and the workflow stays green while doing so. Deriving the same list from the tree removes the hand maintained copy: Copy Text ``` name: build on: pull_request: jobs: select: runs-on: ubuntu-latest outputs: projects: ${{ steps.affected.outputs.projects }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - id: affected run: | base=$(git merge-base "origin/${{ github.base_ref }}" HEAD) echo "projects=$(./scripts/affected.sh "$base")" >> "$GITHUB_OUTPUT" test: needs: select if: needs.select.outputs.projects != '[]' strategy: matrix: project: ${{ fromJSON(needs.select.outputs.projects) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test --workspace ${{ matrix.project }} ``` `fetch-depth: 0` is required in the `select` job because [actions/checkout](https://github.com/actions/checkout) fetches a single commit by default, and `git merge-base` needs both branches present to answer. Getting the base commit wrong is the common failure here, and it fails in the expensive direction quietly: a bad base makes the changed file list too small, so affected projects are skipped and the pull request merges untested. Caching has its own monorepo shape. A cache key built from a hash of the root lockfile changes whenever any project changes a dependency, which invalidates the entry for every project at once. Keying per project and per toolchain, with a `restore-keys` prefix so a near miss still restores something, keeps one project's dependency bump from evicting the rest. ## [Related Terms](#related-terms)Copy Anchor Link * [Monorepo pipelines on GitHub Actions](/solutions/monorepo-github-actions): splitting the work across jobs and matrix shards, sizing each shard to what it does, and the worked numbers for both shapes. * [Task graph and how a build tool orders work](/glossary/task-graph): the derived graph behind change detection, how input hashes decide what is skipped, and why the critical path sets the floor on run time. * [Running only the tests a change affects](/guides/test-impact-analysis-github-actions): selecting the affected subgraph on a pull request and the failure modes of computing the base commit wrong. * [WarpBuild caching documentation](/docs/ci/features/caching): cache action inputs and key examples for per project cache scoping. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner labels available to a workflow, with sizes and images. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between a monorepo and one repository per project? A monorepo keeps several projects in one repository, so a change spanning two of them is one commit on one history and each project consumes its neighbours from the tree at that commit. With one repository per project, the same change is several pull requests, each project consumes a published version of its dependencies, and an upgrade reaches consumers only when each of them bumps the version it pinned. ### Does a monorepo mean everything deploys together? No. A monorepo describes where the source lives and how history is recorded. Release cadence, deployable units, and ownership are separate decisions, and repositories of this shape commonly ship a dozen artifacts on independent schedules from one commit graph. ### Why does a one line change trigger the whole pipeline in a monorepo? Because a workflow trigger fires on a repository event and carries no opinion about which directory moved. A workflow with a bare on push key runs the same jobs whether the commit edited a README or a shared library. Narrowing the work needs a paths filter on the trigger, an if condition per job, or a job that computes the changed set and hands it to a matrix. --- # Multi Arch Image | WarpBuild URL: https://www.warpbuild.com/glossary/multi-arch-image Description: A multi arch image is one image reference that resolves through a manifest list to a per architecture image, so a single tag serves x64 and ARM64 clients. # Multi Arch Image A multi arch image is one image reference that resolves through a manifest list to a per architecture image, so a single tag serves x64 and ARM64 clients. A multi arch image is an image reference that resolves to several per architecture images through a manifest list, so one tag such as `ghcr.io/example/app:1.4.0` serves both x64 and ARM64 clients. A client asks the registry for that tag, reads the list it gets back, and pulls only the entry whose platform matches its own operating system and CPU architecture. The tag therefore points at an index rather than at a single image. Most questions about multi arch images follow from that one level of indirection: what the index holds, how a client picks an entry, and how the index gets published after the per architecture builds finish. ## [Definition](#definition)Copy Anchor Link The index goes by two names because two specifications describe the same structure. The OCI form is the image index defined in the [OCI image index specification](https://github.com/opencontainers/image-spec/blob/main/image-index.md), and the older Docker form is the manifest list described in the [docker manifest reference](https://docs.docker.com/reference/cli/docker/manifest/), both checked on 2026-08-13\. Registries store either one, and current clients read either one. | Format | Media type of the index | Media type of each entry | | -------------------- | --------------------------------------------------------- | ---------------------------------------------------- | | OCI image index | application/vnd.oci.image.index.v1+json | application/vnd.oci.image.manifest.v1+json | | Docker manifest list | application/vnd.docker.distribution.manifest.list.v2+json | application/vnd.docker.distribution.manifest.v2+json | An index is a small JSON document. It holds no layers of its own, only pointers to the manifests that do: Copy Text ``` { "schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests": [ { "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:9f1c3b0a4d5e6f708192a3b4c5d6e7f80912a3b4c5d6e7f8091a2b3c4d5e6f708", "size": 1203, "platform": { "architecture": "amd64", "os": "linux" } }, { "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:4b7a1c2d3e4f50617283940a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e", "size": 1203, "platform": { "architecture": "arm64", "os": "linux" } } ] } ``` Each entry carries the fields a client needs in order to route itself. | Field | What it does | | --------------------- | ------------------------------------------------------------------------------------------- | | digest | The content address of one per architecture manifest, which is what the client fetches next | | size | Byte length of that manifest, so the client can bound the request before making it | | platform.os | linux, windows, or darwin | | platform.architecture | amd64, arm64, 386, ppc64le, s390x, and the other values the specification lists | | platform.variant | ARM revision selectors such as v6, v7, and v8 | Resolution runs in two registry calls. The client issues `GET /v2//manifests/` with an `Accept` header naming the media types it understands. The registry returns the index when the client accepts index media types, and returns a single manifest when it does not, which is how old clients still work against a multi arch tag. The client then scans the `manifests` array for a `platform` block matching itself and issues a second `GET /v2//manifests/` for that entry. Layers come after that, again by digest. One consequence is worth stating plainly. Pinning a deployment to `ghcr.io/example/app@sha256:...` using the index digest keeps the multi arch behavior, because the pinned document is still the index. Pinning to the digest of one entry pins the deployment to that single architecture. BuildKit also stores provenance and SBOM attestations inside the same index, documented in the [attestation storage reference](https://docs.docker.com/build/metadata/attestations/attestation-storage/). Those manifests carry `"platform": {"architecture": "unknown", "os": "unknown"}` so that no client selects them by accident, and they are the reason an index built with default settings often shows four entries where a reader expected two. ## [Example](#example)Copy Anchor Link The following GitHub Actions workflow builds each architecture on its own machine, pushes each result to the registry by digest with no tag attached, then joins the two digests into one manifest list in a second job. Copy Text ``` name: image on: push: branches: [main] env: IMAGE: ghcr.io/${{ github.repository }} jobs: build: strategy: matrix: include: - platform: linux/amd64 arch: amd64 - platform: linux/arm64 arch: arm64 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push by digest run: | docker buildx build \ --platform ${{ matrix.platform }} \ --provenance=false \ --output type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true \ --metadata-file metadata.json \ . jq -r '."containerimage.digest"' metadata.json > digest-${{ matrix.arch }}.txt - uses: actions/upload-artifact@v4 with: name: digest-${{ matrix.arch }} path: digest-${{ matrix.arch }}.txt manifest: needs: build runs-on: ubuntu-latest steps: - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: actions/download-artifact@v4 with: pattern: digest-* merge-multiple: true path: digests - name: Create the manifest list run: | docker buildx imagetools create \ --tag ${{ env.IMAGE }}:${{ github.sha }} \ --tag ${{ env.IMAGE }}:latest \ $(printf "${{ env.IMAGE }}@%s " $(cat digests/*.txt)) ``` Two details in the build job earn their place. `push-by-digest=true` uploads the image without claiming a tag, which keeps a half finished pair of architectures from ever appearing under `:latest`. `--provenance=false` keeps each pushed digest a plain image manifest, so `imagetools create` receives two manifests instead of two small indexes. The `arm64` leg needs a machine that can emit `arm64` output. That is either an ARM64 runner executing the build natively or an x64 runner with QEMU emulation installed by `docker/setup-qemu-action`. After the second job finishes, reading the tag shows the list that was published: Copy Text ``` $ docker buildx imagetools inspect ghcr.io/example/app:latest Name: ghcr.io/example/app:latest MediaType: application/vnd.oci.image.index.v1+json Digest: sha256:1d0f7c9b2a3e4d5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7 Manifests: Name: ghcr.io/example/app:latest@sha256:9f1c3b0a4d5e6f70... MediaType: application/vnd.oci.image.manifest.v1+json Platform: linux/amd64 Name: ghcr.io/example/app:latest@sha256:4b7a1c2d3e4f5061... MediaType: application/vnd.oci.image.manifest.v1+json Platform: linux/arm64 ``` `docker pull ghcr.io/example/app:latest` on an x64 host now resolves to `sha256:9f1c3b0a`, and the same command on an ARM64 host resolves to `sha256:4b7a1c2d`. Neither host learns about the other entry, and the pull command is identical on both. Adding `--platform linux/amd64` forces the first entry on either host, which is the usual way to reproduce an x64 failure from an ARM64 laptop. A single invocation produces the same index when one machine can build both architectures: `docker buildx build --platform linux/amd64,linux/arm64 --push .` writes the index directly. The split shape above exists so that each architecture builds on hardware that matches it, and so that a failure in one architecture leaves the published tag untouched. ## [Related Terms](#related-terms)Copy Anchor Link * [How to push a manifest list from GitHub Actions](/answers/how-do-i-push-a-manifest-list-from-github-actions): the direct answer for a workflow that already builds both architectures and needs the join step. * [Multi platform Docker builds in GitHub Actions](/guides/multi-platform-docker-builds): the full workflow shape, including emulation, native ARM64 jobs, and cache handling across the matrix. * [Image digest, the content address an index entry points at](/glossary/image-digest): how a `sha256:` reference is computed and why pinning the index digest differs from pinning one entry. * [OCI image index specification](https://github.com/opencontainers/image-spec/blob/main/image-index.md): the upstream field list for the index document. * [WarpBuild Docker builder documentation](/docs/ci/docker-builders): builder profiles, the architectures a profile can be configured with, and the actions that attach a build to one. * [WarpBuild cloud runner documentation](/docs/ci/cloud-runners): the runner labels available for x64 and ARM64 jobs. * [WarpBuild pricing](/pricing): per minute rates by runner type and by builder size. ## FAQ ### What is the difference between a manifest list and an image index? They are the same idea in two formats. A Docker manifest list carries the media type application/vnd.docker.distribution.manifest.list.v2+json and an OCI image index carries application/vnd.oci.image.index.v1+json. Both hold a list of per architecture manifests with a platform block on each entry, and registries and clients accept either. ### How does a client know which architecture to pull? The client requests the tag with an Accept header listing the manifest media types it understands. When the registry returns an index, the client compares its own operating system and CPU architecture against the platform block of each entry and fetches the matching manifest by digest. Passing --platform to docker pull or docker run overrides that choice. ### Why does my manifest list contain unknown/unknown entries? Those are attestation manifests. BuildKit stores provenance and SBOM attestations as extra manifests inside the index with the platform set to unknown/unknown, so tools that read the index without filtering will show them next to the real architecture entries. Building with --provenance=false leaves the index with the architecture entries alone. --- # Multi Stage Build | WarpBuild URL: https://www.warpbuild.com/glossary/multi-stage-build Description: A multi stage Docker build is a Dockerfile with several FROM stages, where a later stage copies only the artifacts it needs out of an earlier one. # Multi Stage Build A multi stage Docker build is a Dockerfile with several FROM stages, where a later stage copies only the artifacts it needs out of an earlier one. A multi stage Docker build is a Dockerfile that contains several `FROM` instructions, each of which opens a new stage, where a later stage copies only the artifacts it needs out of an earlier one with `COPY --from`. Everything the earlier stage used to produce those artifacts, such as the compiler, the source tree, and the package manager download directory, stays behind in a stage that is never part of the exported image. The pattern exists because building software needs tools that running it does not. A single stage Dockerfile ships both, since every instruction in the file adds a layer to the one image the file produces. Splitting the file into stages lets the build keep its toolchain while the image keeps only its output. ## [Definition](#definition)Copy Anchor Link A stage is the span of a Dockerfile from one `FROM` instruction up to the next `FROM` or the end of the file. Stages are numbered from zero in the order they appear, and `AS` gives a stage a name that later instructions can reference. Docker has supported the syntax since Engine 17.05 ([multi-stage builds documentation](https://docs.docker.com/build/building/multi-stage/), checked on 2026-08-13). Four pieces of syntax carry the whole pattern. | Syntax | Where it appears | What it does | | ------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------ | | FROM AS | First line of a stage | Opens a stage on the filesystem of a published image and names it | | FROM AS | First line of a stage | Opens a stage on the filesystem an earlier stage ended with, so the new stage inherits everything that stage installed | | COPY --from= | Inside a later stage | Reads the named paths out of another stage and writes them into the current one. Nothing beyond those paths crosses over | | \--target | On the build command | Ends the build at the named stage instead of the last stage in the file | ### [What ends up in the image](#what-ends-up-in-the-image)Copy Anchor Link The exported image is the stage the build ended at. Its layers are the instructions of that stage plus the layers of whatever it started `FROM`. Earlier stages contribute exactly the paths a `COPY --from` named and nothing else, which is what separates a build stage from the image that ships. | Content | Present in the builder stage | Present in the runtime stage | | ---------------------------------------- | ---------------------------- | ---------------------------------------- | | Compiler and language toolchain | Yes | No | | Source tree and version control metadata | Yes | No | | Package manager download directory | Yes | No | | Build output, such as a compiled binary | Yes | Yes, at the path COPY --from wrote it to | | Files supplied by the runtime base image | No | Yes | Build secrets follow the same boundary. A credential mounted or copied into an early stage is absent from the runtime stage unless a `COPY --from` names its path, so the secret never reaches a layer that gets pushed to a registry. ### [Which stages actually run](#which-stages-actually-run)Copy Anchor Link A build does not execute every stage in the file. The builder resolves the stage it was asked for, walks back through the stages that one inherits from or copies out of, and runs only those. A stage the selected target never reaches is skipped entirely, along with any downloads it would have performed. That turns `--target` into a way to get several images out of one Dockerfile. A `test` stage carrying the test runner and a `release` stage carrying the binary can both descend from the same dependency stage, and each build reaches only the branch it asked for. ### [Copying from outside the file](#copying-from-outside-the-file)Copy Anchor Link `COPY --from` also accepts an image reference in place of a stage name, so a stage can lift one file out of a published image without inheriting that image's layers. Writing `COPY --from=alpine:3.20 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/` puts a certificate bundle into a base image that ships without one, and nothing else from the alpine image comes along ([Dockerfile reference](https://docs.docker.com/reference/dockerfile/#copy---from), checked on 2026-08-13). ### [Stages form a graph](#stages-form-a-graph)Copy Anchor Link Stages are a dependency graph rather than a sequence, even though the file reads top to bottom. Two stages that reference nothing from each other have no edge between them, and a builder with spare capacity runs them at the same time. A stage that copies from another waits for that one to finish. ## [Example](#example)Copy Anchor Link A Go service compiled in one stage and shipped from another. The `builder` stage holds the toolchain and the downloaded modules; the final stage starts from a minimal base image and receives a single file. Copy Text ``` # syntax=docker/dockerfile:1 FROM golang:1.24-bookworm AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server FROM gcr.io/distroless/static-debian12:nonroot COPY --from=builder /out/server /usr/local/bin/server USER nonroot ENTRYPOINT ["/usr/local/bin/server"] ``` The final stage runs three instructions. It has no `/src`, no Go module cache under `/go/pkg/mod`, and no compiler, because the only path crossing the stage boundary is `/out/server`. The distroless base it starts from ships without a shell and without a package manager ([distroless project](https://github.com/GoogleContainerTools/distroless), checked on 2026-08-13), so the running container holds the binary plus the certificates and timezone data that base image provides. The workflow below builds that Dockerfile on a GitHub Actions runner. Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 with: context: . push: false tags: example/server:latest ``` No `target` input is set, so the build ends at the last stage in the file and the runtime image is what comes out. Adding `target: builder` to the same step ends the build one stage earlier and produces an image containing the toolchain and the compiled binary, which helps when a compile failure needs an interactive shell and is the wrong thing to push to a registry. Two behaviors are worth tracing across pushes of this workflow. A commit that touches only `cmd/server` leaves the `go.mod` and `go.sum` copy above it unchanged, so `go mod download` can be reused while the compile reruns. A commit that adds a module changes the lockfile copy and reruns both. Whether that reuse survives from one workflow run to the next is a separate question from the Dockerfile. `docker/setup-buildx-action` creates a builder inside the job, and that builder is discarded when the job ends, so the `builder` stage starts cold on the next push unless the build imports layer cache from a store that outlives the job or runs on a builder whose storage does. Stage layout decides how much work a cache hit can save; the builder's lifetime decides whether the hit happens at all. ## [Related Terms](#related-terms)Copy Anchor Link * [Multi stage Docker builds and layer reuse on GitHub Actions](/guides/multi-stage-docker-builds-github-actions): stage layout as a cache decision, and what a job-local builder loses between runs. * [The base image a FROM instruction starts a stage from](/glossary/base-image): how the first line of each stage decides the filesystem that stage builds on. * [Cutting container image size in a GitHub Actions build](/guides/container-image-size-reduction-github-actions): the levers besides stage splitting that change what a pushed image weighs. * [Docker multi-stage build documentation](https://docs.docker.com/build/building/multi-stage/): the upstream reference for stage syntax and target selection. * [WarpBuild Docker builders documentation](/docs/ci/docker-builders): builder profiles and the actions that point a workflow at a builder. * [WarpBuild caching documentation](/docs/ci/features/caching): cache usage inside GitHub Actions workflows. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### How many stages can one Dockerfile have? There is no fixed limit. Every FROM instruction opens a stage, stages are numbered from zero in the order they appear, and a stage can be given a name with AS so that later instructions reference it by name instead of by index. ### Which stage becomes the image when no target is passed? The last stage in the file. Passing a target ends the build at the named stage instead, which is how one Dockerfile produces a test image and a runtime image from the same set of earlier stages. ### Can COPY --from read from an image instead of a stage in the file? Yes. COPY --from accepts an image reference as well as a stage name, so a stage can lift one file out of a published image without inheriting that image's layers. Copying a certificate bundle into a minimal base image is the common case. --- # NAT Gateway | WarpBuild URL: https://www.warpbuild.com/glossary/nat-gateway Description: A NAT gateway is a managed device that lets instances in a private subnet start outbound connections to the internet while accepting none from it. # NAT Gateway A NAT gateway is a managed device that lets instances in a private subnet start outbound connections to the internet while accepting none from it. A NAT gateway is a managed network device that lets instances in a private subnet start outbound connections to the internet while accepting no connections initiated from the internet. It performs network address translation: the private source address on every outgoing packet is rewritten to the gateway's own public address, and replies are translated back and delivered to the instance that asked for them. The name comes from the translation function, and the word gateway signals that a route table points at it. Every major cloud provider sells a managed version, and the shape repeats in each one: a device with a public address, a default route from the private subnet pointing at it, and a bill with an hourly component and a per gigabyte component. ## [Definition](#definition)Copy Anchor Link Three pieces have to line up before a NAT gateway does anything. A **private subnet** is an address range whose route table carries no route to an internet gateway. Instances in it hold addresses from the RFC 1918 private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), which are not routable on the public internet. A **public subnet** is an address range whose route table sends `0.0.0.0/0` to an internet gateway. The NAT gateway lives here and holds a public address of its own. A **default route** in the private subnet's route table sends `0.0.0.0/0` to the NAT gateway rather than to the internet gateway. That one line is what turns a subnet from isolated into outbound only. The translation itself is port address translation. The gateway holds a table entry per flow: source address, source port, destination address, destination port. On the way out it rewrites the source pair to its own address and a port it allocates. On the way back it looks the reply up in the same table and rewrites the destination back to the instance. A packet arriving from the internet is dropped unless it matches an entry the gateway created on the way out. That asymmetry is the security property people buy a NAT gateway for, and it is why a machine behind one needs no inbound firewall rule at all. Three devices can carry traffic out of a subnet, and they differ on direction and on price. | Device | Direction allowed | Who can start a connection | Per gigabyte processing charge | | ----------------------------------- | -------------------- | -------------------------- | ------------------------------ | | Internet gateway | Inbound and outbound | Either side | No | | NAT gateway | Outbound only | The instance | Yes | | Gateway VPC endpoint (S3, DynamoDB) | To that one service | The instance | No | A NAT gateway on AWS is a zonal resource. It lives in one availability zone, so instances in another zone that route through it pay cross zone data transfer and lose their path entirely when that zone fails. The usual arrangement is one gateway per availability zone with a route table per zone. AWS documents each gateway as supporting up to 55,000 simultaneous connections to each unique destination and as scaling from 5 Gbps up to 100 Gbps of bandwidth ([AWS NAT gateway documentation](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-nat-gateway.html), checked on 2026-08-13). Exhausting the port range against a single destination surfaces as an `ErrorPortAllocation` metric rather than as a slow connection, which is why heavy fan out to one host is worth watching. Two variants exist. A public NAT gateway carries IPv4 traffic to the internet. IPv6 uses a separate device, the egress only internet gateway, with the same outbound only behavior and no per gigabyte charge. A private NAT gateway translates between networks with overlapping address ranges and reaches no internet at all. The bill has two parts. In US East (N. Virginia) AWS charges $0.045 per hour for each NAT gateway plus $0.045 per GB of data processed in either direction ([Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13). The hourly part accrues whether or not a packet moves: 730 hours in a 30 day month is $32.85 per gateway, so a three zone layout costs $98.55 a month before any traffic. Google Cloud and Azure sell the same function as Cloud NAT and NAT Gateway, and both bill on the same two axes. ## [Example](#example)Copy Anchor Link A team moves its GitHub Actions runners into a private subnet so that outbound traffic leaves from a fixed set of addresses and nothing on the internet can reach the machines. The workflow file does not change, because network placement is a property of the machine rather than of the job: Copy Text ``` name: build on: push: branches: [main] jobs: package: runs-on: [self-hosted, linux, x64] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: docker build -t app:${{ github.sha }} . ``` Every step in that job talks to something outside the VPC, and the route table decides which of those conversations passes through the meter. | Traffic | Destination | Path out | Per gigabyte charge | | -------------------------------------------------------------- | ------------------------- | -------------------- | ------------------- | | Action downloads and actions/checkout | github.com | NAT gateway | Yes | | npm ci dependency tarballs | Public package registry | NAT gateway | Yes | | Base image layers pulled by docker build | Public container registry | NAT gateway | Yes | | Log and status streaming from the runner agent | github.com | NAT gateway | Yes | | Cache and artifact writes to object storage in the same region | S3 | Gateway VPC endpoint | No | Put numbers on the top four rows. A fleet running 1,000 jobs a month, each pulling 1.5 GB of actions, packages, and image layers, moves 1,500 GB through the gateway. At the US East (N. Virginia) rate that is 1,500 times $0.045, which is $67.50 in data processing. Three gateways across three availability zones add 3 times 730 hours times $0.045, which is $98.55 in hourly charges. The network bill for that fleet is $166.05 a month, and none of it lands on the compute line where anyone is looking. Two levers move that number. The first is the last row of the table. Gateway VPC endpoints for S3 and DynamoDB carry no hourly charge and no per gigabyte charge ([Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13), so cache and artifact traffic routed through one leaves the metered path. The [AWS setup checklist for runners in your own cloud account](/docs/ci/byoc/aws/config) states both halves of this: private subnets must have a NAT gateway, and an S3 gateway endpoint should be configured for the VPC so that runners reach the bucket without data transfer charges. The second lever is placement. A runner in a public subnet with its own public address skips the gateway and its meter. The trade is the stable outbound address, because only traffic leaving through a NAT gateway carries the gateway's address, and that address is what a partner allowlist or a database firewall rule is written against. One distinction is worth keeping straight. A private subnet is about how a machine reaches the internet, and a NAT gateway serves that case. Reaching an internal service that has no public address at all is a different problem, solved by a peered network or an overlay joined when the job starts, as covered in the [networking documentation](/docs/ci/features/networking). ## [Related Terms](#related-terms)Copy Anchor Link * [Whether runners in your own cloud account need a NAT gateway](/answers/do-byoc-runners-need-a-nat-gateway): when the private subnet path is required and when a public subnet is enough. * [Subnet, the address range a route table applies to](/glossary/subnet): what separates a public subnet from a private one, and why the difference is a single route. * [Egress costs and where outbound charges come from](/glossary/egress-costs): the other metered paths on a build, including data transfer out and cross zone traffic. * [AWS setup checklist for runners in your own cloud account](/docs/ci/byoc/aws/config): the VPC, subnet, NAT gateway, and S3 endpoint prerequisites in one list. * [Networking documentation](/docs/ci/features/networking): joining a runner to a private network at job start. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a NAT gateway in simple terms? A NAT gateway is a managed device that sits between a private subnet and the internet. Instances behind it can start outbound connections, and the gateway rewrites the private source address on each packet to its own public address so replies find their way back. Nothing on the internet can start a connection inward, because the gateway drops any packet that fails to match an entry it created on the way out. ### What is the difference between a NAT gateway and an internet gateway? An internet gateway allows connections in both directions, so an instance behind one can be reached from the internet if it holds a public address and a security group permits it. A NAT gateway allows outbound connections only and carries a per gigabyte data processing charge that an internet gateway does not. A subnet becomes private by routing 0.0.0.0/0 to a NAT gateway instead of to an internet gateway. ### Why does a NAT gateway show up on the cloud bill? Because it bills on two axes. In US East (N. Virginia) AWS charges $0.045 per hour for each gateway plus $0.045 per GB of data processed in either direction (Amazon VPC pricing, checked on 2026-08-13). The hourly charge accrues while the gateway is idle, and the per gigabyte charge applies to downloads that are otherwise free, such as package tarballs and container image layers. --- # Nested Virtualization | WarpBuild URL: https://www.warpbuild.com/glossary/nested-virtualization Description: Nested virtualization runs a virtual machine inside a machine that is already virtualized. It needs the host to expose hardware virtualization to the guest. # Nested Virtualization Nested virtualization runs a virtual machine inside a machine that is already virtualized. It needs the host to expose hardware virtualization to the guest. Nested virtualization is running a virtual machine inside a machine that is already virtualized. It works only when the host exposes the CPU's hardware virtualization extensions to its guest, because the guest kernel needs those extensions to act as a hypervisor for a second layer of virtual machines. The term shows up in GitHub Actions whenever a job wants to boot something heavier than a container: an Android emulator, a QEMU guest, a libvirt domain, or a virtual machine image under test. Every runner is already a guest of some hypervisor, so the second layer exists only if the first layer permits it. ## [Definition](#definition)Copy Anchor Link Hardware virtualization on x86 is a set of CPU instructions, published by Intel as VT-x and by AMD as AMD-V, that lets a hypervisor run guest code directly on the processor and trap the privileged operations it cares about. Arm publishes an equivalent set of virtualization extensions. Without those instructions a hypervisor has to interpret or rewrite guest instructions in software, which is correct and slow. Nested virtualization is the case where the guest itself wants those instructions. Kernel documentation names the layers L0 for the bare metal host and its hypervisor, L1 for the guest that hypervisor runs, and L2 for the guest that L1 runs ([KVM nested VMX documentation](https://docs.kernel.org/virt/kvm/x86/nested-vmx.html), checked on 2026-08-13). L0 keeps real control of the hardware and emulates the virtualization instructions on behalf of L1, so L1 believes it is talking to a bare processor. | Layer | What it is | What runs there | | ----- | ------------------------------------------------------- | --------------------------------------------------------------------------------------- | | L0 | The physical machine | The cloud provider's hypervisor, with real access to VT-x, AMD-V, or the Arm extensions | | L1 | The virtual machine you rent, such as a workflow runner | The guest kernel, plus a hypervisor of its own when L0 exposes the extensions | | L2 | The virtual machine the job starts | An Android emulator, a QEMU guest, or a virtual appliance under test | The decision belongs to L0\. A guest cannot switch nested virtualization on from inside, because the instructions either appear in the virtual processor it was handed or they do not. Google Cloud states the same division in its own terms and requires the feature to be enabled on the instance or machine image before a guest can use it ([Compute Engine nested virtualization overview](https://cloud.google.com/compute/docs/instances/nested-virtualization/overview), checked on 2026-08-13). ### [How the guest sees the result on Linux](#how-the-guest-sees-the-result-on-linux)Copy Anchor Link On Linux the visible outcome is a character device at `/dev/kvm`. The kernel creates it when the KVM module loads, and KVM loads only when the processor the guest can see offers usable virtualization extensions. Three states are worth telling apart, because two of them produce a slow job rather than a failed one. | What the job finds | Cause | What happens next | | ---------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------- | | No /dev/kvm | The outer hypervisor kept the virtualization extensions to itself | Any tool that wants hardware acceleration falls back to software emulation | | /dev/kvm with crw-rw---- root:kvm | Extensions exposed, and the job user is outside the kvm group | Opening the device is refused, and the same software fallback follows | | /dev/kvm readable and writable by the job user | Extensions exposed and permissions widened by a udev rule | The second layer of virtual machines runs with hardware acceleration | Nested virtualization is a different mechanism from containers. A container shares the kernel of the machine it runs on and isolates processes with namespaces and cgroups, so it needs nothing from the CPU beyond ordinary user mode. A nested virtual machine brings its own kernel, its own virtual devices, and its own memory map, which is why the CPU has to help. ## [Example](#example)Copy Anchor Link An Android instrumentation suite is the common case in GitHub Actions. The emulator boots a full Android system image, and it runs at a usable speed only when it can hand the guest to the processor through `/dev/kvm`. Copy Text ``` name: instrumentation-tests on: push: branches: [main] jobs: android-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: 17 - name: Report virtualization support run: | grep -c -E 'vmx|svm' /proc/cpuinfo || echo "no virtualization flags" ls -l /dev/kvm || echo "no /dev/kvm on this runner" - uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 arch: x86_64 script: ./gradlew connectedCheck ``` On a runner whose host withholds the extensions, the reporting step prints `no /dev/kvm on this runner` and the workflow keeps going. The emulator action probes the device before launch, logs `ProbeKVM: This user doesn't have permissions to use KVM (/dev/kvm).`, logs `Disabling Linux hardware acceleration.`, and starts the emulator with `-accel off` ([android-emulator-runner README](https://github.com/ReactiveCircus/android-emulator-runner#running-hardware-accelerated-emulators-on-linux-runners), checked on 2026-08-13). The suite still passes, on a timescale that makes people blame the tests. The device existing is half the requirement. Default permissions on `/dev/kvm` are `crw-rw---- root:kvm`, which leaves an ordinary job user unable to open it, so a udev rule has to widen them first: Copy Text ``` - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm ``` That step is a property of the device permissions rather than of any one runner fleet, so it belongs in the workflow wherever the emulator runs. GitHub announced hardware accelerated Android virtualization for a subset of its own hosted runner classes and pointed at the same permission step ([GitHub changelog, 23 February 2023](https://github.blog/changelog/2023-02-23-hardware-accelerated-android-virtualization-on-actions-windows-and-linux-larger-hosted-runners/), checked on 2026-08-13), which is a reminder that support varies by runner class rather than by operating system. Two habits keep this cheap to diagnose. Print `ls -l /dev/kvm` early in any job that expects nested virtualization, and fail the job when the device is absent instead of letting the run continue, so a change in runner class shows up as a red build rather than as a duration that creeps up over a quarter. ## [Related Terms](#related-terms)Copy Anchor Link * [Nested virtualization runners and the labels that request them](/runners/nested-virtualization): which runner classes expose `/dev/kvm` and how a workflow asks for one. * [KVM](/glossary/kvm): the Linux kernel module that turns exposed virtualization extensions into a usable hypervisor interface. * [Android emulator tests in GitHub Actions](/solutions/android-emulator-tests-github-actions): a full workflow for instrumentation tests, including emulator startup and caching. * [Nested virtualization documentation](/docs/ci/features/nested-virtualization): the runner class support matrix and the configuration for enabling it. * [Common issues documentation](/docs/ci/common-issues): the symptoms of a silent software emulation fallback and the fix. * [Runner pricing](/pricing): per minute rates by runner type. ## FAQ ### What is nested virtualization? Nested virtualization is running a virtual machine inside a machine that is already a virtual machine. The outer hypervisor has to expose the host CPU's hardware virtualization extensions to its guest, so that the guest kernel can act as a hypervisor for a second layer of virtual machines. ### How do I tell whether a GitHub Actions runner supports nested virtualization? Run \`ls -l /dev/kvm\` in a step on a Linux runner. A missing device means the outer hypervisor kept the virtualization extensions to itself. A device that exists with \`crw-rw---- root:kvm\` permissions means the extensions are exposed and the job user still needs group access before it can open the device. ### Why does an Android emulator job get slow instead of failing? The android-emulator-runner action probes /dev/kvm before it launches. When the probe fails it logs a ProbeKVM line, disables Linux hardware acceleration, and starts the emulator with \`-accel off\`, so the job keeps running under software emulation and finishes much later than usual. --- # OIDC Token Exchange | WarpBuild URL: https://www.warpbuild.com/glossary/oidc-token-exchange Description: OIDC token exchange is the pattern where a workflow presents a short lived identity token to a cloud provider and receives temporary credentials back. # OIDC Token Exchange OIDC token exchange is the pattern where a workflow presents a short lived identity token to a cloud provider and receives temporary credentials back. OIDC token exchange is the pattern where a workload presents a short lived identity token, signed by an identity provider the other side already trusts, and receives temporary credentials scoped to what that identity is allowed to do. In GitHub Actions it means a job asks GitHub for an OpenID Connect token describing the repository, branch, and workflow it is running from, then trades that token with a cloud provider for credentials that expire on their own, so the repository stores no long lived access key. The exchange replaces a stored secret with a stated identity. What used to be an access key ID and a secret access key sitting in repository secrets becomes a trust policy on the cloud side that names one repository and one branch. ## [Definition](#definition)Copy Anchor Link Three parties take part in every OIDC token exchange, whatever the platform. * **The workload.** The thing that needs credentials. In GitHub Actions this is a single job. * **The identity provider.** The service that mints and signs a token describing the workload. For GitHub Actions the issuer is `https://token.actions.githubusercontent.com`. * **The relying party.** The service holding the resources. It validates the token signature against the issuer's published keys and compares the token claims against a policy that was configured once, in advance. The generic mechanism is standardized. OpenID Connect defines the token format and the discovery endpoint where a relying party fetches the issuer's signing keys, and [OAuth 2.0 Token Exchange (RFC 8693)](https://datatracker.ietf.org/doc/html/rfc8693) defines the request shape for trading one token for another. Each cloud provider brands its implementation differently: AWS calls it web identity federation, Azure and Google Cloud both call it workload identity federation. The steps underneath are the same three. The property that makes the pattern worth adopting is directionality. The relying party pulls the issuer's public signing keys over the network and verifies the token itself. Nothing secret travels from the cloud provider into the repository, so there is nothing in the repository to leak, rotate, or forget to revoke when a contributor leaves. ### [What the identity token contains](#what-the-identity-token-contains)Copy Anchor Link The token GitHub issues is a signed JSON Web Token. The claims below are the ones a trust policy usually reads ([GitHub documentation on security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect), checked on 2026-08-13). | Claim | Value it carries | Why a policy reads it | | -------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------- | | iss | https://token.actions.githubusercontent.com | Identifies which issuer signed the token | | sub | repo:OWNER/REPO:ref:refs/heads/main and similar | The main condition a trust policy matches on | | aud | Defaults to the repository owner URL, overridable per request | Stops a token minted for one service from being replayed at another | | repository | OWNER/REPO | Restricts the exchange to one repository | | repository\_owner | OWNER | Restricts the exchange to one organization | | ref | refs/heads/main, refs/tags/v1.2.0 | Restricts the exchange to one branch or tag | | environment | The deployment environment name, when the job declares one | Ties production credentials to a protected environment | | job\_workflow\_ref | Path and ref of the workflow file that ran the job | Restricts the exchange to one reusable workflow | | actor | The account that triggered the run | Audit trail on the cloud side | | sha, run\_id, run\_attempt | Commit and run identifiers | Correlating a credential back to a run | ### [How the subject claim scopes the exchange](#how-the-subject-claim-scopes-the-exchange)Copy Anchor Link `sub` is the claim most trust policies key on, and its format varies with what triggered the job: Copy Text ``` repo:example-org/example-repo:ref:refs/heads/main repo:example-org/example-repo:ref:refs/tags/v1.2.0 repo:example-org/example-repo:environment:production repo:example-org/example-repo:pull_request ``` An exact string comparison on one of those values is what limits a role to one repository and one branch. Loosening the comparison to a wildcard over the owner, such as a prefix match on `repo:example-org/`, hands the same role to every repository in the organization, including a repository a contributor creates tomorrow. Match the full subject, and add a second condition on `aud`. ### [What the job needs before it can ask](#what-the-job-needs-before-it-can-ask)Copy Anchor Link The identity token request depends on one grant in the workflow file: Copy Text ``` permissions: id-token: write ``` `id-token` accepts `write` or `none`, with no read level, because requesting a token is the only operation the scope covers. When the grant is present, the runner exposes `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` to the steps in that job, and an action calls that endpoint to mint a token for whatever audience it asks for. When the grant is absent, both variables are missing and the request fails before it reaches GitHub. A token can be requested only while the job is running, so nothing persists after the job ends. ## [Example](#example)Copy Anchor Link This workflow deploys from `main`. The `deploy` job asks for an identity token, hands it to AWS, and gets back temporary credentials that work for that job alone. Copy Text ``` name: deploy on: push: branches: [main] permissions: {} jobs: deploy: runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy aws-region: us-east-1 - name: Confirm the assumed identity run: aws sts get-caller-identity - run: aws s3 sync ./dist s3://example-artifacts/site/ --delete ``` `permissions: {}` at the top of the file sets every scope to `none` for any job that stays silent. The `deploy` job then restates the two grants it needs. `contents: read` lets `actions/checkout` fetch the commit, and `id-token: write` is what makes the credential step possible. The other half of the configuration lives in AWS and is written once. The role's trust policy names the GitHub issuer as a federated principal and pins both `aud` and `sub`: Copy Text ``` { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:example-org/example-repo:ref:refs/heads/main" } } } ] } ``` With both halves in place, a push to `main` produces this sequence: 1. GitHub starts the `deploy` job and injects the two `ACTIONS_ID_TOKEN_REQUEST_*` variables, because the job holds `id-token: write`. 2. The credentials action calls the request URL with audience `sts.amazonaws.com` and receives a signed JWT whose `sub` is `repo:example-org/example-repo:ref:refs/heads/main`. 3. The action calls [sts:AssumeRoleWithWebIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API%5FAssumeRoleWithWebIdentity.html) with that JWT and the role ARN. 4. AWS fetches GitHub's public signing keys from the issuer's discovery endpoint, verifies the signature, then compares `aud` and `sub` against the two conditions in the trust policy. 5. AWS returns a temporary access key, secret key, and session token. The default session lasts one hour, and the role's maximum session duration caps it ([AWS STS API reference](https://docs.aws.amazon.com/STS/latest/APIReference/API%5FAssumeRoleWithWebIdentity.html), checked on 2026-08-13). 6. The action exports those three values as environment variables, so `aws sts get-caller-identity` and `aws s3 sync` authenticate as the role. When the job ends, the session expires on its own. A push to any branch other than `main` changes `sub`, step 4 fails the string comparison, and AWS returns an error instead of credentials. The branch restriction is enforced by the cloud provider against a claim GitHub signed, so editing the workflow file cannot widen it. ### [Exchanging with a provider that has no ready made action](#exchanging-with-a-provider-that-has-no-ready-made-action)Copy Anchor Link When the relying party is an internal service or a provider without a published action, request the token directly and post it wherever that service expects it: Copy Text ``` - name: Request an identity token run: | curl --silent --fail \ --header "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://vault.example.com" \ | jq -r '.value' > "${RUNNER_TEMP}/id-token" ``` The audience string has to match what the receiving service expects, because that service compares `aud` before it looks at anything else. Write the token to a file under `RUNNER_TEMP` rather than into a step output, so the value stays off the log stream and out of the run's artifacts. ## [Related Terms](#related-terms)Copy Anchor Link * [How to authenticate to AWS from GitHub Actions with OIDC](/answers/how-do-i-authenticate-to-aws-from-github-actions-with-oidc): the identity provider registration, the role trust policy, and the errors each misconfiguration produces. * [GITHUB\_TOKEN, the per job credential the id-token grant sits alongside](/glossary/github-token): where the job's GitHub credential comes from, what it reaches, and how long it lasts. * [Managing secrets in GitHub Actions workflows](/guides/github-actions-secrets-management): repository, environment, and organization secrets, and which of them this pattern removes. * [WarpBuild runner security documentation](/docs/ci/security): how runner isolation, storage, and secrets handling are documented. * [AWS account configuration for BYOC runners](/docs/ci/byoc/aws/config): the VPC, subnet, security group, and IAM prerequisites for running jobs in your own AWS account. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is OIDC token exchange in GitHub Actions? A job asks GitHub's OpenID Connect provider for a signed identity token that describes the repository, branch, and workflow it is running from. It sends that token to a cloud provider, which validates the signature and compares the claims against a trust policy, then returns temporary credentials. No long lived cloud key is stored in the repository. ### Why does the identity token request fail inside my job? The job is missing the id-token write grant. GitHub injects the ACTIONS\_ID\_TOKEN\_REQUEST\_URL and ACTIONS\_ID\_TOKEN\_REQUEST\_TOKEN environment variables only when the job holds id-token write, and naming any scope in a permissions block sets every scope left out to none, so a workflow level block that lists other scopes removes it. ### How is the exchange restricted to one branch? Through the sub claim. GitHub sets sub to a string such as repo:example-org/example-repo:ref:refs/heads/main, and the trust policy on the cloud side compares it with an exact string match. A push to any other branch produces a different sub, so the comparison fails and no credentials are issued. --- # OpenTelemetry | WarpBuild URL: https://www.warpbuild.com/glossary/opentelemetry Description: OpenTelemetry is an open standard and a set of libraries for emitting traces, metrics, and logs in one vendor-neutral format. Signals, OTLP, and an example. # OpenTelemetry OpenTelemetry is an open standard and a set of libraries for emitting traces, metrics, and logs in one vendor-neutral format. Signals, OTLP, and an example. OpenTelemetry is an open standard and a set of libraries for emitting traces, metrics, and logs in one vendor-neutral format. Any process that speaks it can send telemetry to any backend that accepts it, so the instrumentation in your code stops being tied to the tool that stores the data ([What is OpenTelemetry](https://opentelemetry.io/docs/what-is-opentelemetry/), checked on 2026-08-13). That portability is the whole point of the project. Before it existed, adding a second monitoring tool meant a second agent, a second SDK, and a second set of attribute names for the same facts. ## [Definition](#definition)Copy Anchor Link OpenTelemetry, usually shortened to OTel, is a specification plus the APIs, SDKs, and tools that implement it. It is a Cloud Native Computing Foundation project, formed in 2019 by merging the OpenTracing and OpenCensus efforts into one standard, and it is maintained in the open at [github.com/open-telemetry](https://github.com/open-telemetry). The standard covers how telemetry is produced and moved. Storage, indexing, querying, dashboards, and alerting stay with whatever backend receives the data. Changing that backend becomes an endpoint change in configuration rather than a rewrite of application code. ### [The three signals](#the-three-signals)Copy Anchor Link OpenTelemetry organizes telemetry into signals. Three are stable and widely implemented, and each answers a different kind of question. | Signal | Unit of data | What it captures | Example from a build | | ------- | --------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------- | | Traces | Spans in a parent and child tree | One operation, with a start time, a duration, and attributes | A workflow run as a root span, with a child span per job and per step | | Metrics | Measurements aggregated over time | A number sampled or counted repeatedly | CPU utilization on the machine, sampled every 10 seconds | | Logs | Timestamped records | A message plus structured attributes | System log lines written while the job was running | A fourth signal for continuous profiling is newer and moves through the specification process separately, so check the [specification index](https://opentelemetry.io/docs/specs/otel/) before depending on it. ### [The pieces you actually install](#the-pieces-you-actually-install)Copy Anchor Link The word OpenTelemetry covers several components that are easy to confuse, and knowing which one you are configuring saves a lot of guessing. | Piece | Role | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | API | The interfaces application code calls to start a span or record a measurement | | SDK | The implementation behind the API: sampling, batching, and export | | Instrumentation libraries | Prebuilt hooks for HTTP clients, databases, and language runtimes | | OTLP | The wire protocol that carries the data to a receiver | | Collector | A standalone process that receives, processes, and forwards telemetry | | Semantic conventions | Agreed attribute names so two systems describe the same fact identically | | Context propagation | The [W3C Trace Context](https://www.w3.org/TR/trace-context/) headers that stitch spans together across process boundaries | The [OpenTelemetry Protocol](https://opentelemetry.io/docs/specs/otlp/) is the piece most configuration touches. It runs over gRPC on port 4317 by default, or over HTTP on port 4318, where each signal has its own path: `/v1/traces`, `/v1/metrics`, and `/v1/logs`. The [Collector](https://opentelemetry.io/docs/collector/) is worth calling out because it removes work from the application. It receives data, batches it, drops or renames attributes, and forwards the result to one or more destinations. A fleet of machines can therefore point at one endpoint and have routing decided centrally. ### [Configuration is environment variables](#configuration-is-environment-variables)Copy Anchor Link SDKs and collectors read a common set of environment variables, which is why the same instrumentation moves between a laptop, a container, and a build machine untouched. The names are fixed by the [SDK environment variable specification](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/), checked on 2026-08-13. | Variable | Purpose | Typical value | | ------------------------------ | -------------------------------------------------------------- | ---------------------------------- | | OTEL\_SERVICE\_NAME | Names the service the data belongs to | github-actions-runner | | OTEL\_EXPORTER\_OTLP\_ENDPOINT | Base URL of the collector or backend | https://otlp.example.internal:4318 | | OTEL\_EXPORTER\_OTLP\_PROTOCOL | Wire format for export | http/protobuf | | OTEL\_EXPORTER\_OTLP\_HEADERS | Headers added to every export request, usually auth | authorization=Bearer ... | | OTEL\_RESOURCE\_ATTRIBUTES | Key and value pairs attached to every record from this process | cicd.pipeline.run.id=1234 | | OTEL\_METRIC\_EXPORT\_INTERVAL | Milliseconds between metric exports | 10000 | Resource attributes are what make telemetry from different sources join up. Two records that carry the same run id can be shown side by side even though one came from a metrics scraper and the other from a log shipper. The attribute names for build pipelines live in the `cicd` group of the [semantic conventions](https://opentelemetry.io/docs/specs/semconv/), one of the newer additions, so read the registry page before standardizing on names across repositories. ## [Example](#example)Copy Anchor Link A GitHub Actions job is an ordinary process tree on an ordinary machine, so a collector can run alongside it. The workflow below starts a collector on the runner, builds the project, and stops the collector at the end. The collector scrapes host metrics and exports them over OTLP with attributes naming the repository, workflow, run, and job. Copy Text ``` name: build on: push: branches: [main] jobs: build: runs-on: ubuntu-latest env: OTEL_EXPORTER_OTLP_ENDPOINT: https://otlp.example.internal:4318 OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf OTEL_SERVICE_NAME: github-actions-runner OTEL_RESOURCE_ATTRIBUTES: >- cicd.pipeline.name=${{ github.workflow }}, cicd.pipeline.run.id=${{ github.run_id }}, cicd.pipeline.task.name=${{ github.job }}, vcs.repository.name=${{ github.repository }} steps: - uses: actions/checkout@v4 - name: Start the metrics agent run: | ./bin/otelcol-contrib --config=.github/otel/collector.yaml & echo $! > /tmp/otelcol.pid - name: Build run: make build - name: Stop the metrics agent if: always() run: | kill "$(cat /tmp/otelcol.pid)" sleep 5 ``` The collector configuration reads the same environment and turns machine counters into OTLP metrics: Copy Text ``` receivers: hostmetrics: collection_interval: 10s scrapers: cpu: memory: disk: filesystem: network: processors: batch: exporters: otlp: endpoint: ${env:OTEL_EXPORTER_OTLP_ENDPOINT} service: pipelines: metrics: receivers: [hostmetrics] processors: [batch] exporters: [otlp] ``` Three details decide whether this produces anything useful. `if: always()` keeps the shutdown step running after a failed build. A build that fails is the one whose CPU and memory curves you want, and a job that exits without stopping the agent loses the last export window. The `sleep 5` gives the collector time to flush its final batch before the machine is torn down. Batching is what keeps export cheap, and the cost of batching is that the last few seconds of data need a moment to leave. The resource attributes are what put the metrics next to the job logs. A backend that indexes `cicd.pipeline.run.id` can answer a single query with the memory series from the collector and the log records shipped from the same job, so a step that failed at minute seven can be lined up against the machine's memory curve at minute seven. A trace pipeline is the same shape with a different receiver. A step that records one span per build phase, exported through the same endpoint, gives a waterfall of the run to sit above those metrics. ## [Related Terms](#related-terms)Copy Anchor Link * [Send OpenTelemetry metrics from GitHub Actions jobs](/guides/opentelemetry-ci-metrics-github-actions): the collector setup, attribute naming, and the queries worth building once the data lands. * [GitHub Actions observability and per-job runner metrics](/solutions/github-actions-observability): CPU, memory, disk, and queue time per job, and how to read them together. * [Runner utilization](/glossary/runner-utilization): the share of a machine's CPU and memory a job actually consumes, and how it is measured. * [WarpBuild observability documentation](/docs/ci/features/observability): the metrics and system logs collected from runners, and how collection is configured. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is OpenTelemetry in one sentence? OpenTelemetry is an open standard, together with the APIs, SDKs, and tools that implement it, for generating and exporting traces, metrics, and logs in a single vendor-neutral format that any compatible backend can read. ### What is the difference between OpenTelemetry and a monitoring backend? OpenTelemetry covers production and transport: how a process records a span or a measurement, what the attributes are called, and how the data reaches a receiver over OTLP. Storage, indexing, querying, dashboards, and alerting belong to whichever backend accepts the data, and swapping that backend is an endpoint change rather than a reinstrumentation project. ### Can GitHub Actions jobs emit OpenTelemetry data? Yes. A job is a normal process tree on a machine, so anything that can run on the runner can export OpenTelemetry data: a collector scraping host metrics, an SDK inside a test harness emitting spans, or a step that ships the run structure after the job finishes. The usual pattern is to attach the repository, workflow, run id, and job name as resource attributes so every record can be traced back to one run. --- # Personal Access Token | WarpBuild URL: https://www.warpbuild.com/glossary/personal-access-token Description: A personal access token is a credential tied to one user account that authenticates API and Git requests as that person, under that person's permissions. # Personal Access Token A personal access token is a credential tied to one user account that authenticates API and Git requests as that person, under that person's permissions. A personal access token is a credential tied to one user account that authenticates API and Git requests as that person. It is presented in place of a password, and every request it makes runs under that account's permissions, narrowed by the scopes or the permission set chosen when the token was created. The consequences follow from that single sentence. The token's reach is a property of a human account rather than of the project it automates, so the questions worth answering are how wide that reach is, how long the credential lives, and what happens to the automation when the person behind it changes roles. ## [Definition](#definition)Copy Anchor Link Three properties describe the credential. * **Bound to a person.** The token is created under one user account and acts as that account. Audit entries, commits, comments, and API writes are attributed to that person. * **Presented as a bearer credential.** It travels in an `Authorization: Bearer` header for REST and GraphQL calls, and as the password in a Git operation over HTTPS. * **Bounded at creation.** The creator picks the scopes or permissions, an optional or required expiration date, and, for the fine-grained type, the repositories the token may reach. ### [The two token types](#the-two-token-types)Copy Anchor Link GitHub issues two kinds of personal access token. They differ in how reach is expressed and in how much control an organization has over them ([GitHub documentation on managing personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) and the [organization token policy documentation](https://docs.github.com/en/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization), both checked on 2026-08-13). | Property | Classic token | Fine-grained token | | -------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | Prefix | ghp\_ | github\_pat\_ | | Repository reach | every repository the owner can access, present and future | one resource owner, then all or a named list of its repositories | | Permission model | coarse scopes such as repo, workflow, admin:org, write:packages | per permission read or write, such as Contents, Issues, Pull requests, Actions | | Metadata permission | implied by the scopes | repository Metadata read is mandatory | | Expiration | set at creation, including a no expiration option | set at creation, with a custom value of up to 366 days | | Organization control | an owner can block classic tokens from reaching organization resources | an owner can require approval before the token reaches organization resources | | SAML single sign-on | authorization per organization required | authorization per organization required | Expiration is also governed from above. An organization or enterprise owner can set a maximum token lifetime policy, and a token created against that owner's resources gets the shorter of the requested value and the policy value. The `repo` scope on a classic token is the one that surprises people. It grants read and write on every private repository the account can already open, so a token created to update one manifest file can also push to anything else that account touches. The `workflow` scope is a second checkpoint: without it, a push that modifies a file under `.github/workflows` is rejected. ### [Lifetime and revocation](#lifetime-and-revocation)Copy Anchor Link A token stops working in five ways: the expiration date passes, the owner deletes it, an organization owner revokes its access to that organization, the underlying account is suspended or removed, or GitHub revokes it after finding it exposed. GitHub scans public repositories for its own credential formats and acts on matches, and push protection can block a commit that contains one before it lands ([GitHub secret scanning documentation](https://docs.github.com/en/code-security/secret-scanning/introduction/about-secret-scanning), checked on 2026-08-13). The fourth case is the one that breaks scheduled automation. An offboarding removes the account, and the nightly job that used the token fails on a night nobody was expecting a deploy problem. Rate limits are also personal. Requests authenticated with a personal access token count against a budget of 5,000 requests per hour for that user, shared by every script, tool, and workflow using any token that account owns ([GitHub rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api), checked on 2026-08-13). ## [Example](#example)Copy Anchor Link A workflow reaches for a personal access token when the automatic per job credential cannot do the work. `GITHUB_TOKEN` is scoped to the repository that contains the workflow, and events it raises start no new workflow runs, so a job that has to write to a second repository or to trigger a downstream workflow needs a different credential. The token is stored as a repository secret and read in the workflow like any other secret: Copy Text ``` name: sync-config on: schedule: - cron: "0 3 * * *" jobs: propagate: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 with: repository: acme/platform-config token: ${{ secrets.CONFIG_SYNC_PAT }} path: platform-config - run: ./scripts/render-config.sh platform-config - name: Open a pull request env: GH_TOKEN: ${{ secrets.CONFIG_SYNC_PAT }} working-directory: platform-config run: | git switch -c sync/${{ github.run_id }} git commit -am "sync rendered config" git push origin HEAD gh pr create --fill --base main ``` Two details decide whether this workflow is safe. The secret named `CONFIG_SYNC_PAT` is encrypted at rest and injected into the job only when a step references it, and GitHub masks matching strings in the log output. Masking covers accidental echoes rather than deliberate exfiltration, because any step in the job, including a third-party action pinned to a moving tag, runs with the same environment. The [guide to managing secrets in GitHub Actions](/guides/github-actions-secrets-management) covers the storage and review side of that. The second detail is the token's reach. A fine-grained token limited to `acme/platform-config` with Contents write and Pull requests write does exactly the work above. A classic token with the `repo` scope, created by an engineer who can open forty repositories, does the work above plus anything else in those forty. ### [Why an application identity usually fits automation better](#why-an-application-identity-usually-fits-automation-better)Copy Anchor Link The same job written against an application identity changes what the secret store holds. An App ID and a private key are exchanged at the start of the run for an installation token limited to one repository: Copy Text ``` - uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ vars.CONFIG_SYNC_APP_ID }} private-key: ${{ secrets.CONFIG_SYNC_APP_KEY }} owner: acme repositories: platform-config - uses: actions/checkout@v4 with: repository: acme/platform-config token: ${{ steps.app-token.outputs.token }} path: platform-config ``` Four things change with it. The credential in the job lives one hour, rather than for whatever lifetime a stored token was given. The pull request is attributed to the app's bot account, so a reviewer can tell automated changes from human ones at a glance. The permission set was declared by the app and approved by whoever installed it, which makes it reviewable outside the workflow file. And the automation keeps running when the engineer who built it leaves. The [GitHub App page](/glossary/github-app) covers the registration, installation, and token exchange behind that snippet. Personal access tokens still fit the cases an app cannot cover: a script on a laptop, a personal automation, a one-off migration, or a tool with no app registration. For those, prefer the fine-grained type, set the shortest expiration the task tolerates, list one repository where possible, and record a rotation date somewhere a person will read it. ## [Related Terms](#related-terms)Copy Anchor Link * [GitHub Apps and the installation tokens they mint](/glossary/github-app): the registration, installation, and one hour credential chain behind an application identity. * [GITHUB\_TOKEN, the credential GitHub creates for each job](/glossary/github-token): the per job installation token, its `permissions` block, and why events it raises start no new runs. * [Managing secrets in GitHub Actions workflows](/guides/github-actions-secrets-management): where secrets live, which jobs receive them, and how to review what reads them. * [WarpBuild API keys documentation](/docs/ci/api-keys): creating, scoping, and using an API key for the WarpBuild API. * [Runner security documentation](/docs/ci/security): how runner isolation, storage, and secrets handling are documented. * [WarpBuild pricing](/pricing): per minute runner rates. ## FAQ ### What is a personal access token? A personal access token is a credential created by an individual user that authenticates API and Git requests as that user. It is presented as a bearer credential in place of a password, and every request it makes runs under the permissions of the account that created it, narrowed by the scopes or permissions chosen at creation. ### What is the difference between a classic and a fine-grained personal access token? A classic token carries broad scopes such as repo and workflow, and those scopes apply to every repository the owner can reach. A fine-grained token names one resource owner, lists the repositories it may touch, and sets each permission to read or write individually. A fine-grained token also takes an expiration set at creation, with a custom value of up to 366 days, and an organization owner can cap that with a maximum token lifetime policy. ### Should a GitHub Actions workflow use a personal access token? Use one when a job needs to reach a repository outside the one running the workflow, or needs a push to start another workflow run, and no application identity is available. An application identity usually fits better because its credentials are minted per run and expire in an hour, its permissions are approved at install time, and it survives the offboarding of the person who set the automation up. --- # Provenance Attestation | WarpBuild URL: https://www.warpbuild.com/glossary/provenance-attestation Description: A build provenance attestation is a signed statement describing how an artifact was built: the source commit, the builder, and the build parameters. # Provenance Attestation A build provenance attestation is a signed statement describing how an artifact was built: the source commit, the builder, and the build parameters. A build provenance attestation is a signed statement describing how an artifact was built: the source commit it came from, the builder that ran the build, and the build parameters that run was given. The statement names its artifact by cryptographic digest, so anyone holding a container image or a release binary can fetch the matching attestation and check where the bytes came from before trusting them. An image tag, a release filename, and a green check on a pull request all say nothing verifiable about origin. A provenance attestation moves that question from convention to signature, because the record was produced by the build system itself and signed with an identity a verifier can pin. ## [Definition](#definition)Copy Anchor Link A provenance attestation has three parts: a subject, a predicate, and a signature wrapping both. The subject names the artifact by content hash. Naming by digest matters because a tag can be moved onto different content later, while a digest resolves to the exact bytes it was computed from. Bind the statement to the tag and the record drifts away from the artifact; bind it to the digest and the two stay together. The predicate carries the facts. The in-toto attestation framework defines the outer envelope shape, and a `predicateType` URI declares which vocabulary the body uses ([in-toto attestation statement spec](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md), checked on 2026-08-13). The common vocabulary for build provenance is SLSA Provenance, published at `https://slsa.dev/provenance/v1` ([SLSA provenance specification](https://slsa.dev/spec/v1.0/provenance), checked on 2026-08-13). SLSA Provenance splits its body in two, and the split is the useful mental model for the whole term: | Section | Field | What it records | | --------------- | --------------------------------- | ------------------------------------------------------------------------------------------------ | | buildDefinition | buildType | A URI naming the schema of the parameters below, so a verifier knows how to read them | | buildDefinition | externalParameters | The inputs a person controlled: the repository, the ref, the workflow file path, dispatch inputs | | buildDefinition | internalParameters | Values the build platform chose on its own | | buildDefinition | resolvedDependencies | The source commit and other inputs pinned to a hash at run time | | runDetails | builder.id | A URI identifying the system that executed the build | | runDetails | metadata.invocationId | The specific run, usually a URL to the log | | runDetails | metadata.startedOn and finishedOn | Timestamps for the run | The signature is what turns a description into evidence. Attestations are commonly signed with a short-lived certificate issued to the build's workload identity rather than a long-lived key held by a person, and the issuance is written to a public transparency log so the record can be audited after the certificate expires. The workload identity comes from an OIDC token the build platform mints for the running job, which is why the signer's certificate can carry the repository, the workflow, and the ref as identity fields instead of a human name. ### [What verification actually checks](#what-verification-actually-checks)Copy Anchor Link Verification is a sequence of independent checks, and each one catches a different substitution: | Check | Failure it catches | | --------------------------------------------------------------------- | ---------------------------------------------------------------- | | The signature verifies against the certificate in the envelope | The statement was edited after signing | | The certificate identity matches the expected repository and workflow | A valid attestation produced by an unrelated build | | The subject digest equals the digest of the artifact in hand | An attestation copied from a different build of the same project | | The predicateType is the one the policy requires | An SBOM presented where provenance was required | | A transparency log entry exists for the signing time | Signing outside the recorded validity window | Every one of those checks is about the record. None of them inspects the artifact's behavior, so provenance answers the question of origin and leaves the question of quality to tests, scanners, and review. ### [Where the statement lives](#where-the-statement-lives)Copy Anchor Link The signed statement is a separate document from the artifact, referenced by digest, and there are three usual homes for it. A registry can store it as a referrer of the image manifest, which the OCI distribution specification supports through the referrers API ([OCI distribution spec](https://github.com/opencontainers/distribution-spec/blob/main/spec.md), checked on 2026-08-13). A build platform can keep it in its own attestation store and serve it by digest lookup. A release process can publish it as a file beside the binary it describes. Storing it in the registry has one practical advantage: copying the image between registries can carry the attestation along with it, so the evidence survives promotion from a build registry to a production one. ## [Example](#example)Copy Anchor Link This workflow builds a container image, pushes it, and then attaches a signed provenance attestation bound to the digest the registry returned. `actions/attest-build-provenance` generates the SLSA statement, signs it with the job's workload identity, and pushes it to the registry as a referrer of the image manifest ([GitHub artifact attestations documentation](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds), checked on 2026-08-13). Copy Text ``` name: release on: push: tags: ["v*"] jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write id-token: write attestations: write steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: push uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }} - uses: actions/attest-build-provenance@v2 with: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true ``` Two permissions carry the weight here. `id-token: write` lets the job request the OIDC token that proves which repository, workflow, and ref it is, and `attestations: write` lets it publish the resulting statement. Without the first, the signer has no identity to bind the certificate to. The statement that comes out looks like the excerpt below, trimmed to the fields a reader cares about: Copy Text ``` { "_type": "https://in-toto.io/Statement/v1", "subject": [ { "name": "ghcr.io/acme/api", "digest": { "sha256": "9f2c...c41d" } } ], "predicateType": "https://slsa.dev/provenance/v1", "predicate": { "buildDefinition": { "buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", "externalParameters": { "workflow": { "ref": "refs/tags/v1.4.0", "repository": "https://github.com/acme/api", "path": ".github/workflows/release.yml" } }, "resolvedDependencies": [ { "uri": "git+https://github.com/acme/api@refs/tags/v1.4.0", "digest": { "gitCommit": "c0ffee1234567890abcdef1234567890abcdef12" } } ] }, "runDetails": { "builder": { "id": "https://github.com/actions/runner/github-hosted" }, "metadata": { "invocationId": "https://github.com/acme/api/actions/runs/7412/attempts/1" } } } } ``` Read it from the bottom up and it answers the origin question directly: run 7412 of `release.yml`, triggered on tag `v1.4.0`, from commit `c0ffee1`, executed by a hosted runner, producing the manifest with digest `sha256:9f2c...c41d`. A build executed on machines you supply reports a different `builder.id`, so the builder field distinguishes hosted execution from a fleet you registered yourself. A consumer verifies the image before deploying it: Copy Text ``` gh attestation verify oci://ghcr.io/acme/api@sha256:9f2c...c41d --repo acme/api ``` That command resolves the digest, fetches the attestations attached to it, checks the signature and the transparency log entry, and fails unless the signer identity belongs to `acme/api`. Pointing it at an image built from a fork, rebuilt on a laptop, or pushed by a compromised token fails at the identity check rather than at the signature check, which is the distinction worth understanding: a valid signature from the wrong identity is still a rejection. Image builders can also emit provenance without a separate signing step. BuildKit attaches a SLSA provenance attestation to the image index when a build is run with provenance enabled, and `mode=max` records the full set of build arguments and resolved dependencies rather than a minimal summary ([Docker SLSA provenance attestation documentation](https://docs.docker.com/build/metadata/attestations/slsa-provenance/), checked on 2026-08-13): Copy Text ``` - uses: docker/build-push-action@v6 with: context: . push: true provenance: mode=max sbom: true tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }} ``` The two paths answer different questions and are frequently used together. The builder-generated attestation describes the build graph in detail. The signed attestation from the workflow ties that artifact to a repository identity a policy engine can pin at deploy time. ## [Related Terms](#related-terms)Copy Anchor Link * [How an SBOM lists the contents of an artifact](/glossary/sbom): the companion document that records what went into a build rather than how the build ran. * [Attaching build provenance to an image in GitHub Actions](/answers/how-do-i-attach-build-provenance-in-github-actions): the permissions, the action inputs, and the verification command in one worked sequence. * [OIDC token exchange and the workload identity a signer proves](/glossary/oidc-token-exchange): where the short-lived identity in the signing certificate comes from. * [WarpBuild Docker builders documentation](/docs/ci/docker-builders): how a workflow points an image build at a remote builder. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between provenance and an SBOM? Provenance describes how an artifact was produced: the source repository and commit, the builder identity, the workflow file, and the parameters that run was given. An SBOM describes what is inside the artifact: the packages and versions assembled into it. They are separate documents with separate predicate types, and a build can publish both against the same digest. ### Does a provenance attestation prove the build is safe? No. The statement records what the build system observed, and verification answers who signed it and whether the subject digest matches the artifact in hand. Whether the recorded source, builder, and parameters are acceptable is a policy decision the verifier makes after the signature checks out. ### Where is the attestation stored if it is not inside the image? The signed statement is a separate document referenced by the artifact digest. It can be pushed to the same OCI registry as a referrer of the image manifest, kept in the build platform's own attestation store and fetched by digest at verification time, or written next to a released binary as a file. --- # Provisioning Profile | WarpBuild URL: https://www.warpbuild.com/glossary/provisioning-profile Description: A provisioning profile is an Apple signed file that binds an app identifier, the certificates allowed to sign it, and a device list or a distribution method. # Provisioning Profile A provisioning profile is an Apple signed file that binds an app identifier, the certificates allowed to sign it, and a device list or a distribution method. A provisioning profile is a file issued and signed by Apple that binds an application identifier to a set of signing certificates and to either a list of devices or a distribution method. Apple platforms check that binding before they run third party code, so a build that signs cleanly still refuses to install when the profile does not cover the application, the certificate, or the device. In GitHub Actions the profile is one of two secrets a macOS job needs before it can archive an application for upload. The other is the signing certificate, and each half is checked against the other. ## [Definition](#definition)Copy Anchor Link A provisioning profile is a property list wrapped in a Cryptographic Message Syntax signature. The Apple Developer website creates the profile and signs it, and a device checks that signature before it trusts anything inside ([TN3125: Inside Code Signing: Provisioning Profiles](https://developer.apple.com/documentation/technotes/tn3125-inside-code-signing-provisioning-profiles), checked on 2026-08-13). Removing the wrapper with `security cms -D -i profile.mobileprovision` produces the readable XML underneath. Apple describes a profile as the answer to five questions. Each question maps to a property in that plist: | Question | Property | What it holds | | ----------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------- | | Who can sign? | DeveloperCertificates | DER encoded certificates for every identity allowed to sign code covered by the profile | | What can they sign? | Entitlements \> application-identifier | An App ID: a team prefix plus a bundle identifier, which may end in a wildcard | | Where can it run? | ProvisionedDevices or ProvisionsAllDevices | An explicit list of device identifiers, or a flag covering every device | | When can it run? | ExpirationDate | The moment the profile stops being accepted | | How can it be entitled? | Entitlements | An allowlist of entitlements the signed application is permitted to claim | The entitlements property is an allowlist rather than a description of the application. Every entitlement claimed in the code signature has to appear in the profile, and the profile is free to allow entitlements the application never claims. An App ID that ends in a wildcard, such as `TEAMID.com.example.*`, covers any bundle identifier with that prefix, which is why one profile can serve an application and its extensions. ### [The types differ in where and when](#the-types-differ-in-where-and-when)Copy Anchor Link The five properties above are the same in every profile. What separates the types is device coverage and how long the profile stays valid. | Profile type | Device coverage | Typical use | | ---------------------- | ------------------------------------------- | ----------------------------------------------------------- | | Development | ProvisionedDevices lists registered devices | Running a debug build on a test device | | Ad hoc distribution | ProvisionedDevices lists registered devices | Handing a build to testers outside the App Store | | App Store distribution | No ProvisionedDevices property | Uploading a build for review and release | | In-house (Enterprise) | ProvisionsAllDevices | Internal distribution inside an organization | | Developer ID (macOS) | ProvisionsAllDevices | Signing a Mac application distributed outside the App Store | Validity varies by type and is typically not more than a year, with Developer ID profiles the exception because their expiration date sits far in the future (TN3125, checked on 2026-08-13). A pipeline that signed correctly for months therefore starts failing on an ordinary morning with nothing in the diff to explain it. ### [Where the file ends up](#where-the-file-ends-up)Copy Anchor Link A profile lives in two places, and the second one is the reason the first one matters. On the build machine it sits in the profile directory of the Xcode being used. Xcode 16 and later read `~/Library/Developer/Xcode/UserData/Provisioning Profiles`, and earlier toolchains read `~/Library/MobileDevice/Provisioning Profiles`, which is the directory GitHub's own signing example copies into ([Installing an Apple certificate on macOS runners](https://docs.github.com/en/actions/how-tos/deploy/deploy-to-third-party-platforms/sign-xcode-applications), checked on 2026-08-13). Xcode then embeds the profile in the product it builds. Apple platforms other than macOS expect it at `MyApp.app/embedded.mobileprovision`, and macOS expects it at `MyApp.app/Contents/embedded.provisionprofile`. Applications downloaded from the App Store carry no embedded profile, because the store re-signs the application and performs the check itself during distribution. A certificate and a profile answer different questions. The certificate holds the private key that produces the signature, which is the subject of [code signing](/glossary/code-signing). The profile is the authorization that says which identities may sign which application, on which devices, and until when. ## [Example](#example)Copy Anchor Link A distribution build installs the profile before `xcodebuild archive` runs, because Xcode resolves `PROVISIONING_PROFILE_SPECIFIER` against the profiles already on disk. This job decodes the profile from a secret, records what it contains, installs it under its UUID, and archives the application for export. Copy Text ``` name: release on: workflow_dispatch: jobs: archive: runs-on: macos-latest steps: - uses: actions/checkout@v4 - name: Install the distribution provisioning profile env: DISTRIBUTION_PROFILE: ${{ secrets.DISTRIBUTION_PROFILE }} run: | PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" mkdir -p "$PROFILES" echo -n "$DISTRIBUTION_PROFILE" | base64 --decode \ > "$RUNNER_TEMP/profile.mobileprovision" security cms -D \ -i "$RUNNER_TEMP/profile.mobileprovision" \ -o "$RUNNER_TEMP/profile.plist" UUID="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$RUNNER_TEMP/profile.plist")" NAME="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$RUNNER_TEMP/profile.plist")" EXPIRY="$(/usr/libexec/PlistBuddy -c 'Print :ExpirationDate' "$RUNNER_TEMP/profile.plist")" APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' \ "$RUNNER_TEMP/profile.plist")" echo "profile $NAME covers $APP_ID and expires $EXPIRY" cp "$RUNNER_TEMP/profile.mobileprovision" "$PROFILES/$UUID.mobileprovision" echo "PROFILE_NAME=$NAME" >> "$GITHUB_ENV" - name: Archive run: | xcodebuild archive \ -scheme App \ -configuration Release \ -destination "generic/platform=iOS" \ -archivePath "$RUNNER_TEMP/App.xcarchive" \ CODE_SIGN_STYLE=Manual \ DEVELOPMENT_TEAM=${{ vars.DEVELOPMENT_TEAM }} \ PROVISIONING_PROFILE_SPECIFIER="$PROFILE_NAME" - name: Export for upload run: | xcodebuild -exportArchive \ -archivePath "$RUNNER_TEMP/App.xcarchive" \ -exportOptionsPlist ExportOptions.plist \ -exportPath "$RUNNER_TEMP/export" ``` The job above installs the profile only. Importing the certificate into a temporary keychain is a separate step, and [code signing iOS builds on GitHub Actions](/guides/ios-code-signing-github-actions) covers that half along with the keychain commands that keep `codesign` from asking for an interactive authorization. Three details in the install step earn their lines. Naming the installed file after the profile's UUID is the usual convention and stops two profiles for the same application from overwriting each other. Printing the name, the App ID, and the expiration puts the profile's identity in the run log, so an expiry failure a year later is one log line away from an explanation instead of a bisect. Reading the profile through `security cms -D` and `PlistBuddy` means the job inspects the file it is about to install rather than the file someone believes is in the secret. `ExportOptions.plist` repeats part of the same information. Its `provisioningProfiles` dictionary maps each bundle identifier in the product to a profile name or UUID, and its `method` key states the distribution method. A mismatch between the profile installed above and the entry in that dictionary surfaces at export rather than at archive, which is why the export step is worth reading closely when the archive succeeded. Two failures cover most of the profile related output. `No profiles for 'com.example.App' were found` means no installed profile carries a matching App ID, usually because the secret was never decoded into the directory the selected Xcode reads. `Provisioning profile "X" doesn't include signing certificate "Y"` means the profile and the certificate came from different rotations, so the profile's `DeveloperCertificates` array and the identity in the keychain no longer agree. ## [Related Terms](#related-terms)Copy Anchor Link * [Code signing iOS builds on GitHub Actions](/guides/ios-code-signing-github-actions): the keychain, certificate, and profile steps of a signing job in one workflow file. * [Code signing](/glossary/code-signing): the signature a profile authorizes, and how the operating system checks it. * [Uploading a signed build to TestFlight from GitHub Actions](/guides/testflight-uploads-github-actions): what happens to the exported archive after the profile has done its work. * [Cloud runners documentation](/docs/ci/cloud-runners): the runner labels available for macOS jobs, with sizes and images. * [Preinstalled software documentation](/docs/ci/preinstalled-software): the tooling each macOS runner image carries, including the Xcode versions on disk. * [Runner pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a provisioning profile? A provisioning profile is a property list wrapped in a Cryptographic Message Syntax signature and signed by Apple. It binds an application identifier to the certificates allowed to sign that application, to a device list or a distribution method, and to an expiration date, and it carries the allowlist of entitlements the signed code may claim. ### What is the difference between a signing certificate and a provisioning profile? A certificate holds the private key that produces a signature and establishes who signed the code. A profile is the authorization that says which identities may sign which application, on which devices, and until when. A build can fail on either half on its own, and the error message usually names only one of them. ### Where does a GitHub Actions job install a provisioning profile? Into the profile directory of the Xcode the job selects. Xcode 16 and later read \~/Library/Developer/Xcode/UserData/Provisioning Profiles, and earlier toolchains read \~/Library/MobileDevice/Provisioning Profiles. The profile is stored base64 encoded in a repository or environment secret and decoded into that directory before xcodebuild archive runs. --- # QEMU Emulation | WarpBuild URL: https://www.warpbuild.com/glossary/qemu-emulation Description: QEMU emulation runs binaries built for another processor architecture by translating their instructions, which is how an x64 machine builds an ARM64 image. # QEMU Emulation QEMU emulation runs binaries built for another processor architecture by translating their instructions, which is how an x64 machine builds an ARM64 image. QEMU emulation runs a program compiled for one processor architecture on a machine with a different one, by translating the program's instructions into instructions the host CPU can execute. In a Docker build it is the mechanism that lets an x64 machine run the aarch64 binaries inside an arm64 base image, and the translated code runs far slower than the same binaries would on an ARM64 machine. Two properties of that sentence explain most confusing build logs. The translation applies only to processes executing guest binaries, so parts of the same build stay at full speed. And nothing in the workflow file names the interpreter, so the only visible trace is a setup step near the top of the job. ## [Definition](#definition)Copy Anchor Link QEMU is an open source machine emulator and virtualizer. It has two modes, and the difference decides what a Docker build is paying for. **User mode emulation** runs a single foreign binary as an ordinary process on the host. QEMU translates that process's instructions and passes its system calls through to the host kernel. There is no guest kernel and no virtual hardware. **System mode emulation** emulates a whole machine: a CPU, memory, devices, and a guest kernel booted on top of them. It is what you use to boot an operating system for another architecture. Container image builds use user mode. A container shares the host kernel already, so the only thing missing on an x64 machine is a way to execute aarch64 user space binaries. ### [How the kernel routes a foreign binary](#how-the-kernel-routes-a-foreign-binary)Copy Anchor Link The connection between the two is `binfmt_misc`, a Linux kernel feature that maps the magic bytes at the start of a file to an interpreter that should run it. Registration writes one file per architecture under `/proc/sys/fs/binfmt_misc`. Each file names the ELF magic for that architecture, a mask, and the path to the QEMU binary that handles it. Docker's multi-platform build documentation describes installation as registering the emulators with `binfmt_misc`, and it documents verifying the result by checking that `F` appears among the flags in `/proc/sys/fs/binfmt_misc/qemu-*` (Docker multi-platform builds, checked on 2026-08-13). That `F` flag makes the kernel open the interpreter at registration time and hold the reference, which is why the handler still works inside a container whose filesystem contains no copy of QEMU. Once an entry exists, running an aarch64 binary on an x64 machine starts `qemu-aarch64` with that binary as its argument. Without the entry the same command returns `exec format error`. ### [What the interpreter actually does](#what-the-interpreter-actually-does)Copy Anchor Link Translation happens a basic block at a time. QEMU reads a run of guest instructions up to the next branch, compiles it into host instructions, caches the result, and jumps into the cached form. A hot loop pays the translation cost once and then reuses it, so long compiles behave better than a per instruction model would predict, and still land well short of native speed. System calls take a separate path. The interpreter catches each one, converts the arguments from the guest ABI to the host ABI, and forwards it to the host kernel. Work that makes many small calls, such as a linker walking thousands of object files, pays that conversion on every call. Only part of a build passes through the interpreter: | Part of an emulated image build | What executes it | Translated | | -------------------------------------------------------------------------------- | -------------------------------------- | ---------- | | Guest binaries in the target rootfs: compilers, linkers, package managers, tests | QEMU on the host CPU | Yes | | System calls made by those binaries | Host kernel, arguments converted first | Partly | | The build engine, base image pulls, COPY and ADD | Host CPU, natively | No | | Kernel interfaces such as /proc/cpuinfo and nproc | Host kernel, reporting host values | No | The last row is a frequent source of surprise. A step that sizes its parallelism from the detected core count reads host x64 information while running guest code, and a library that probes for CPU features can receive an answer the interpreter does not implement, which surfaces as `Illegal instruction` rather than as a clear emulation error. Docker documents three strategies for producing multi-platform images: emulation with QEMU, multiple native build nodes, and cross compilation in a multi-stage build. It states that emulation with QEMU can be much slower than native builds, especially for compute heavy tasks like compilation and compression or decompression, and recommends the other two strategies where they apply. ## [Example](#example)Copy Anchor Link This workflow builds an arm64 image on an x64 machine. `ubuntu-latest` is GitHub's default hosted Linux label and is an x64 machine, so the `docker/setup-qemu-action` step is what makes the build possible at all. Copy Text ``` name: image on: push: branches: [main] jobs: build-arm64: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-qemu-action@v3 with: platforms: arm64 - uses: docker/setup-buildx-action@v3 - name: Build and push the arm64 image run: | docker buildx build \ --platform linux/arm64 \ --tag ghcr.io/${{ github.repository }}:${{ github.sha }}-arm64 \ --push \ . ``` After the setup step, the registration is visible in the job: Copy Text ``` $ cat /proc/sys/fs/binfmt_misc/qemu-aarch64 enabled interpreter /usr/bin/qemu-aarch64 flags: OCF ``` Every `RUN` step in the Dockerfile now executes inside a rootfs of aarch64 binaries, and each one reaches the CPU as translated code. The same build on an ARM64 machine drops the setup step entirely. GitHub publishes `ubuntu-24.04-arm` as a hosted ARM64 label in its [hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13: Copy Text ``` jobs: build-arm64: runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - name: Build and push the arm64 image run: | docker buildx build \ --platform linux/arm64 \ --tag ghcr.io/${{ github.repository }}:${{ github.sha }}-arm64 \ --push \ . ``` The Dockerfile, the tag, and the buildx command are identical. What changed is the architecture of the machine underneath, so `--platform linux/arm64` now names the host's own architecture and the binaries execute directly. | Behavior | Emulated build on an x64 machine | Native build on an ARM64 machine | | ------------------------- | ---------------------------------------------------------------- | -------------------------------- | | binfmt\_misc handler | Required before the build | Unused | | Guest instructions | Translated by QEMU, then executed | Executed by the CPU | | System calls | Intercepted and converted | Direct | | nproc and /proc/cpuinfo | Host x64 values | ARM64 values | | Common failure signatures | exec format error, Illegal instruction, hangs in JIT heavy steps | Ordinary build errors | A workflow that has to publish both architectures under one tag runs one job per architecture and joins the two results with a manifest list, which keeps the interpreter out of both jobs. ## [Related Terms](#related-terms)Copy Anchor Link * [Moving ARM64 Docker builds off QEMU emulation](/guides/qemu-vs-native-arm64-builds): which Dockerfile steps absorb the translation cost, the workflow diff for each route off emulation, and how to read the resulting build times. * [Cross compilation, the other way to target a foreign architecture](/glossary/cross-compilation): producing aarch64 binaries from a compiler running natively on x64, and the build shapes where that works. * [Linux ARM64 runners and the labels they register with](/runners/linux-arm64): ARM64 label shapes, image differences, and the work directory change to watch for. * [Docker multi-platform build documentation](https://docs.docker.com/build/building/multi-platform/): the upstream reference for emulation, native nodes, and cross compilation. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner catalog with sizes, images, and architectures. * [WarpBuild Docker builder documentation](/docs/ci/docker-builders): builder profiles and the actions that point a build at a builder outside the job. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is QEMU emulation in a Docker build? It is user mode emulation of a foreign processor architecture. QEMU translates the guest instructions of every binary inside the target rootfs into host instructions and forwards the guest syscalls to the host kernel, so an x64 machine can execute the aarch64 binaries in an arm64 base image. ### How does the kernel know to hand a binary to QEMU? Through binfmt\_misc, a Linux kernel feature that maps the magic bytes at the start of a file to an interpreter. Registering an aarch64 entry writes a file under /proc/sys/fs/binfmt\_misc naming the aarch64 ELF magic and the qemu-aarch64 binary, and the kernel routes matching binaries there instead of failing with exec format error. ### Which build steps get slower under QEMU emulation? The steps that execute guest binaries and burn CPU: compilers, linkers, minifiers, package managers that build from source, and tests run during the image build. Docker's multi-platform documentation states that emulation with QEMU can be much slower than native builds, especially for compute heavy tasks like compilation and compression or decompression. --- # Queue Time | WarpBuild URL: https://www.warpbuild.com/glossary/queue-time Description: Queue time is the wait between a GitHub Actions job entering the queue and a runner starting it, measured apart from how long the job then takes to run. # Queue Time Queue time is the wait between a GitHub Actions job entering the queue and a runner starting it, measured apart from how long the job then takes to run. Queue time is the interval between the moment a GitHub Actions job enters the queue and the moment a runner claims it and starts the first step. It measures the wait for a machine, and it is a separate interval from run time, which covers how long the steps take once a machine is working on them. Both intervals land on the same pull request check as wall clock, which is why they get blamed for each other. A team that shortens its test suite and sees no change in the time to a green check was usually waiting for a machine rather than running slow steps. ## [Definition](#definition)Copy Anchor Link A GitHub Actions job carries three timestamps, and the two intervals between them are the whole subject. Each job object returned by the REST API has `created_at`, `started_at` and `completed_at` ([REST reference for workflow jobs](https://docs.github.com/en/rest/actions/workflow-jobs), checked on 2026-08-13). | Interval | Arithmetic | What it measures | | ---------- | ------------------------------- | ------------------------------------------------ | | Queue time | started\_at minus created\_at | the wait for an eligible runner to claim the job | | Run time | completed\_at minus started\_at | the steps, from checkout to conclusion | | Wall clock | completed\_at minus created\_at | what a developer watching the check experiences | Queue time is a property of one job rather than one run. A run made of twelve matrix legs has twelve queue times, and the run finishes when the slowest chain of queue plus run finishes. Reporting a single number per run therefore averages away the leg that waited. Four things live inside a queue time, and they have different fixes: 1. **Every eligible machine is busy.** The job waits for one to free up. This is a depth problem in the set of runners that carry the label. 2. **No machine exists yet.** An autoscaled fleet creates one on demand, so the wait includes boot, agent registration, and image pull before the first step runs. 3. **A ceiling is holding the job back.** Account concurrency limits, a `concurrency` group in the workflow, or an unfinished `needs` dependency all keep a job out of a runner while nothing is wrong with capacity. 4. **No machine will ever match.** A misspelled label, a repository outside a runner group, or a workflow restriction leaves the job queued with an empty log. GitHub cancels a job that has waited 24 hours for a self-hosted runner ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), so this failure mode surfaces a day after the push. Cause three is worth separating in any measurement. A job blocked by `needs` or by a concurrency group is reported as waiting, so subtracting timestamps across every job in a run mixes dependency waiting into machine waiting. The `workflow_job` webhook avoids the ambiguity: it delivers `queued` when the job enters the queue, `in_progress` when a runner starts it, and `completed` when it ends, and each payload carries the job's `labels` array and `runner_name` ([workflow\_job webhook payload](https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow%5Fjob), checked on 2026-08-13). Recording those three deliveries gives both intervals with the routing key attached. Read the result as percentiles. Queue time has a long tail by nature, since most jobs on a quiet afternoon start immediately and the jobs that queue arrive together during a busy hour. A mean across a week reports a healthy number while the P90 sits in minutes. ## [Example](#example)Copy Anchor Link This scheduled workflow reads finished runs from the REST API and prints wait time and run time per runner label, so a growing wait can be told apart from a slow build. Copy Text ``` name: queue-time-report on: schedule: - cron: "0 7 * * 1" workflow_dispatch: permissions: actions: read contents: read jobs: report: runs-on: ubuntu-latest steps: - name: Split wait time from run time per runner label env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh api "repos/${{ github.repository }}/actions/runs" \ -f status=completed -F per_page=100 --jq '.workflow_runs[].id' \ | while read -r run_id; do gh api "repos/${{ github.repository }}/actions/runs/$run_id/jobs" \ --jq '.jobs[] | select(.started_at != null and .completed_at != null) | {label: (.labels | join(",")), wait: ((.started_at | fromdateiso8601) - (.created_at | fromdateiso8601)), run: ((.completed_at | fromdateiso8601) - (.started_at | fromdateiso8601))}' done \ | jq -s 'group_by(.label)[] | {label: .[0].label, jobs: length, wait_p90: (map(.wait) | sort | .[(length * 0.9 | floor)]), run_p90: (map(.run) | sort | .[(length * 0.9 | floor)])}' ``` The output has one row per label. The numbers below illustrate the shape of a week from one repository rather than a measurement: | Runner label | Jobs | Wait P90 | Run P90 | | ----------------------- | ---- | -------- | ------- | | ubuntu-latest | 355 | 8s | 5m 02s | | self-hosted,linux,x64 | 412 | 6m 40s | 4m 10s | | self-hosted,linux,arm64 | 96 | 1m 12s | 9m 30s | Three labels, three different problems. The hosted label starts almost immediately and spends its time in the steps. The x64 self-hosted label waits longer than it runs, which points at how many machines carry that label and how quickly new ones appear. The ARM64 label starts quickly and runs for nine minutes, so adding machines there changes nothing and the steps are the place to look. A single average across all three labels would report roughly four minutes of wait and hide the row that matters. Grouping by label keeps the routing key attached to the number, which is also the key a fix is applied to, since capacity, scope, and machine size are all configured per label. ## [Related Terms](#related-terms)Copy Anchor Link * [GitHub Actions queue times and how to fix them](/guides/github-actions-queue-times): the five causes behind a long wait and the fix that matches each one. * [Runner pool, the set of machines behind a label](/glossary/runner-pool): how depth, warm floor, and idle timeout decide whether a job waits. * [Cold start, the machine creation time inside a wait](/glossary/cold-start): what a job pays for when no machine exists yet. * [Queue timings and job reports](/docs/ci/features/reports): P75 and P90 wait per runner label and stack, with CSV export. * [Runners not picking up jobs](/docs/ci/common-issues): the bot access, runner group, and workflow restriction checks for a job that never starts. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is queue time in GitHub Actions? Queue time is the interval between a job entering the queue and a runner claiming it and starting the first step. It measures the wait for a machine. Run time, the interval from the first step to the job conclusion, measures the work itself. A job that shows Waiting for a runner to pick up this job is accumulating queue time. ### How do I measure queue time for a job? Each job object in the GitHub REST API carries created\_at, started\_at and completed\_at. Queue time is started\_at minus created\_at and run time is completed\_at minus started\_at. The workflow\_job webhook gives the same two intervals from its queued, in\_progress and completed deliveries without polling. ### Why report queue time per runner label instead of one average? The two intervals have different fixes and one average hides which one is growing. A label whose wait exceeds its run time points at capacity or routing. A label whose run time dominates points at the steps. Reporting P75 and P90 per label keeps the two apart, because a long tail on a small share of jobs disappears in a mean. --- # Registry Cache | WarpBuild URL: https://www.warpbuild.com/glossary/registry-cache Description: A registry cache stores Docker build layers as a separate image in a container registry, so any builder able to pull from that registry reuses them. # Registry Cache A registry cache stores Docker build layers as a separate image in a container registry, so any builder able to pull from that registry reuses them. A registry cache is a Docker layer cache stored as an image in a container registry, kept under its own tag and separate from the application image the build produces. Any builder that can pull from that registry can read it, so a build running on a machine that has never seen the project reuses layers produced by an earlier run on a different machine. That property is what makes the registry backend common in GitHub Actions. A job usually starts on a machine with an empty local build cache, so the cache has to live somewhere the next machine can reach. ## [Definition](#definition)Copy Anchor Link BuildKit, the engine behind current Docker builds, can export its layer cache to an external backend instead of leaving it on local disk. `type=registry` is one of those backends, documented in the [registry cache backend reference](https://docs.docker.com/build/cache/backends/registry/), checked on 2026-08-13\. The backend writes the cache as a normal registry artifact, so registry authentication, storage quotas, replication, and retention rules apply to it exactly as they apply to an application image. The exported artifact carries two kinds of content. The first is the layer blobs themselves. The second is a cache config blob, media type `application/vnd.buildkit.cacheconfig.v0`, which records the dependency graph of the build: which step produced which layer, and the digests of the inputs that step consumed. The config blob is what turns a pile of blobs into a usable cache, because it lets the next build decide whether a step it is about to run matches a step already recorded. Import is lazy by design. `--cache-from type=registry,ref=...` fetches the manifest and the config blob first, compares step digests against the graph it is building, and downloads a layer blob only for the steps that match. A build with no matching steps pays for two small metadata requests rather than a full cache download. One prerequisite trips people up. Docker documents the registry backend as unsupported by the default `docker` driver, so the build has to run on a builder created with the `docker-container`, `kubernetes`, or `remote` driver, per the [builder drivers reference](https://docs.docker.com/build/builders/drivers/), checked on 2026-08-13. The backend accepts these parameters, from the same registry backend reference: | Parameter | Values | Default | What it controls | | ----------------- | ------------------- | ---------------- | ---------------------------------------------------------------------- | | ref | image reference | required | The repository and tag the cache is written to and read from | | mode | min, max | min | Whether every stage is exported or only the final one | | oci-mediatypes | true, false | true | Whether the cache manifest uses OCI media types | | image-manifest | true, false | false | Exports the cache as an OCI image manifest rather than a manifest list | | compression | gzip, estargz, zstd | gzip | Blob compression applied to exported layers | | compression-level | integer | backend specific | Compression effort for the chosen algorithm | | force-compression | true, false | false | Recompresses layers that already exist in another format | | ignore-error | true, false | false | Whether a failed cache export also fails the build | `image-manifest=true` matters for registries that reject a manifest list under a tag. It has to be paired with `oci-mediatypes=true`, and it is the usual fix when an export succeeds against one registry and fails against another with the same workflow file. A registry cache is distinct from the metadata carried inside a pushed image, which the [inline cache](/glossary/inline-cache) term covers. The registry backend writes a dedicated artifact and can therefore record intermediate stages; inline metadata rides along with the image and is limited to what that image already contains. ## [Example](#example)Copy Anchor Link This workflow builds an image, pushes it to the GitHub Container Registry, and keeps its layer cache under a second tag in the same repository. `docker/setup-buildx-action` creates a `docker-container` builder, which satisfies the driver requirement above. Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max ``` The first run finds nothing at the `:buildcache` reference, executes every step, then pushes two artifacts. The second run lands on a fresh machine with an empty local cache, resolves the same reference, and imports the layers whose step digests still match. Those steps report `CACHED` in the build log: Copy Text ``` => CACHED [build 3/5] RUN go mod download => CACHED [build 4/5] COPY . . => [build 5/5] RUN go build -o /out/app ./cmd/app ``` After a successful run the registry repository holds two tags with different jobs: | Tag | Contents | Top artifact | Pulled by | | ----------- | ------------------------------------------------------- | ------------------------------------------------------------------ | ----------------------------------- | | : | Application image layers and image config | Image manifest or index | A container runtime, at deploy time | | :buildcache | Exported layer blobs and the BuildKit cache config blob | Manifest list by default, image manifest under image-manifest=true | The next build, through cache-from | The `:buildcache` tag is a build input rather than a runnable image, so `docker run` against it fails. Treat it as a storage line item: `mode=max` on a multi-stage Dockerfile writes every stage, which is why teams often set a registry lifecycle rule on the cache tag. Branch strategy is the other decision the reference encodes. A single shared tag means concurrent branches overwrite each other's cache. Giving each branch its own tag and reading from both the branch tag and the default branch keeps the two apart, since `cache-from` may be given more than once: Copy Text ``` cache-from: | type=registry,ref=ghcr.io/${{ github.repository }}:cache-${{ github.ref_name }} type=registry,ref=ghcr.io/${{ github.repository }}:cache-main cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache-${{ github.ref_name }},mode=max ``` Access control follows registry permissions. Writing the cache needs push rights on the repository, reading it needs pull rights, and a workflow triggered by a fork pull request usually has read-only credentials. Passing `ignore-error=true` on `cache-to` lets those runs read the cache and skip the export instead of failing the build. ## [Related Terms](#related-terms)Copy Anchor Link * [Inline cache, the same metadata embedded in the pushed image](/glossary/inline-cache): what `type=inline` records, and why it cannot describe intermediate stages. * [Buildx cache backends in GitHub Actions](/guides/buildx-cache-backends-github-actions): the registry, GitHub Actions, local, and inline backends side by side, with the tradeoffs for each. * [The difference between cache-from and cache-to](/answers/what-is-the-difference-between-cache-from-and-cache-to): why a build that sets one flag looks cached while never populating the cache. * [Docker registry cache backend reference](https://docs.docker.com/build/cache/backends/registry/): the upstream parameter list and media type details. * [WarpBuild Docker builder documentation](/docs/ci/docker-builders): builder profiles and the actions that point a workflow at a builder outside the job. * [WarpBuild caching documentation](/docs/ci/features/caching): cache action inputs, key matching, and entry expiry. * [WarpBuild pricing](/pricing): per minute rates by runner type and by builder size. ## FAQ ### Where does a registry cache actually live? In a container registry, as its own tag next to the application image. The cache tag holds the exported layer blobs plus a BuildKit cache config blob that records which build step produced which layer, so it is pulled like any other artifact and never runs as a container. ### Why does my registry cache export fail with the default builder? Docker documents the registry backend as unsupported on the default docker driver. Create a builder on the docker-container, kubernetes, or remote driver first, which is what docker/setup-buildx-action does when it creates a docker-container builder for the job. ### What does mode=max change? The default mode=min exports only the layers of the final stage of the resulting image. mode=max exports the layers of every stage, including build stages whose output is copied forward, so a multi-stage Dockerfile can reuse its compile stage on the next run. --- # Remote Build Cache | WarpBuild URL: https://www.warpbuild.com/glossary/remote-build-cache Description: A remote build cache stores build outputs on a shared store, keyed by the inputs that produced them, so a target built on one machine is reused elsewhere. # Remote Build Cache A remote build cache stores build outputs on a shared store, keyed by the inputs that produced them, so a target built on one machine is reused elsewhere. A remote build cache is a shared store of build outputs, indexed by a key derived from the inputs that produced each output, which several machines read from and write to over the network. When a build computes a key that already exists in the store, it downloads the stored output instead of running that unit of work again. The term belongs to build tools rather than to any one platform. Bazel, Gradle, Turborepo, Nx, and other tools implement the same idea with their own key derivation and their own transport, and the store behind them can be an object bucket, an HTTP endpoint, or a gRPC service. ## [Definition](#definition)Copy Anchor Link A remote build cache has two halves, and both are needed before the word applies. The first half is a key. The build tool splits the work into units, one compilation, one code generation step, one test task, then hashes everything that unit consumes into a single identifier. The second half is the entry that identifier points at: the files the unit produced, plus enough metadata to place them back on disk in the right locations. A cache lookup therefore answers one question. Given these exact inputs, has any machine already produced the outputs? A hit means the unit does not run at all. Its outputs arrive from the network and the build carries on as if the work had happened locally. The word remote separates this from a purely local cache. Both forms store the same thing, and most tools support both at once with the local disk sitting as a first tier in front of the network. The difference is who can read an entry. A local cache only helps a machine that has already done the work. A remote cache helps every machine that shares the endpoint, including a build server, a colleague's laptop, and a runner that was created a minute ago and has never seen the repository. ### [What goes into the key](#what-goes-into-the-key)Copy Anchor Link Key derivation is where cache hit rates are won and lost, and each tool documents its own recipe. Gradle computes a build cache key for a cacheable task from the task implementation class and its classpath, the values of the task's input properties, the contents of its input files, and the names of its output properties ([Gradle build cache documentation](https://docs.gradle.org/current/userguide/build%5Fcache.html)). Bazel computes an action key from the command line of the action, the digests of its input files, the environment variables the action is allowed to see, and the execution platform ([Bazel remote caching documentation](https://bazel.build/remote/caching)). The two recipes differ in detail and agree on the principle. Everything that can change the output has to be in the key, and nothing that cannot change the output should be. That second rule is the one teams break. Four inputs commonly leak into a key and turn every build into a miss: 1. Absolute paths. A workspace checked out at one path on a laptop and another path on a runner produces different keys for identical source unless the tool normalizes paths. 2. Timestamps and build numbers. A task that stamps the current time or the run number into a generated file has a different output on every run by construction, so nothing it produces is reusable. 3. Environment variables. A tool that passes the caller's full environment into an action makes the key depend on `PATH`, on shell configuration, and on any variable a runner image happens to set. 4. Toolchain versions. A different compiler patch release, JDK, or language runtime between the writer and the reader is a real input change, and a correct cache treats it as one. The first three are configuration mistakes. The fourth is the cache doing its job, and the fix is pinning the toolchain so every machine agrees on which version is in use. ### [Reads, writes, and who is allowed to do each](#reads-writes-and-who-is-allowed-to-do-each)Copy Anchor Link Every remote build cache has two directions, and they are usually configured separately. A read happens before a unit of work runs. The tool computes the key, asks the endpoint whether it holds an entry, and downloads it on a hit. A write happens after a unit of work runs locally. The tool uploads the outputs under the key it computed, so that the next machine to compute that key gets a hit. Splitting the two permissions is the standard safety measure. A build triggered by an unreviewed branch or a fork can be given read access and denied write access, which keeps an untrusted commit from placing content under a key that a later trusted build would download and treat as its own output. Bazel expresses the read-only mode as `--noremote_upload_local_results`, and Gradle as `push = false` on the remote cache block. Two operational properties follow from the network hop. Entries are content addressed, so an entry can be verified against the digest it is stored under rather than trusted because of where it came from. And distance costs time, because a lookup that has to cross a region boundary pays that latency on every unit of work, which is why teams place the cache endpoint close to the machines that use it. ### [Remote build cache, dependency cache, and container layer cache](#remote-build-cache-dependency-cache-and-container-layer-cache)Copy Anchor Link Three caches show up in the same pipeline and are often confused because all three make a repeated build do less work. They store different things and fail in different ways. | Cache kind | What one entry holds | Key derived from | Written by | | --------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | Remote build cache | Outputs of one unit of your own build: compiled classes, jars, generated sources, test results | Hash of that unit's inputs: source contents, flags, toolchain version, dependency digests | The build tool, during a build | | Dependency cache | Third party packages fetched from a registry: npm, Maven, PyPI, Go modules, crates | A lockfile hash, or the package coordinate plus the published checksum | The package manager, or a cache step that wraps the package directory | | Container layer cache | Filesystem layers produced by image build instructions | The parent layer digest plus the instruction and the digests of the files it copies in | The image builder, during an image build | Three practical differences fall out of that table. Invalidation blast radius differs. A changed source file invalidates one build cache entry and the entries downstream of it. A changed lockfile invalidates the whole dependency cache entry, because the key covers the entire resolved set. A changed instruction invalidates that image layer and every layer after it, because each layer is keyed on its parent. Ownership differs. Build cache entries are outputs your repository produced, so they are only as trustworthy as the machine that wrote them. Dependency cache entries are reproducible from a registry and verifiable against a published checksum. Placement differs. A dependency cache and a layer cache are both useful on a single machine that runs repeat builds. A build cache is worth the network hop mainly when several machines build the same targets, which is the normal situation in a monorepo where every pull request builds the parts of the graph nobody touched. The three compose. A build with all three configured downloads its packages from the dependency cache, skips the compilation and test units that already have entries in the build cache, and reuses unchanged image layers when it builds a container at the end. ### [What a remote build cache leaves alone](#what-a-remote-build-cache-leaves-alone)Copy Anchor Link A cache hit removes execution of a unit of work. Everything a build does outside those units still happens on every machine. Loading and analysis still run. A build tool has to read the build files, resolve the dependency graph, and decide which units exist before it can compute a single key, and on a large monorepo that pass is real work that a remote cache does not shorten. Fetching external repositories still happens unless a separate local cache holds them, because those archives are inputs rather than outputs. Units that lack deterministic output stay uncacheable. A task that talks to a network service, reads the clock, or writes outside its declared output paths has no reliable key, and most tools provide a way to mark such a task as uncacheable so the build stops trying. Downloads are not free. An entry whose outputs are hundreds of megabytes has to cross the network on a hit, so tools expose controls for which outputs are materialized on disk and which are left in the store until something needs them. ## [Example](#example)Copy Anchor Link The clearest demonstration is one repository built twice on two different machines, where the second machine has never seen the workspace. A Gradle monorepo turns on the build cache in `settings.gradle.kts` and points the remote half at an HTTP endpoint, with writes limited to trusted builds: settings.gradle.kts Copy Text ``` buildCache { local { isEnabled = true } remote { url = uri("https://build-cache.internal.example.com/cache/") isPush = System.getenv("CACHE_PUSH") == "true" credentials { username = System.getenv("CACHE_USER") password = System.getenv("CACHE_PASSWORD") } } } ``` The first build runs on a machine with an empty cache. Every cacheable task executes, and each one uploads its outputs under the key it computed. The console marks the executed tasks with no status suffix. The second build runs on a different machine, at the same commit, with the same toolchain. Nothing in the source changed, so every key matches and the console reports where each output came from: Copy Text ``` $ ./gradlew :services:api:test --build-cache > Task :libs:common:compileJava FROM-CACHE > Task :libs:common:jar FROM-CACHE > Task :libs:protocol:compileJava FROM-CACHE > Task :services:api:compileJava FROM-CACHE > Task :services:api:test FROM-CACHE BUILD SUCCESSFUL 19 actionable tasks: 2 executed, 17 from cache ``` `FROM-CACHE` is the token to read. The test task compiled nothing and executed no test methods. Gradle computed the key for `:services:api:test`, found an entry, and unpacked the recorded test results into the build directory. The two tasks that executed are the ones whose keys did not match. That is the normal shape of a warm build: a small changed set runs, the unchanged remainder of the graph is downloaded. Bazel reports the same event with a different vocabulary. Its summary line counts the processes that resolved to a cache entry instead of running: Copy Text ``` $ bazel build //services/api:image --config=ci INFO: Analyzed target //services/api:image (0 packages loaded, 0 targets configured). INFO: Found 1 target... INFO: 412 processes: 389 remote cache hit, 23 internal. INFO: Build completed successfully, 412 total actions ``` `remote cache hit` counts actions whose key was found on the endpoint. `internal` counts work Bazel did in process, such as writing symlinks, which never had a remote form. The line above describes a build where almost nothing was recompiled. ### [Wiring the endpoint into a GitHub Actions job](#wiring-the-endpoint-into-a-github-actions-job)Copy Anchor Link On GitHub Actions the cache configuration is a small part of the job. The `runs-on` label picks the machine, and the build tool reads its endpoint from configuration and secrets: .github/workflows/build.yml Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" - name: Build and test env: CACHE_USER: ${{ secrets.BUILD_CACHE_USER }} CACHE_PASSWORD: ${{ secrets.BUILD_CACHE_PASSWORD }} CACHE_PUSH: ${{ github.ref == 'refs/heads/main' }} run: ./gradlew build --build-cache ``` `CACHE_PUSH` is the read and write split from earlier expressed as one workflow expression. Builds on `main` upload their outputs. Pull request builds read entries and upload nothing. Label shapes such as `warp-ubuntu-latest-x64-8x` encode an operating system, an architecture, and a machine size, and GitHub treats the whole string as one opaque routing key, so pointing this job at a different fleet is a change to the `runs-on` line and nothing else. The [WarpBuild caching documentation](/docs/ci/features/caching) covers the cache actions available to a job, and the [remote Docker builders documentation](/docs/ci/docker-builders) covers the layer cache that sits behind image builds rather than build tool outputs. One detail is worth checking before reading hit rates. A build tool's remote cache and a workflow level cache step are different mechanisms, and a job can use both: the cache step restores directories such as a package manager store, while the build tool talks to its own endpoint for task outputs. Counting a `FROM-CACHE` line as evidence that the workflow cache step worked is a common misreading. ## [Related Terms](#related-terms)Copy Anchor Link * [Docker layer cache, defined](/glossary/docker-layer-cache): what an image layer entry holds, how the key chains to the parent layer, and why one changed instruction invalidates everything after it. * [Artifact cache, defined](/glossary/artifact-cache): how a workflow stores and restores directories between jobs and runs, and how that differs from a build tool's own cache. * [Running Bazel on GitHub Actions with a remote cache](/solutions/bazel-github-actions): the rc file, the flags, and how the remote cache overlaps with runner local state. * [Running Gradle builds on GitHub Actions](/solutions/gradle-github-actions): the Gradle User Home, the build cache, and the configuration cache in one workflow. * [Nx builds on GitHub Actions](/solutions/nx-github-actions): task graph caching in an Nx monorepo and how affected project selection interacts with it. * [What is a remote Docker builder?](/answers/what-is-a-remote-docker-builder): a persistent machine that runs image builds and keeps its layer cache between jobs. * [WarpBuild caching documentation](/docs/ci/features/caching): the cache actions available inside a GitHub Actions job and how their keys work. * [WarpBuild pricing](/pricing): per minute rates by runner type and cache storage rates. ## FAQ ### What is the difference between a remote build cache and a dependency cache? A dependency cache holds third party packages downloaded from a registry, keyed by a lockfile hash or by the coordinate and checksum of each artifact. A remote build cache holds the outputs your own build produced, keyed by a hash of the inputs to each task or action. One removes downloads from a registry, the other removes compilation and test execution. ### Does a remote build cache work across branches and machines? Yes, that is the property the word remote carries. The key is derived from the inputs of a unit of work rather than from a branch name or a machine identity, so a branch that touches one package hits the cache for every package it did not touch, and a laptop can reuse an entry a build server wrote. ### Why does my remote build cache miss on every run? Almost always because an input that has nothing to do with the source is part of the key. The usual culprits are absolute paths that differ between machines, a timestamp or build number injected into the task, an environment variable such as PATH leaking into the action environment, and a toolchain version that differs between the writer and the reader. ### Should pull request builds write to the remote build cache? Most teams let pull requests read the cache and block them from writing to it, so an unreviewed branch cannot place an output under a key that a later trusted build would download. Bazel spells this as --noremote\_upload\_local\_results and Gradle as push = false on the remote cache block. --- # Remote Execution | WarpBuild URL: https://www.warpbuild.com/glossary/remote-execution Description: Remote execution is a build mode where the build tool dispatches each action to a pool of worker machines instead of running it on the local machine. # Remote Execution Remote execution is a build mode where the build tool dispatches each action to a pool of worker machines instead of running it on the local machine. Remote execution is a build mode where actions run on a pool of worker machines instead of the machine that started the build, coordinated by a build tool that decides what each action is. The build tool still reads the workspace and resolves the dependency graph locally, then ships individual compile, link, and test actions across the network and fetches back only the outputs it needs. The word action is doing the work in that definition. A build tool that supports remote execution has already broken the build into discrete units with declared inputs, declared outputs, and a command line, and it is those units that travel. ## [Definition](#definition)Copy Anchor Link Remote execution is defined by an open protocol rather than by any one build tool. The Remote Execution API, version 2, is published in the [bazelbuild/remote-apis](https://github.com/bazelbuild/remote-apis) repository under the Apache License 2.0, and the repository describes its aim as enabling large scale parallel execution that would not be feasible on a single system while minimizing uploads by storing data in a content addressable format (read on 2026-08-13). The protocol is four gRPC services. Names and methods below are from [remote\_execution.proto](https://github.com/bazelbuild/remote-apis/blob/main/build/bazel/remote/execution/v2/remote%5Fexecution.proto), read on 2026-08-13. | Service | Key methods | What it carries | | ------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------- | | ContentAddressableStorage | FindMissingBlobs, BatchUpdateBlobs, BatchReadBlobs, GetTree | Every input file, command, and output blob, addressed by digest | | ActionCache | GetActionResult, UpdateActionResult | The mapping from an action digest to the result of running it | | Execution | Execute, WaitExecution | The request to run an action and the stream of progress back | | Capabilities | GetCapabilities | What digest functions and API versions a server accepts | An `Action` message is small. It holds `command_digest`, `input_root_digest`, an optional `timeout`, a `do_not_cache` flag, a `salt` that separates cache entries into their own space, and a `platform` block of key and value properties. The command line and the entire input tree are referenced by digest rather than embedded, which is why an action can describe a build of a large tree in a few hundred bytes. ### [How one action travels](#how-one-action-travels)Copy Anchor Link A client that wants an action executed hashes its inputs, calls `FindMissingBlobs` to learn which of those digests the server has never seen, uploads only those, and then calls `Execute`. The server responds with a stream of operations whose metadata reports one of four stages defined in the proto: `CACHE_CHECK`, `QUEUED`, `EXECUTING`, and `COMPLETED`. The result is an `ActionResult` listing output files by digest, exit code, and the digests of stdout and stderr. Two consequences follow. The first is that identical work is deduplicated across every client pointed at the same server, because the action digest is the cache key. The second is that actions have to be reproducible for that sharing to be correct, so a rule that reads an undeclared file or stamps a timestamp into its output poisons results for everyone. The `platform` properties are how an action reaches the right worker. They are opaque key and value pairs, commonly a container image reference, an operating system family, or a pool name, and the server routes the action to a worker that satisfies them. A build that needs two toolchains sends two sets of properties. ### [Where remote execution sits](#where-remote-execution-sits)Copy Anchor Link Three build modes are often discussed together, and they move different things off the local machine. | Mode | What runs the action | What crosses the network | What the local machine needs | | ---------------- | --------------------------- | ------------------------------------------ | ------------------------------------------ | | Local execution | The local machine | Nothing | Cores and RAM for peak action concurrency | | Remote caching | The local machine on a miss | Action digests and cached results | Cores and RAM for every miss | | Remote execution | A worker in the pool | Inputs, action metadata, requested outputs | Bandwidth and open connections, little CPU | Parallelism is the practical difference. Local execution is bounded by the cores on the machine, while a remote execution client can hold hundreds of actions in flight against a worker pool, which is the reason build tools raise their concurrency setting well past the local core count when remote execution is turned on. Support is per build tool. The Remote Execution API repository lists Bazel, Buck2, BuildStream, Justbuild, Pants, Please, Recc, Reclient, and Siso as clients that distribute build actions to workers through the API (read on 2026-08-13). A tool with no action graph has nothing to send. ## [Example](#example)Copy Anchor Link This GitHub Actions workflow runs a Bazel build on a hosted Linux runner and points the compile and test actions at a remote execution endpoint. Flag names are from Bazel's remote options, and the endpoint address, instance name, and platform properties come from whichever server is being used. Copy Text ``` name: build on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build with remote execution run: | bazel build //... \ --remote_executor=grpcs://remote.example.internal:443 \ --remote_instance_name=default_instance \ --remote_default_exec_properties=OSFamily=Linux \ --remote_timeout=3600 \ --jobs=200 \ --remote_download_minimal ``` When that push lands, the runner checks out the repository, loads and analyzes the build graph, and computes a digest for every action in it. It then asks the server which input blobs are missing, uploads those, and issues up to 200 concurrent `Execute` calls. Each action compiles on a worker, and the resulting object files and archives stay in the content addressable storage rather than coming back, because `--remote_download_minimal` tells the build tool to materialize only the outputs it actually needs locally. A later action that consumes one of those object files references it by digest, and the worker that runs that action fetches it from the same storage. Related flags in the same family are worth naming, since they change what the runner does. `--remote_cache` points at the cache alone with no execution service. `--remote_download_outputs` takes `all`, `toplevel`, or `minimal` and controls how much comes back. `--remote_local_fallback` runs an action locally when the remote call fails, which trades a slower build for a build that still completes. `--remote_execution_priority` orders requests inside a shared pool. ### [What the runner still does](#what-the-runner-still-does)Copy Anchor Link The residual work on the machine that started the build is real and does not shrink as the worker pool grows: checkout, graph loading and analysis, input hashing, blob upload for anything the server has not seen, and the bookkeeping for hundreds of open gRPC streams. That work is dominated by single thread speed, disk throughput, and network round trips rather than by core count, so the machine profile that suits a remote execution client differs from the one that suits a local build of the same repository. Distance matters for the same reason. Every cache check, upload, and result fetch is a round trip, so a client far from the endpoint spends wall clock time in latency that no worker count recovers. ## [Related Terms](#related-terms)Copy Anchor Link * [Wiring Bazel remote execution into a GitHub Actions workflow](/guides/bazel-remote-execution-github-actions): the configuration, the platform properties, and what the runner is still sized for. * [Remote build cache and how action results are keyed and reused](/glossary/remote-build-cache): the cache half of the same API, and why a digest match is the unit of reuse. * [Running Bazel on GitHub Actions with a remote cache](/solutions/bazel-github-actions): the four pieces of Bazel state on a runner and which of them a network service can serve. * [WarpBuild caching documentation](/docs/ci/features/caching): how the cache action is configured in a workflow. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner labels available and how a workflow selects them. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between remote execution and remote caching? A remote cache stores the results of actions that already ran somewhere and returns them when an action digest matches, so a miss still compiles on the machine running the build. Remote execution sends the action itself to a worker pool, so a miss compiles on a worker. Both are defined by the same API, and a build can use the cache alone, execution alone, or both with the cache checked first. ### Which build tools support remote execution? The Remote Execution API repository lists Bazel, Buck2, BuildStream, Justbuild, Pants, Please, Recc, Reclient, and Siso as clients that distribute builds to workers through it, read on 2026-08-13\. Support is per tool: the tool has to model its work as discrete actions with declared inputs before it can send one anywhere. ### What still runs on the local machine under remote execution? Source checkout, loading and analysis of the build graph, hashing inputs to compute action digests, uploading blobs the server reports missing, holding many concurrent gRPC calls open, and downloading whatever outputs the build asked to materialize. That residual work is single threaded and network heavy rather than CPU heavy. --- # Reusable Workflow | WarpBuild URL: https://www.warpbuild.com/glossary/reusable-workflow Description: A reusable workflow is a GitHub Actions workflow that declares on: workflow_call, so other workflows call it as a job and share one pipeline definition. # Reusable Workflow A reusable workflow is a GitHub Actions workflow that declares on: workflow\_call, so other workflows call it as a job and share one pipeline definition. A reusable workflow is a GitHub Actions workflow file that declares `on: workflow_call`, so other workflows can call it as a job rather than copying its steps. One definition of a pipeline then lives in one file, and every caller across repositories or across jobs in the same repository runs that same definition. The pattern exists because build, test, and release logic tends to be duplicated into every repository that needs it. A reusable workflow turns that duplication into a single file plus a one line reference at each call site. ## [Definition](#definition)Copy Anchor Link A reusable workflow has two halves. The **called workflow** is an ordinary workflow file that lists `workflow_call` among its triggers and optionally declares typed inputs, secrets, and outputs. The **caller job** is a job in another workflow that points at the called workflow with `uses` instead of listing `steps`. The file has to sit directly in the `.github/workflows` directory of the repository that owns it. Subdirectories of that path are not supported for reusable workflows ([GitHub reusable workflows documentation](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows), checked on 2026-08-13). The caller references the file by path and ref: `owner/repo/.github/workflows/build.yml@v1`, where the ref is a commit SHA, a tag, or a branch. A workflow in the same repository can use the short form `./.github/workflows/build.yml`, which always resolves to the ref of the caller's own commit. ### [What a caller job may contain](#what-a-caller-job-may-contain)Copy Anchor Link A job that calls a reusable workflow accepts a fixed set of keys: `name`, `uses`, `with`, `secrets`, `strategy`, `needs`, `if`, `concurrency`, and `permissions`. GitHub rejects `steps` and `runs-on` in that job, because the called workflow owns its own jobs and each of those jobs declares its own `runs-on`. Machine selection therefore stays inside the reusable file, which is why changing runner labels for a whole organization can be a single edit in one repository. `strategy` is worth calling out. A caller job can fan a matrix across the same reusable workflow, so one file drives a build across several language versions or target platforms while the pipeline logic stays in one place. ### [Inputs, secrets, and outputs](#inputs-secrets-and-outputs)Copy Anchor Link Values cross the boundary through three declared channels: * **Inputs** are declared under `on.workflow_call.inputs` and passed by the caller under `with`. Each input has a type, and the value must match: `boolean`, `number`, or `string`. * **Secrets** are declared under `on.workflow_call.secrets` and passed by name, or passed as a group with `secrets: inherit` when the caller and the called workflow belong to the same organization or enterprise. Environment secrets cannot be passed in, because `on.workflow_call` has no `environment` keyword. * **Outputs** are declared under `on.workflow_call.outputs`, each mapped to a job output inside the called workflow. The caller reads them as `needs..outputs.`. Environment variables do not cross the boundary. Anything set in an `env` context at the workflow level of the caller stays in the caller, so values that the called workflow needs have to arrive as inputs. Permissions travel one way. GitHub allows the `GITHUB_TOKEN` permissions of a called workflow to be maintained or reduced through the chain, and rejects any attempt to elevate them, so delegating work to a reusable file is a way to lose access rather than gain it. ### [Limits worth knowing before you nest](#limits-worth-knowing-before-you-nest)Copy Anchor Link Two documented ceilings shape how far this pattern scales ([GitHub reusable workflows reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusable-workflows), checked on 2026-08-13): | Limit | Documented value | | ------------------------------------------------------------ | ---------------------------------------------------------- | | Unique reusable workflows called from a single workflow file | 50 | | Levels of connected workflows | 10, meaning the top level caller plus nine levels below it | Secrets pass only to the workflow a file calls directly. In a chain from A to B to C, workflow C receives a secret from A only when A passed it to B and B passed it on to C. ### [Reusable workflow compared with composite action](#reusable-workflow-compared-with-composite-action)Copy Anchor Link Both remove duplication, and they operate at different layers. | Dimension | Reusable workflow | Composite action | | ---------------- | ------------------------------------------ | --------------------------------------- | | Unit shared | A whole workflow, with jobs | A sequence of steps | | Where it runs | Its own jobs, each on its own runner | Inside a job the caller already started | | Declares runs-on | Yes, per job in the called file | No, it inherits the caller's machine | | Called from | jobs..uses | steps\[\*\].uses | | Log view | Separate jobs in the run graph | Steps folded into the calling job | | File location | .github/workflows of the owning repository | Any path containing action.yml | ## [Example](#example)Copy Anchor Link The called workflow below declares one input, one secret, and one output. It lives at `.github/workflows/build.yml` in a repository named `acme/ci`. Copy Text ``` name: build on: workflow_call: inputs: node-version: required: true type: string publish: required: false type: boolean default: false secrets: npm-token: required: true outputs: artifact-name: description: Name of the uploaded build artifact value: ${{ jobs.build.outputs.artifact-name }} jobs: build: runs-on: ubuntu-latest outputs: artifact-name: ${{ steps.pack.outputs.name }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} - run: npm ci - run: npm test - id: pack env: NODE_AUTH_TOKEN: ${{ secrets.npm-token }} run: | npm pack echo "name=$(ls *.tgz)" >> "$GITHUB_OUTPUT" - if: inputs.publish run: npm publish ``` A workflow in any repository that is allowed to reach `acme/ci` calls it as a job. The calling job carries no steps of its own: Copy Text ``` name: pull-request on: [pull_request] jobs: build: uses: acme/ci/.github/workflows/build.yml@v1 with: node-version: "22" secrets: npm-token: ${{ secrets.NPM_TOKEN }} report: needs: build runs-on: ubuntu-latest steps: - run: echo "built ${{ needs.build.outputs.artifact-name }}" ``` In the run graph, `build` appears as its own job with the steps from the called file, and `report` reads the output the called workflow published. Bumping `@v1` to `@v2` across the calling repositories is the whole upgrade, and the pipeline steps themselves are edited once in `acme/ci`. The same shape with a matrix keeps the fan out at the call site while the steps stay in one file: Copy Text ``` jobs: build: strategy: matrix: node-version: ["20", "22"] uses: acme/ci/.github/workflows/build.yml@v1 with: node-version: ${{ matrix.node-version }} secrets: inherit ``` ## [Related Terms](#related-terms)Copy Anchor Link * [Composite actions and how they differ from reusable workflows](/glossary/composite-action): the step level unit of reuse that runs on the caller's machine. * [How to reuse a workflow across repositories](/answers/how-do-i-reuse-a-workflow-across-repositories): the access settings, ref pinning, and secret passing a cross repository call needs. * [Running reusable workflows across a large set of repositories](/guides/reusable-workflows-at-scale-github-actions): versioning, rollout, and runner label changes when many repositories share one workflow file. * [GitHub reusable workflows reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusable-workflows): the upstream syntax and limits, checked on 2026-08-13. * [WarpBuild quick start](/docs/ci/quick-start): pointing a workflow's `runs-on` at a managed runner fleet. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between a reusable workflow and a composite action? A reusable workflow is a whole workflow file with its own jobs, and each of those jobs declares its own runs-on and gets its own machine. A composite action is a bundle of steps that runs inside a job the caller already started, on the caller's machine. Use a reusable workflow when you want to share job structure, and a composite action when you want to share a sequence of steps. ### Can a reusable workflow live in a different repository? Yes. The caller references it as owner/repo/.github/workflows/file.yml@ref, where ref is a commit SHA, a tag, or a branch. The file has to sit directly in the .github/workflows directory of the repository that owns it, because GitHub does not support subdirectories of that path for reusable workflows. ### Why are the caller's environment variables missing inside the reusable workflow? GitHub does not propagate variables set in an env context at the workflow level of the caller into the called workflow. Pass the values you need as typed inputs under with, and send values back out through outputs declared on on.workflow\_call. --- # Runner Group | WarpBuild URL: https://www.warpbuild.com/glossary/runner-group Description: A runner group is a GitHub-side container around a set of runners that controls which repositories and which workflows are allowed to run jobs on them. # Runner Group A runner group is a GitHub-side container around a set of runners that controls which repositories and which workflows are allowed to run jobs on them. A runner group is a container defined on the GitHub side that holds a set of runners and carries an access policy describing which repositories may use them. The same policy can narrow access further to named workflow files at a specific branch, tag, or commit SHA, so a group answers the question of who is allowed to run jobs on this hardware. The term shows up in two places: in the organization or enterprise settings where the group and its policy are edited, and in the `runs-on` key of a workflow when a job wants to target one group by name. ## [Definition](#definition)Copy Anchor Link A runner group has three parts. 1. **A name.** The string a workflow uses to target the group, and the string that appears in the settings list. 2. **A membership list.** The runners assigned to the group. A runner belongs to exactly one group at a time, and moving it to another group changes which repositories can reach it. 3. **An access policy.** The rules that decide which repositories, and optionally which workflows, may queue jobs onto the members. The policy is evaluated by GitHub before any label matching happens. A repository that the policy excludes never sees the runners inside the group, whatever labels those runners advertise. ### [Where groups sit in the runner hierarchy](#where-groups-sit-in-the-runner-hierarchy)Copy Anchor Link Registration scope decides whether a group is involved at all. A runner is registered against one repository, one organization, or one enterprise, and only the last two put the runner into a group. | Registration scope | Group applies | Who edits the policy | What the policy controls | | ------------------ | ------------- | -------------------- | ------------------------------------------------------------------------------ | | Repository | No | Repository admins | The runner serves that one repository | | Organization | Yes | Organization owners | Which repositories in the organization may use the group | | Enterprise | Yes | Enterprise admins | Which organizations may use the group, and then which repositories inside them | Enterprise groups nest. An enterprise group first chooses the organizations that can see it, and each of those organizations then chooses the repositories that can use it. A repository needs a yes at both levels. Groups are not limited to runners you supply. GitHub places its own larger hosted runners into groups as well, and access to a larger runner shape is administered through the same repository access policy ([GitHub larger runners reference](https://docs.github.com/en/actions/reference/runners/larger-runners), checked on 2026-08-13). ### [What a group can restrict](#what-a-group-can-restrict)Copy Anchor Link GitHub supports a small, fixed set of restriction axes. Every axis is a filter on eligibility, and a job has to pass all of them. | Axis | Values GitHub supports | Typical use | | ------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------- | | Repository access | All repositories in the organization, or a selected list | Keep a fleet reserved for one team's repositories | | Public repositories | Allowed or disallowed, disallowed by default | Decide whether self-hosted runners may serve public repositories | | Organization access | All organizations, or a selected list (enterprise groups only) | Share one enterprise fleet with named organizations | | Workflow path | All workflows, or named workflow files | Allow only the deployment workflow onto a privileged fleet | | Branch, tag, or sha | A ref appended to the workflow path | Allow the deployment workflow only from the release branch | A workflow restriction is written as a full path with a ref attached. The form is `owner/repo/.github/workflows/cd.yaml@refs/heads/main` for a branch, `@refs/tags/v1` for a tag, and a commit SHA for an exact revision. Adding one restricted workflow to a group blocks every workflow the list does not name, which is why the symptom of a workflow restriction is one file queueing while the rest of the repository keeps working ([WarpBuild common issues documentation](/docs/ci/common-issues), checked on 2026-08-13). ### [How a group is chosen when a job queues](#how-a-group-is-chosen-when-a-job-queues)Copy Anchor Link The `runs-on` key accepts an object form that names a group and then filters inside it: Copy Text ``` jobs: deploy: runs-on: group: production-runners labels: [self-hosted, linux, x64] ``` Evaluation runs in two stages. GitHub first builds the candidate set: the runners inside `production-runners`, provided the group's policy grants this repository access. It then applies the ordinary label rule inside that set, where a runner is eligible only when it carries every label the job asked for. Extra labels on the runner are ignored, which is covered in more detail under [runner label](/glossary/runner-label). A job that names no group skips the first stage and is eligible for runners in any group the repository can reach, plus any runner registered directly against the repository. Most workflows are written this way, which is why teams often meet the concept for the first time while debugging a job that never started. The group name is checked against the groups the repository can reach. Naming a group that exists but excludes the repository, and naming a group that does not exist at all, produce the same visible result: the job stays queued and the log stays empty. GitHub cancels a job that has been queued for 24 hours ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), so a policy mistake reports itself a day after the push. Groups are editable through the REST API as well as the settings UI, under the organization and enterprise runner group endpoints ([GitHub REST API for self-hosted runner groups](https://docs.github.com/en/rest/actions/self-hosted-runner-groups), checked on 2026-08-13). That is the path teams use to keep the repository access list in version control rather than in a browser tab. ## [Example](#example)Copy Anchor Link Every organization starts with one group called `Default`, carrying id `1`. It cannot be deleted, and a runner registered at the organization level lands in it unless an admin moves the runner somewhere else. Managed runner fleets follow the same rule, because a provider registers machines against your organization through the same GitHub registration API a self-hosted runner uses. WarpBuild registers into the `Default` runner group at id `1` ([WarpBuild public repositories documentation](/docs/ci/public-repos), checked on 2026-08-13). The `Default` group is where the public repository setting lives, and that single checkbox is the most consequential policy toggle in the whole feature. GitHub disables self-hosted runners in public repositories by default. Until an organization owner opens the group settings page and checks `Allow public repositories`, every job in every public repository that targets a self-hosted or managed runner queues and never starts, while the same workflow in a private repository in the same organization runs normally. That asymmetry is worth stating plainly, because the workflow file is identical in both cases. The difference lives entirely in the group policy. ### [A job before and after the group is opened](#a-job-before-and-after-the-group-is-opened)Copy Anchor Link Start with a workflow that names no group. It asks for a runner by label, and eligibility is decided by whichever groups the repository can reach: Copy Text ``` name: build on: push: branches: [main] jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: make test ``` In a private repository whose organization has this fleet in the `Default` group, the job is claimed and starts. In a public repository in the same organization, the identical job stays queued until `Allow public repositories` is checked on that group. Nothing in the file changes between those two outcomes. Once an organization creates a second group, jobs can target it explicitly. The object form pairs the group name with the labels used inside it: Copy Text ``` name: release on: push: tags: ['v*'] jobs: build-linux: runs-on: group: release-runners labels: [warp-ubuntu-latest-x64-8x] steps: - uses: actions/checkout@v4 - run: make release build-linux-arm: runs-on: group: release-runners labels: [warp-ubuntu-latest-arm64-4x] steps: - uses: actions/checkout@v4 - run: make release ``` Both jobs are filtered by the group first and the label second. If `release-runners` excludes this repository, neither job starts, and the label is never consulted. If the group grants access but carries a workflow restriction of `acme/service/.github/workflows/release.yaml@refs/tags/v1`, then a push of tag `v2` queues while `v1` runs, because the ref in the restriction is part of the match. The labels in those two files resolve to the machine shapes below. Sizes are from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | Label | OS | vCPU | RAM | Storage | | --------------------------- | ------------ | ---- | ----- | --------- | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | ### [Reading the failure from the outside](#reading-the-failure-from-the-outside)Copy Anchor Link A group problem and a hardware problem look the same on the Actions tab for the first minute. They separate over time. A job waiting on busy runners is claimed as soon as one frees up, and its log fills in. A job blocked by a group policy is never claimed, so the log view stays empty until the 24 hour cancellation. Waiting longer changes nothing, because the passage of time does not add the repository to an access list. Three checks resolve almost every instance, in the order the causes actually occur: confirm the repository is public or private and read the public repository checkbox on the group; confirm the repository appears on the group's repository access list; then read the workflow restrictions for a path or ref that excludes this file. The [runner groups guide](/guides/github-actions-runner-groups) walks each check with the settings pages involved. ## [Related Terms](#related-terms)Copy Anchor Link * [Using runner groups with GitHub Actions](/guides/github-actions-runner-groups): the troubleshooting checklist for a job that never starts, in the order the causes occur. * [Runner label, defined](/glossary/runner-label): the subset matching rule that runs after group filtering, and the label shapes that encode a machine size. * [Self-hosted runner, defined](/glossary/self-hosted-runner): what registration scope means and why organization-level registration is what puts a runner into a group. * [Are GitHub Actions free for open source?](/answers/are-github-actions-free-for-open-source): what a public repository gets by default, and where the public repository setting on a group fits. * [WarpBuild public repositories documentation](/docs/ci/public-repos): the `Default` group at id `1` and the `Allow public repositories` checkbox, with the settings page path. * [WarpBuild common issues documentation](/docs/ci/common-issues): runner group access checks and the workflow restriction syntax with a worked example. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a runner group in GitHub Actions? A runner group is a container defined on the GitHub side that holds a set of runners and carries an access policy. The policy decides which repositories may send jobs to those runners, and optionally which workflow files, branches, tags, and commit SHAs may claim them. Every runner registered at the organization or enterprise level belongs to exactly one group. ### Do runner groups apply to repository-level runners? Groups are an organization and enterprise concept. A runner registered against a single repository is already scoped to that repository, so there is no group policy to apply. Once a runner is registered at the organization or enterprise level, it lands in a group, and the group's access policy decides which repositories can reach it. ### How does a workflow target a specific runner group? Use the object form of runs-on with a group key, for example runs-on with group set to production-runners and labels set to \[linux, x64\]. GitHub first narrows the candidate runners to that group, then applies the usual label subset match inside it. A job that names no group is eligible for any group the repository can reach. ### Why does a job stay queued when a runner group excludes the repository? No runner is eligible, so nothing claims the job and nothing writes to the log. The run sits at the waiting state with an empty log view for as long as you let it. GitHub cancels a job that has been queued for 24 hours, so the failure surfaces a day later rather than immediately. --- # Runner Image | WarpBuild URL: https://www.warpbuild.com/glossary/runner-image Description: A runner image is the prepared disk image a CI runner boots from, carrying the operating system and the preinstalled toolchains a job expects to find. # Runner Image A runner image is the prepared disk image a CI runner boots from, carrying the operating system and the preinstalled toolchains a job expects to find. A runner image is the prepared disk image a machine boots from before it accepts a GitHub Actions job, carrying the operating system together with the compilers, language runtimes, and command line tools that jobs expect to find. The image decides what exists on the machine at second zero, so any tool the image lacks has to be installed by a step inside the job. The image is built ahead of time and published as a versioned artifact. Two runs of the same workflow can land on two different builds of the same image, which is the usual explanation for a tool version that moved without a commit touching it. ## [Definition](#definition)Copy Anchor Link A runner image is a snapshot of a complete filesystem, produced by a build pipeline and published in a bootable form: an AMI on AWS, a managed image on Azure, a machine image on GCP, or a container image where jobs run inside containers. When a runner starts, the machine boots that image, the runner agent registers with GitHub, and the first step of the job executes against whatever the image already contains. Four layers travel inside a runner image, and a job depends on all four. | Layer | Contents | Why a job depends on it | | -------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | Operating system | Kernel, init system, base distribution packages | Fixes the syscall surface, the C library version, and the package manager available to steps | | System packages | git, curl, unzip, a compiler toolchain, a container runtime | Steps call these directly and rarely install them | | Language runtimes and SDKs | Node, Python, Go, Java, .NET, Ruby, held in a tool cache that can carry several versions of each | A setup action resolves a requested version from the cache instead of downloading it | | Machine configuration | Default user, PATH, environment variables, service units, warmed caches | Decides file permissions and what has already run before the first step | ### [Where the image definition lives](#where-the-image-definition-lives)Copy Anchor Link GitHub builds the images behind its hosted runners in the open. The [actions/runner-images repository](https://github.com/actions/runner-images) holds the build definitions plus one README per image listing the packages and versions in the current build, such as the [Ubuntu 24.04 image README](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md) (checked on 2026-08-13). Self-hosted fleets follow the same pattern with their own pipeline: a tool such as Packer provisions a machine, installs the toolchain, and captures the result as a versioned image. ### [Image versions move](#image-versions-move)Copy Anchor Link A published image is immutable, and the pipeline that produces it ships a new version on a cadence. Jobs pick up the newest version as it rolls out, so a workflow that pins nothing inherits the image's choice of patch versions. Hosted images record the build a job ran on in the `ImageOS` and `ImageVersion` environment variables, which is why cache keys in published workflows often include them ([GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13). ### [Terms that sit next to it](#terms-that-sit-next-to-it)Copy Anchor Link * **Container image.** The filesystem named in a `container:` key or a Dockerfile `FROM` line, which a job's processes run inside. The runner image still sits underneath and supplies the kernel and the container runtime. * **Runner agent.** The software that registers the machine and polls GitHub for queued jobs. It is baked into the image or fetched at start, and updating it changes no part of the toolchain. * **Tool cache.** A directory inside the image holding prebuilt runtimes, exposed through the `RUNNER_TOOL_CACHE` environment variable and located at `/opt/hostedtoolcache` on hosted Linux images. A version present there is a directory copy; a version absent from it is a download on every run. ## [Example](#example)Copy Anchor Link This workflow leans on the runtimes the image already carries. Nothing here installs a language. Copy Text ``` name: test on: push: branches: [main] jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Show what the image carries run: | node --version python3 --version docker --version ls "$RUNNER_TOOL_CACHE/node" - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test ``` The `setup-node` step is a lookup rather than an install while the image tool cache holds a matching build. The step log says the version was found in the cache and prints the path under `/opt/hostedtoolcache/node`, and the only work done is adding that directory to PATH. Request a version the image build predates and the same step falls back to a download. | Requested version | Held in the image tool cache | What the step does | Failure modes it adds | | ----------------- | ---------------------------- | ------------------------------------------- | ------------------------------------------------------------ | | node-version: 22 | Yes | Adds the cached directory to PATH | None beyond the image itself | | node-version: 25 | No | Fetches the release tarball and extracts it | Network reachability, checksum, and rate limits on every run | The same split explains why one workflow reaches its test command with no downloads at all while a sibling workflow spends its opening steps on `apt-get install` and toolchain fetches. ### [Recording which image build ran](#recording-which-image-build-ran)Copy Anchor Link When a job fails and the commit looks innocent, the image build is the first thing to check. Copy Text ``` - name: Record the image build run: echo "image ${{ env.ImageOS }} ${{ env.ImageVersion }}" ``` Printing those two values on every run turns an image rollout into a visible event in the logs. A green run on `ImageVersion 20260701.1` and a red run on `ImageVersion 20260715.2` at the same commit points at the image rather than at the change. When a required tool is missing from the image, there are two routes. Install it in the job, which pays the install cost on every run, or bake it into an image of your own and route jobs to that image, which moves the cost to build time and adds an image pipeline to maintain. ## [Related Terms](#related-terms)Copy Anchor Link * [Preinstalled software on WarpBuild runner images](/runners/preinstalled-software): what each platform image carries and how to check a tool version from inside a job. * [Building a custom runner image for GitHub Actions](/guides/custom-runner-images-github-actions): baking a toolchain into a VM image and pointing `runs-on` at it. * [Runner pools and how machines are matched to a label](/glossary/runner-pool): the set of machines eligible to accept a job, and what happens when all of them are busy. * [GitHub runner image build definitions](https://github.com/actions/runner-images): the upstream READMEs listing the software in each hosted image. * [WarpBuild preinstalled software documentation](/docs/ci/preinstalled-software): the per image software references by platform. * [WarpBuild custom VM images documentation](/docs/ci/byoc/custom-vm-images): the requirements an image must meet before runners can boot from it. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a runner image in GitHub Actions? It is the prepared disk image a runner machine boots from before it accepts a job. The image carries the operating system, the system packages, and the language runtimes and SDKs that steps use, so anything the image lacks has to be installed by a step inside the job. ### How is a runner image different from a Docker image? A runner image boots a whole machine and supplies the kernel, the init system, and the container runtime. A Docker image supplies a filesystem for a process that runs on top of that machine, so a job using a container still depends on the runner image underneath it. ### Why did a tool version change without any workflow edit? Runner images are versioned and republished on a cadence, and a job runs on whichever build is current when it starts. A workflow that requests a runtime by major version inherits the patch version the image happens to carry, so an image rollout can move it between two runs of the same commit. --- # Runner Label | WarpBuild URL: https://www.warpbuild.com/glossary/runner-label Description: A runner label is the string in a GitHub Actions runs-on key that GitHub matches against registered runners to decide which machine picks up the job. # Runner Label A runner label is the string in a GitHub Actions runs-on key that GitHub matches against registered runners to decide which machine picks up the job. A runner label is the string a GitHub Actions runner advertises when it registers, and the same string a workflow writes in its `runs-on` key to ask for that kind of machine. GitHub hands a queued job to an online runner whose label set contains every label the job requested, so the label is the routing key that connects a job to a machine. Labels carry no meaning to GitHub beyond that match. Most reports of a job that never starts, or a job that lands on the wrong hardware, resolve to a label that nobody is advertising or a label that describes the machine inaccurately. ## [Definition](#definition)Copy Anchor Link A runner label is an arbitrary string associated with a runner in GitHub's records. Every runner has a set of labels. Every job carries a request, written in `runs-on`. GitHub compares the request against the label sets of the runners in scope and delivers the job to one that satisfies it. ### [Where labels come from](#where-labels-come-from)Copy Anchor Link Labels reach a runner in three ways. **Default labels.** A self-hosted runner picks up `self-hosted` plus an operating system label (`linux`, `windows`, or `macOS`) and an architecture label (`x64`, `ARM`, or `ARM64`) when the agent registers. GitHub assigns these, and they cannot be removed. **Custom labels.** The registration script accepts them directly, as in `./config.sh --labels build,large-disk`, and they can also be added afterwards in the runner settings for a repository, organization, or enterprise, or through the self-hosted runner endpoints of the REST API ([GitHub REST API for self-hosted runners](https://docs.github.com/en/rest/actions/self-hosted-runners), checked on 2026-08-13). **Fleet labels published by the operator.** GitHub-hosted runners come with labels GitHub publishes and controls, such as `ubuntu-latest`, `ubuntu-24.04`, `windows-latest`, `macos-latest`, and image-pinned variants like `ubuntu-24.04-arm`. Larger hosted runners take a label chosen when the runner is created in organization settings. Any other provider that registers machines against your organization does so through the same runner registration API, so the labels it publishes are custom labels in GitHub's model. ### [How matching works](#how-matching-works)Copy Anchor Link Matching is a subset test in one direction. Every label named in `runs-on` must appear on the runner. Labels the runner carries beyond that set are ignored. A runner advertising `self-hosted`, `linux`, `x64`, and `build` is therefore eligible for a job requesting `[self-hosted, linux]` and for a job requesting `[self-hosted, linux, x64, build]`. The same runner is skipped for a job requesting `[self-hosted, linux, x64, gpu]`, because `gpu` is missing. Labels are one of three eligibility filters. The runner also has to be online and idle, and it has to be reachable by the workflow: registered at a scope that covers the repository, and inside a runner group that allows the repository. A label cannot widen either of those boundaries. When several runners qualify, the first one to acknowledge the job message claims it. There is no ranking by size, cost, or idle time, so a fleet mixing shapes under one shared label gives no control over which shape a job lands on. ### [The shapes of runs-on](#the-shapes-of-runs-on)Copy Anchor Link `runs-on` accepts a single string, an array of labels, an object that names a runner group, or an expression that evaluates to any of those. Copy Text ``` jobs: single-label: runs-on: ubuntu-latest several-labels: runs-on: [self-hosted, linux, x64] group-and-labels: runs-on: group: production-runners labels: [linux, x64] from-an-expression: runs-on: ${{ github.ref == 'refs/heads/main' && 'self-hosted' || 'ubuntu-latest' }} ``` The array form is the one that surprises people. It reads like a list of alternatives and behaves like a list of requirements, so adding a label to the array narrows the pool of eligible runners rather than widening it. ### [Label conventions](#label-conventions)Copy Anchor Link Once an organization runs more than one fleet, the label stops being a single word and starts being a record with fields in a fixed order. A common scheme encodes the operator prefix, the image, the architecture, and the machine size, so a reader of the workflow file can tell what a job lands on without opening a dashboard. Two properties make that work. The string is opaque to GitHub, so any separator is allowed. And the string is the only part of the routing decision visible in the repository, so the fields chosen for it are the fields reviewers get to see in a pull request. Some fleets extend the convention with an options suffix after a separator such as a semicolon. GitHub still treats the whole string as one label and matches it as one unit. The operator parses the suffix on its side after the job is claimed. ## [Example](#example)Copy Anchor Link Start with a job on a GitHub-hosted runner. `ubuntu-latest` is the label GitHub publishes for its default hosted Linux image. Copy Text ``` name: test on: push: branches: [main] jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: make test ``` Now take an organization with three registered self-hosted runners, all online and idle, and work through what each request matches. | Runner | Labels it advertises | | -------- | ---------------------------------- | | runner-a | self-hosted, linux, x64, build | | runner-b | self-hosted, linux, ARM64, build | | runner-c | self-hosted, macOS, ARM64, release | | Value of runs-on | Eligible runners | What happens | | -------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | \[self-hosted, linux\] | runner-a, runner-b | Either machine can claim the job, and the architecture the job gets is whichever one answers first. | | \[self-hosted, linux, x64\] | runner-a | One machine matches, so the job is deterministic and waits if that machine is busy. | | \[self-hosted, build, release\] | none | The two labels live on different machines. No single runner carries both, so nothing matches. | | \[self-hosted, linux, x64, gpu\] | none | gpu is advertised by no runner. | | \[self-hosted, Linux, x64\] | depends on the typed string | A label that differs from the registered string by a character is a different routing key. Copy the label from the runner settings page rather than retyping it. | ### [An unmatched label queues the job with no error](#an-unmatched-label-queues-the-job-with-no-error)Copy Anchor Link The third and fourth rows above are the failure mode worth internalizing. A job whose labels match no runner is not rejected. GitHub accepts the workflow run, creates the job, and leaves it in the queue waiting for a runner that never appears. The run view shows the job as pending with no logs and no annotation naming the missing label. The wait is bounded but long. GitHub cancels a job that has been queued for 24 hours ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). A typo in a label therefore turns into a red run a full day after the pull request was opened, which is why the label string deserves the same review attention as the steps below it. ### [Labels that encode image, architecture, and size](#labels-that-encode-image-architecture-and-size)Copy Anchor Link Managed fleets publish label sets built on the convention described above. The labels below come from the WarpBuild runner catalog, and the table splits each one into the fields the string encodes ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). | Label | Prefix | Image | Architecture | Size segment | | -------------------------------- | ------ | ------------------- | ------------ | ------------ | | warp-ubuntu-latest-x64-4x | warp | ubuntu-latest | x64 | 4x | | warp-ubuntu-2604-arm64-8x | warp | ubuntu-2604 | arm64 | 8x | | warp-macos-26-arm64-12x | warp | macos-26 | arm64 | 12x | | warp-windows-2025-vs2026-x64-16x | warp | windows-2025-vs2026 | x64 | 16x | Two reading rules follow from the scheme. The image field either names a version (`ubuntu-2604`, `macos-26`) or a moving pointer (`ubuntu-latest`), and a moving pointer resolves to whichever version the operator currently publishes behind it, so pinning the version is how a workflow stays on one image across a rollout. The trailing segment names the size class, and the machine behind each size is listed in the documentation rather than derivable from the string. Because architecture is a field in the label, a matrix can fan the same steps across architectures by varying only the label: Copy Text ``` jobs: build: strategy: matrix: runner: - warp-ubuntu-latest-x64-4x - warp-ubuntu-latest-arm64-4x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - run: make build ``` An options suffix rides on the same string. Here the job asks for a specific base machine and passes one option through the label: Copy Text ``` jobs: integration: runs-on: warp-ubuntu-latest-x64-2x;snapshot.key=my-project-snapshot steps: - uses: actions/checkout@v4 - run: ./scripts/integration-test.sh ``` To GitHub that whole value is one label, matched as one unit against the runner's label set. Everything after the semicolon is interpreted by the fleet operator after the job is claimed. ### [Checking a label before blaming the workflow](#checking-a-label-before-blaming-the-workflow)Copy Anchor Link When a job sits in the queue, work through the eligibility filters in the order GitHub applies them. 1. Read the exact label off the runner settings page for the repository, organization, or enterprise, and compare it character by character with the value in `runs-on`. 2. Confirm the runner is registered at a scope the workflow's repository can reach. A repository-scoped runner is invisible to every other repository. 3. Confirm the runner group allows this repository, and check whether the group also restricts which workflows may use it by path, branch, tag, or commit. 4. Confirm at least one runner carrying the full label set is online and idle rather than busy with another job. 5. For an array request, check that a single runner carries every label, since a request spread across two machines never matches. The [runner troubleshooting documentation](/docs/ci/common-issues) walks the same checks for a managed fleet, including runner group access and workflow restrictions. ## [Related Terms](#related-terms)Copy Anchor Link * [How to change the runner in a GitHub Actions workflow](/answers/how-do-i-change-the-runner-in-a-github-actions-workflow): editing `runs-on` safely across a repository, including matrix jobs and reusable workflows. * [Runner group](/glossary/runner-group): the access boundary that decides which repositories may queue jobs on a fleet, and why a correct label still fails without it. * [Build matrix](/glossary/build-matrix): how one job definition expands into many jobs, each carrying its own label. * [Linux x64 runner labels](/runners/linux-x64): the label shapes published for Linux x64 machines and what each field selects. * [Cloud runners documentation](/docs/ci/cloud-runners): the published label catalog with images, architectures, and sizes. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a runner label in GitHub Actions? A runner label is a string attached to a runner when it registers with GitHub. A workflow job lists labels in its runs-on key, and GitHub gives the job to an online runner that advertises every label the job asked for. ### Does a runner label change what a machine can do? No. Labels are opaque routing strings. GitHub does not check that a runner labeled x64 is an x86-64 machine or that a runner labeled large has more cores. Whoever registers the runner picks the strings, so the accuracy of a label is a convention held by the registrar. ### Why is my job queued forever after I changed the label? No online runner in scope advertises every label in runs-on. Common causes are a typo, a runner registered to a different repository, a runner group that excludes the repository, and a request whose labels are spread across two different machines. GitHub cancels a job that waits 24 hours in the queue. ### Can one job request more than one runner label? Yes. The array form of runs-on lists several labels and a runner must carry all of them to be eligible. Labels the runner carries beyond that set are ignored, so a request is a subset test against each runner's label set. --- # Runner Pool | WarpBuild URL: https://www.warpbuild.com/glossary/runner-pool Description: A runner pool is the set of machines that accept GitHub Actions jobs for a given label, plus the rules that create and retire them. How depth sets queue time. # Runner Pool A runner pool is the set of machines that accept GitHub Actions jobs for a given label, plus the rules that create and retire them. How depth sets queue time. A runner pool is the set of machines that can accept a GitHub Actions job for a given label, together with the rules that decide when machines join the set and when they leave it. A queued job runs on whichever member of the matching pool is free, and it waits when every member is busy. Nothing in workflow YAML names a pool. A job names labels through `runs-on`, and the pool is whatever set of online runners advertises those labels at the moment the job is queued. ## [Definition](#definition)Copy Anchor Link Two properties describe a pool: membership and lifecycle policy. Membership comes from labels and registration scope. GitHub routes a queued job by comparing the labels the job asked for against the labels each online runner advertises, and every label in the job must be present on the runner ([workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidruns-on), checked on 2026-08-13). Registration scope decides which repositories may queue onto the pool at all, since a runner is registered against a repository, an organization, or an enterprise. Runner groups apply a second filter on top of scope, restricting a set of runners to named repositories or workflows. Lifecycle policy governs the size of the pool over time: how many machines exist while the queue is empty, how many may exist at peak, how quickly a new machine appears after a job queues, and how long a free machine survives before it is retired. | Property | What it sets | Symptom when it is wrong | | ------------ | -------------------------------------------------- | ------------------------------------------------------------ | | Label set | which jobs the pool can serve | jobs queue while machines in a neighboring pool sit idle | | Scope | which repositories and workflows may queue onto it | a job waits with no logs and no assigned machine | | Warm floor | machines kept running while the queue is empty | the first job after a quiet period waits on machine creation | | Depth | ceiling on machines running at once | the queue grows through peak hours | | Idle timeout | how long a free machine waits before retiring | either paid idle capacity or repeated cold starts | Members are persistent or ephemeral. A persistent member stays registered and takes job after job, so caches, checkouts, and Docker layers from one job are on disk for the next, and state from one job leaks into the next. An ephemeral member starts the agent with `--ephemeral`, accepts exactly one job, then deregisters ([self-hosted runner reference](https://docs.github.com/en/actions/reference/runners/self-hosted-runners), checked on 2026-08-13). Ephemeral membership is what makes an autoscaled pool correct, because each machine has a defined end and the controller can create one machine per queued job. Pools are static or autoscaled. A static pool holds a fixed number of machines that are registered once and left running, so depth and warm floor are the same number. An autoscaled pool watches the job queue and moves the member count between a floor and a ceiling, which a controller such as Actions Runner Controller does by reading queued jobs from GitHub and creating one runner for each. Depth is the number that developers feel. A pool of four machines serves at most four jobs at a time, whatever the workflow file says about matrix width. Splitting one label into two also splits depth, because a static pool of eight machines divided into two pools of four can no longer put eight machines behind a single burst of matching jobs. ## [Example](#example)Copy Anchor Link This workflow fans out to twelve matrix legs, all asking for the same three labels: Copy Text ``` name: integration on: push: branches: [main] jobs: suite: strategy: matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] runs-on: [self-hosted, linux, x64] steps: - uses: actions/checkout@v4 - run: ./scripts/run-shard.sh ${{ matrix.shard }} ``` Every leg is eligible for any runner advertising `self-hosted`, `linux`, and `x64`. If that pool holds four machines and each shard takes five minutes, the twelve legs run in three waves: | Wave | Legs running | Legs still queued | Minutes after the push | | ---- | ------------ | ----------------- | ---------------------- | | 1 | 4 | 8 | 0 to 5 | | 2 | 4 | 4 | 5 to 10 | | 3 | 4 | 0 | 10 to 15 | The last shard starts about ten minutes after the push, and the workflow reports about fifteen minutes of wall clock for five minutes of work. Nothing failed and no step was slow. The burst asked for twelve machines and the pool held four. The queued legs show "Waiting for a runner to pick up this job" and produce no logs. That message reads the same whether the pool is full, the scope excludes the repository, or a label is misspelled, which is why the first check is whether the machines in the pool are actually busy. Idle machines alongside a waiting job point at membership rather than depth. GitHub cancels a job that has waited 24 hours for a self-hosted runner ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), so a label mismatch surfaces as a cancellation a day after the push. Raising depth to twelve collapses the three waves into one and returns the workflow to roughly five minutes. On a static pool that means twelve machines running all day for a burst that arrives on each push. On an autoscaled pool it means twelve machines created when the burst arrives and retired after the idle timeout, with each new machine paying its creation time before the first step runs. ## [Related Terms](#related-terms)Copy Anchor Link * [Autoscaling runners and how a controller sizes a fleet](/glossary/autoscaling-runner): the queue signals a controller reads, and the floor and ceiling it moves between. * [Queue time, the wait between a job being queued and starting](/glossary/queue-time): how the wait is measured and what separates a full pool from a cold start. * [Shortening GitHub Actions queue times](/guides/github-actions-queue-times): where the wait comes from in a real fleet and which lever moves it. * [GitHub workflow syntax reference for runs-on](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidruns-on): the documented label matching behavior that decides pool membership. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner labels, sizes, and images available. * [Runners not picking up jobs](/docs/ci/common-issues): the scope, runner group, and workflow restriction checks to run when a job stays queued. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a runner pool in GitHub Actions? A runner pool is the set of machines that can accept a job carrying a given label, together with the policy that decides when machines are created and when they are retired. Workflow YAML never names a pool. A job names labels, and the pool is whatever set of online runners advertises those labels when the job is queued. ### Is a runner pool the same as a runner group? They answer different questions. A runner group is a GitHub access control object that decides which repositories and workflows may queue work onto a set of runners. A pool describes capacity: which machines exist, how many can exist at once, and how they are created and retired. One group can hold several pools, and one pool can be reachable through a group. ### How many machines should a runner pool hold? Size the ceiling against the widest burst you want served without waiting. Take the peak count of jobs that carry the pool's label at the same moment over a busy week, and set the maximum at least that high. Set the warm floor against the arrival pattern, since a floor of zero means the first job of the day pays for machine creation. --- # Runner Registration Token | WarpBuild URL: https://www.warpbuild.com/glossary/runner-registration-token Description: A runner registration token is the short lived credential a self-hosted runner presents to GitHub to join a repository, organization, or enterprise pool. # Runner Registration Token A runner registration token is the short lived credential a self-hosted runner presents to GitHub to join a repository, organization, or enterprise pool. A runner registration token is a short lived credential that a self-hosted runner presents to GitHub so that it can join a repository, organization, or enterprise runner pool. GitHub mints the token, the runner uses it once during configuration, and it expires one hour after it was created ([GitHub REST reference for self-hosted runners](https://docs.github.com/en/rest/actions/self-hosted-runners), checked on 2026-08-13). Most confusion about registration tokens traces back to that one hour window. A token pasted into a machine image, a Terraform variable, or a runbook stops working before anyone gets around to using it, which is why registration is normally scripted to fetch a new token at the moment a machine boots. ## [Definition](#definition)Copy Anchor Link A registration token authorizes exactly one operation: adding a runner to a pool. It grants no ability to read source code, push commits, or approve a deployment. What it grants is membership, and membership is what makes a machine eligible to receive queued jobs. Three properties describe the credential: * **Scoped.** A token is minted for one registration scope. That scope decides which workflows are allowed to queue jobs onto the runner once it has joined. * **Short lived.** The token expires one hour after creation. Expiry is absolute rather than idle based, so a token that has been sitting unused for 61 minutes is already dead. * **Single use in practice.** The config script trades the token for credentials the agent stores in its own directory, and every later connection uses those stored credentials. Once a runner is online, the registration token has no further role. ### [Where the token comes from](#where-the-token-comes-from)Copy Anchor Link GitHub prints a fresh token into the setup commands shown under Settings, Actions, Runners in the web UI. The same token is available from the REST API, which is the path automation uses. Endpoints and permissions below are from the [GitHub REST reference for self-hosted runners](https://docs.github.com/en/rest/actions/self-hosted-runners), checked on 2026-08-13. | Registration scope | REST endpoint (POST) | Permission required | Jobs the runner becomes eligible for | | ------------------ | ------------------------------------------------------------ | ---------------------------------------------- | ------------------------------------------------------ | | Repository | /repos/{owner}/{repo}/actions/runners/registration-token | Admin on the repository | Workflows in that one repository | | Organization | /orgs/{org}/actions/runners/registration-token | Organization admin, admin:org scope on a token | Workflows in the repositories the runner group allows | | Enterprise | /enterprises/{enterprise}/actions/runners/registration-token | Enterprise admin | Workflows in the organizations the runner group allows | Each scope has a matching remove token endpoint at the same path with `remove-token` in place of `registration-token`. That second credential is what a deregistration step presents when a machine leaves the pool, and it follows the same one hour expiry. ### [Credentials that get confused with it](#credentials-that-get-confused-with-it)Copy Anchor Link Three other credentials show up in the same workflows and do different jobs. `GITHUB_TOKEN` is the installation token GitHub injects into a running job. Its lifetime is the job, its permissions are set by the workflow, and it plays no part in registering a machine. A personal access token or a GitHub App installation token is the long lived operator credential that authenticates the API call which mints a registration token. It sits on the side that creates runners rather than on the runner itself, which is why autoscaling controllers keep it in a secrets manager and never copy it onto a runner host. The agent's stored credentials are written during configuration and live in the runner's own working directory. They are the reason a runner survives a reboot without anyone reissuing a registration token. ### [Why the expiry is so short](#why-the-expiry-is-so-short)Copy Anchor Link Whoever holds a valid registration token can attach a machine to the pool, and a machine in the pool can be handed jobs that carry repository secrets and deployment credentials. A one hour window caps how long a leaked token stays useful and rules out the pattern of baking a token into a golden image or a configuration management repository. The same reasoning produced a second path. The just-in-time endpoints at `/actions/runners/generate-jitconfig` return an encoded configuration for a single ephemeral runner, which the agent consumes at startup, so no registration token is ever written to the machine. Autoscalers pick one of these two paths: mint a registration token per boot, or mint a just-in-time config per queued job. ## [Example](#example)Copy Anchor Link Registering a self-hosted runner is two steps. First, mint a token with an operator credential from somewhere other than the runner: Copy Text ``` RUNNER_TOKEN=$(curl -sS -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer $GITHUB_ADMIN_TOKEN" \ https://api.github.com/orgs/$GITHUB_ORG/actions/runners/registration-token \ | jq -r .token) ``` Second, hand that token to the runner agent's config script on the machine itself: Copy Text ``` ./config.sh \ --url https://github.com/$GITHUB_ORG \ --token "$RUNNER_TOKEN" \ --labels self-hosted,linux,x64,build \ --unattended \ --ephemeral ./run.sh ``` The two steps have to happen within the hour. In an autoscaling group the gap is seconds, because the boot script calls the API and then the config script back to back. In a manual setup the gap is however long it takes to copy a token into a terminal, which is where the expiry usually bites. After `config.sh` succeeds, the token has done its work. The runner appears in the pool advertising the four labels it registered with, and a workflow reaches it by naming those labels: Copy Text ``` name: build on: push: branches: [main] jobs: package: runs-on: [self-hosted, linux, x64, build] steps: - uses: actions/checkout@v4 - run: make package ``` No token appears anywhere in that workflow. Routing after registration is a label match, and the registration token is invisible to the jobs it made possible. Because `--ephemeral` was passed, the agent accepts one job, deregisters itself, and exits. The next machine in the autoscaling group repeats the whole sequence with a token of its own, which is the reason a fleet fetches thousands of registration tokens a month and stores none of them. ## [Related Terms](#related-terms)Copy Anchor Link * [Self-hosted runners and what registering one commits you to](/glossary/self-hosted-runner): the machine on the other end of the registration call, and the provisioning, patching, and scaling work that comes with it. * [Runner pools and how a registered machine joins one](/glossary/runner-pool): what the token buys membership in, and how pool membership decides which jobs reach a machine. * [What a managed runner provider creates inside your cloud account](/answers/what-does-warpbuild-create-in-my-cloud-account): the resources involved when registration is automated on your behalf. * [WarpBuild runner security documentation](/docs/ci/security): how runner isolation, storage, and secrets handling are documented. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner labels available and how a workflow selects them. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### How long does a runner registration token last? One hour. GitHub's REST reference for self-hosted runners states that the token expires after one hour, which applies to the repository, organization, and enterprise endpoints alike. A token minted at 09:00 is useless at 10:01, so registration scripts fetch a fresh token at the moment a machine boots. ### Where does a runner registration token come from? GitHub mints it. The web UI prints a fresh token into the setup commands under Settings, Actions, Runners, and the same token is available from the REST API at POST /repos/{owner}/{repo}/actions/runners/registration-token, POST /orgs/{org}/actions/runners/registration-token, or the enterprise equivalent. Minting one requires admin permission at that scope. ### Does the runner keep using the registration token after setup? No. The config script exchanges the registration token for credentials that the agent stores in its own directory, and every later connection to GitHub uses those stored credentials. The registration token is presented once and then expires on its own schedule. --- # Runner Utilization | WarpBuild URL: https://www.warpbuild.com/glossary/runner-utilization Description: Runner utilization is the share of a runner's CPU, memory, disk, and network that a job actually uses while it holds the machine, and how to measure it. # Runner Utilization Runner utilization is the share of a runner's CPU, memory, disk, and network that a job actually uses while it holds the machine, and how to measure it. Runner utilization is the share of a runner's provisioned resources that a job actually consumes while it holds the machine, measured per resource across the life of the job: CPU, memory, filesystem space, disk throughput, and network throughput. Each figure is a percentage of what was allocated, so a job can pass every step while using a small fraction of the machine it reserved. The number matters in GitHub Actions because a runner is held for the whole job. One runner runs one job at a time and stays occupied from the first step to the last, so the machine is paid for and unavailable for its full wall clock duration whether a step saturates it or leaves it idle. ## [Definition](#definition)Copy Anchor Link Utilization is defined per resource, and each resource has its own ceiling. **CPU utilization** is consumed CPU time divided by available CPU time over the same interval. A 16 vCPU machine makes 16 CPU-seconds available for every second of wall clock, so a process burning 4 CPU-seconds per second sits at 25 percent. Values above 100 percent for a single process are possible in tools that report per-core figures, which is why the denominator has to be stated. **Memory utilization** is peak resident memory divided by provisioned memory. Peak is the figure that matters, because the process that gets killed is the one that asked for a page when none was free. **Filesystem utilization** is peak bytes used on the volume divided by volume size. It is the one dimension where the ceiling produces an immediate hard failure rather than a slowdown. **Disk and network utilization** are throughput measures. Both are read against the throughput the underlying volume or instance supports, so the same megabytes per second reads as saturation on one shape and as an idle link on another. ### [Peak, sustained, and mean](#peak-sustained-and-mean)Copy Anchor Link A single percentage is ambiguous until the statistic behind it is named, and three statistics are in common use. | Statistic | What it answers | What it hides | | ---------------------- | ------------------------------------------------------- | ---------------------------------------------------------- | | Peak | Did any instant hit the ceiling | A one second spike reads the same as a five minute plateau | | Sustained | Did a rolling window average stay near the ceiling | The length of the window has to be quoted with the number | | Mean over job duration | How much of the reserved capacity the job used in total | A saturated step averaged against a long idle tail | Peak alone justifies a bigger machine too easily, because every dependency install spikes. Mean alone justifies a smaller machine too easily, because it averages the one step that needed the cores against the steps that did not. Reading the pair together is what makes the number actionable. ### [Where the raw numbers come from](#where-the-raw-numbers-come-from)Copy Anchor Link Utilization is measured on the runner by sampling operating system counters while the job runs. On Linux the counters are cumulative and live in the kernel interfaces: `/proc/stat` for CPU time, `/proc/meminfo` for memory, and the cgroup v2 files for a job confined to a slice, where `cpu.stat` reports `usage_usec` and `memory.peak` records the maximum memory usage recorded for the cgroup ([cgroup v2 kernel documentation](https://docs.kernel.org/admin-guide/cgroup-v2.html), checked on 2026-08-13). Utilization is derived from those counters by dividing a delta by the elapsed interval and the allocated capacity. A single step can be measured without any agent. GNU time reports both halves of the picture for one command, printing "Percent of CPU this job got" and "Maximum resident set size" under the verbose flag ([GNU time](https://www.gnu.org/software/time/), checked on 2026-08-13). That percentage is relative to one core, so a value near 100 on a multi-core machine describes a single-threaded workload. ## [Example](#example)Copy Anchor Link This job packages a release. It asks for a 16 vCPU machine because one step compiles in parallel, and it then holds that machine through a step that can only ever use one core. Copy Text ``` name: release on: push: tags: ['v*'] jobs: package: runs-on: warp-ubuntu-latest-x64-16x steps: - uses: actions/checkout@v4 - run: /usr/bin/time -v make -j"$(nproc)" bundle - run: gzip -9 dist/bundle.tar - run: ./scripts/upload.sh dist/bundle.tar.gz ``` The `gzip` step is the one that sets the shape of the whole reading. GNU gzip compresses a single stream on a single thread, so it occupies one of the sixteen vCPUs while the other fifteen stay idle: 1 divided by 16, or 6.25 percent CPU utilization for as long as that step runs. Put a duration against each step and the job-level numbers fall out of arithmetic. | Step | Duration | Cores busy | CPU utilization of 16 vCPU | | ---------------- | -------- | ---------- | -------------------------- | | actions/checkout | 0.5 min | about 1 | 6 percent | | make -j16 bundle | 4 min | about 12 | 75 percent | | gzip -9 | 5 min | 1 | 6.25 percent | | upload.sh | 0.5 min | under 1 | under 5 percent | The job holds the machine for 10 minutes, which reserves 160 vCPU-minutes (16 vCPU multiplied by 10 minutes). The steps consume roughly 0.5 + 48 + 5 + 0.1, or 53.6 vCPU-minutes. Mean CPU utilization is therefore about 34 percent against a peak sustained figure of 75 percent, and half the wall clock is spent at 6.25 percent. Memory tells a separate story on the same run. If the compile peaks at 3.1 GB resident on a machine with 64 GB, peak memory utilization is under 5 percent, and no amount of CPU tuning moves that number. ### [Reading the two numbers together](#reading-the-two-numbers-together)Copy Anchor Link The gap between 75 percent peak and 34 percent mean is the actionable part. It says the machine was sized for one step and paid for during three, so there are two directions to take: 1. Cut the wall clock of the single-threaded step, for example by replacing `gzip -9` with a parallel compressor, which raises the mean without changing the size. 2. Split the job in two, so the parallel compile runs on the large machine and the packaging and upload run on a small one. Both moves are visible in the same measurement afterwards, which is why utilization is worth recording per job rather than sampled by hand. Running the same job on an 8 vCPU machine instead shows the other side: the compile step stretches to about 6 minutes while the single-threaded step stays at 5, so the job runs about 12 minutes and mean CPU utilization rises to roughly 56 percent of a smaller reservation. One caution on interpretation. Utilization describes a single runner while it holds a job. It leaves out how long the job waited in the queue before it started, and how many runners in a fleet were busy at the same moment. Those are separate measurements with separate fixes. ## [Related Terms](#related-terms)Copy Anchor Link * [Right sizing GitHub Actions runners](/guides/runner-right-sizing-github-actions): how to turn measured peak and mean utilization into a runner size decision, step by step. * [vCPU](/glossary/vcpu): what one virtual processor is, and why the count sets the parallelism of build tools that read it at startup. * [How to detect an undersized GitHub Actions runner](/answers/how-do-i-detect-an-undersized-runner): the symptoms that separate a machine that is too small from a workflow that is badly ordered. * [Runner observability documentation](/docs/ci/features/observability): where per-job CPU, memory, filesystem, disk IO, and network utilization are reported. * [Job reports documentation](/docs/ci/features/reports): per-job duration, queue time, CPU, and memory at the 75th and 90th percentiles across runs. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is runner utilization? Runner utilization is the share of a runner's provisioned resources that a job consumes while it holds the machine, reported per resource: CPU, memory, filesystem, disk throughput, and network. Each figure is a percentage of what was allocated, measured across the life of the job. ### What is a good CPU utilization number for a GitHub Actions job? There is no single target, because the useful reading is the gap between peak and mean. A job whose peak sustained CPU sits near the ceiling is limited by the machine, and a job whose mean stays low for most of its wall clock is holding cores that no step can use. Both readings point at a size change rather than a code change. ### Why is my job slow when CPU utilization is low? Low CPU utilization means the bottleneck lives somewhere else. The usual candidates are a single-threaded step that can only occupy one core, a step waiting on network transfer such as a dependency download or an image pull, and a step waiting on disk throughput. Memory pressure produces the same shape once swapping starts. --- # runs-on | WarpBuild URL: https://www.warpbuild.com/glossary/runs-on Description: runs-on is the GitHub Actions job key that selects which runner picks the job up, by a single label, a list of labels, or a runner group object. # runs-on runs-on is the GitHub Actions job key that selects which runner picks the job up, by a single label, a list of labels, or a runner group object. `runs-on` is the key inside a GitHub Actions job that selects which runner picks that job up. It accepts a single label, a list of labels that one runner must all carry, or an object naming a runner group with labels to filter inside that group. The value is a routing key rather than a machine specification. GitHub compares what the job asked for against the labels each online runner advertises, hands the job to one runner that satisfies the request, and leaves the job queued when nothing matches. ## [Definition](#definition)Copy Anchor Link `runs-on` sits at `jobs..runs-on` and is required for every job that runs steps. A job that calls a reusable workflow through `uses` omits the key, because the called workflow declares runners for its own jobs. GitHub documents the key in the [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidruns-on), checked on 2026-08-13. ### [The three shapes](#the-three-shapes)Copy Anchor Link | Shape | YAML | What it selects | | ------ | -------------------------------------------------------------- | ------------------------------------------------------------------- | | String | runs-on: ubuntu-latest | any online runner advertising that one label | | Array | runs-on: \[self-hosted, linux, x64\] | any online runner advertising every label in the list | | Object | runs-on: { group: production-runners, labels: \[linux, x64\] } | a runner inside the named group that also carries the listed labels | The object form is the only shape that reaches a runner group by name. Groups are an organization-level or enterprise-level grouping around a fleet, and the group itself controls which repositories and workflows may queue work onto it. ### [How matching works](#how-matching-works)Copy Anchor Link Matching is a subset test in one direction. Every label written in `runs-on` must appear on the runner. Labels the runner carries beyond that set are ignored. | Job selector | Labels the runner advertises | Result | | ------------------------------- | ------------------------------------- | --------------- | | \[self-hosted, linux\] | self-hosted, linux, x64, ubuntu-24.04 | eligible | | \[self-hosted, linux, x64\] | self-hosted, linux, arm64 | skipped, no x64 | | \[self-hosted, linux, x64, db\] | self-hosted, linux, x64 | skipped, no db | | ubuntu-latest | ubuntu-latest on a hosted pool | eligible | Two consequences follow. Each label added to the array narrows the set of runners that can serve the job, so a longer selector is a stricter filter. And the array behaves as an AND with no OR form available, so a job that should accept either of two fleets uses an expression that resolves to one label rather than a list. Labels themselves are opaque strings. GitHub does not verify that a runner labeled `x64` is an x86-64 machine or that a runner labeled `large` has more than two cores. Whoever registers the runner chooses what it advertises, and keeping those strings honest is the registrar's job. The reserved labels such as `ubuntu-latest`, `windows-latest`, and `macos-latest` are the exception, since they point at [GitHub's own hosted pools](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) and cannot be claimed by a self-hosted runner. A registered self-hosted runner automatically advertises `self-hosted` plus its operating system and architecture, along with any custom labels supplied at registration. ### [Expressions in the value](#expressions-in-the-value)Copy Anchor Link `runs-on` accepts expressions, and they resolve before the job starts. `${{ matrix.runner }}`, `${{ inputs.runner }}`, and `${{ vars.DEFAULT_RUNNER }}` are all valid, which is how one workflow file targets different fleets per branch, per input, or per matrix leg. Because the expression resolves to an ordinary string, a misspelled label is never a syntax error. The job is accepted, queued, and then waits, showing no logs and no failure. GitHub cancels a job that has been queued for 24 hours waiting on a self-hosted runner ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), so a label mismatch reports as a cancellation a day after the push. ## [Example](#example)Copy Anchor Link This job asks for a GitHub-hosted Linux machine using the reserved label: Copy Text ``` name: test on: push: branches: [main] jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test ``` Sending the same job to a different fleet is a one line edit. The steps, the trigger, and the action versions stay where they are: Copy Text ``` jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test ``` The job now waits for a runner advertising `warp-ubuntu-latest-x64-4x` instead of one from GitHub's hosted Linux pool. Labels of that shape pack the operating system, the architecture, and the machine size into one string, and the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners) lists the full set of them. ### [Combining several labels into one selector](#combining-several-labels-into-one-selector)Copy Anchor Link The array form composes labels into a single selector. Each entry is a condition the runner must satisfy: Copy Text ``` jobs: integration: runs-on: [self-hosted, linux, x64, db] steps: - uses: actions/checkout@v4 - run: ./scripts/integration-test.sh ``` `self-hosted` restricts the job to registered runners, `linux` and `x64` pin the platform, and `db` picks out the subset of that fleet prepared with a local database. Drop `db` and the job becomes eligible for every Linux x64 runner in scope. Add a fifth label and the eligible set shrinks again, down to zero if no runner carries all five. The object form applies the same subset test inside a named group: Copy Text ``` jobs: deploy: runs-on: group: production-runners labels: [linux, x64] ``` An expression covers the case where the fleet changes per matrix leg, since the label is resolved per job: Copy Text ``` jobs: build: strategy: matrix: runner: [warp-ubuntu-latest-x64-4x, warp-ubuntu-latest-arm64-4x] runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - run: make build ``` ## [Related Terms](#related-terms)Copy Anchor Link * [Runner labels and how a runner advertises them](/glossary/runner-label): where labels come from at registration and what makes a label set readable. * [How to change the runner in a GitHub Actions workflow](/answers/how-do-i-change-the-runner-in-a-github-actions-workflow): the edit to make, and what to check when the job stays queued afterwards. * [A labeling strategy for GitHub Actions runner fleets](/guides/runner-labels-strategy-github-actions): naming schemes that survive a growing fleet and a growing matrix. * [GitHub workflow syntax reference for runs-on](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidruns-on): the documented behavior of the string, array, and group forms. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner labels available, with sizes and images. * [WarpBuild quick start](/docs/ci/quick-start): pointing a first workflow at a managed runner fleet. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What does runs-on mean in GitHub Actions? runs-on is the key inside a job that selects which runner executes that job. It holds a label, a list of labels the runner must all carry, or an object naming a runner group plus labels to filter inside it. GitHub routes the queued job to an online runner that advertises everything the key asked for. ### Can runs-on take more than one label? Yes, as a YAML array. Every label in the array must be present on the runner, so each entry narrows the eligible set. The array behaves as an AND. There is no OR form, so a job that should accept either of two labels uses an expression that resolves to one label instead. ### What happens when no runner matches runs-on? The job stays queued with no logs, because GitHub never assigns it. A typo in a label produces the same symptom as an empty fleet. GitHub cancels a job that has waited 24 hours for a self-hosted runner, so a mismatch surfaces as a silent failure a day later. --- # SBOM (Software Bill of Materials) | WarpBuild URL: https://www.warpbuild.com/glossary/sbom Description: An SBOM is a machine-readable inventory of the components and versions inside a build output. What the formats record, and how a workflow generates one. # SBOM (Software Bill of Materials) An SBOM is a machine-readable inventory of the components and versions inside a build output. What the formats record, and how a workflow generates one. A software bill of materials, usually written SBOM, is a machine-readable inventory of the components and versions that went into a build output, together with the dependency relationships between them. It answers one question about an artifact that has already shipped: what is inside it, at which version, and under which identifier. The value of that inventory shows up on the day an advisory lands for a widely used library. With an SBOM stored per release, finding affected artifacts is a query over documents you already have. Without one, the same question means rebuilding every release from source and hoping the dependency resolution reproduces. ## [Definition](#definition)Copy Anchor Link An SBOM is a document produced by tooling during or after a build. Each entry names a component, pins a version, and carries identifiers that other systems can match against. The common identifier is a package URL, or purl, such as `pkg:npm/lodash@4.17.21`, which encodes the ecosystem, the name, and the version in one string ([package URL specification](https://github.com/package-url/purl-spec), checked on 2026-08-13). Entries reference each other through dependency relationships, so the document describes a graph rather than a flat list. A baseline for what the document has to carry comes from the US National Telecommunications and Information Administration, which published a set of minimum elements in 2021 ([minimum elements for an SBOM](https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom), checked on 2026-08-13). | Minimum element | What it records | | ------------------------ | ---------------------------------------------- | | Supplier name | The party that produced each component | | Component name | The name each component is published under | | Version of the component | The exact version string, rather than a range | | Other unique identifiers | Keys such as purl or CPE that support lookup | | Dependency relationship | Which component includes which other component | | Author of SBOM data | The tool or party that assembled the document | | Timestamp | When the document was assembled | ### [The two formats in common use](#the-two-formats-in-common-use)Copy Anchor Link Two specifications carry most of the traffic, and both are standardized. | Format | Steward | Standardization | Usual suffix | Serializations | | --------- | ---------------- | -------------------------------------------- | ------------ | -------------------------- | | SPDX | Linux Foundation | Version 2.2.1 published as ISO/IEC 5962:2021 | .spdx.json | JSON, YAML, RDF, tag-value | | CycloneDX | OWASP | Version 1.6 published as ECMA-424 | .cdx.json | JSON, XML, protobuf | The [SPDX specification](https://spdx.dev/use/specifications/) and the [CycloneDX specification](https://cyclonedx.org/specification/overview/) (both checked on 2026-08-13) describe overlapping shapes: a document header, a component list, and relationships. Most generators can write either format from a single scan, so the choice is usually driven by what the consuming system parses. Converting between them is possible and lossy, because each format carries fields the other has no slot for. ### [When the inventory is taken](#when-the-inventory-is-taken)Copy Anchor Link An SBOM describes the components a tool could see at the moment it ran, so the point in the pipeline where it is generated changes what ends up in the document. The US Cybersecurity and Infrastructure Security Agency names six types by that generation point ([types of SBOM documents](https://www.cisa.gov/resources-tools/resources/types-software-bill-material-sbom-documents), checked on 2026-08-13). | Type | Generated from | Typically misses | | -------- | ------------------------------------------------ | ------------------------------------------------ | | Design | Planned components, before code exists | Anything the implementation actually pulled in | | Source | The repository and its dependency manifests | Components injected by the build itself | | Build | The build process, as the artifact is assembled | Files added later by a deployment step | | Analyzed | Inspection of the finished artifact | Provenance for components with stripped metadata | | Deployed | The system as installed, including configuration | Code loaded only while the process runs | | Runtime | Observation of the running system | Components on paths the observation never hit | The build SBOM is the one a workflow produces, and it is the strongest of the six for release records, because the tool runs with both the source tree and the finished artifact in reach. Two limits are worth stating plainly. An SBOM records inventory and carries no judgement about severity, so risk ranking happens later when a scanner joins the component list against advisory data, and the quality of that join depends on how well the identifiers resolve. An SBOM also says nothing about how the artifact was built or by whom; that record is a separate document, covered in [provenance attestation](/glossary/provenance-attestation). ## [Example](#example)Copy Anchor Link This release workflow builds a container image, generates an SBOM in SPDX JSON form from the pushed image, publishes the document as an attestation bound to the image digest, and attaches the same file to the GitHub release as a downloadable asset. Copy Text ``` name: release on: push: tags: ["v*"] permissions: contents: write packages: write id-token: write attestations: write jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: push uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }} - uses: anchore/sbom-action@v0 with: image: ghcr.io/${{ github.repository }}@${{ steps.push.outputs.digest }} format: spdx-json output-file: sbom.spdx.json - uses: actions/attest-sbom@v2 with: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.push.outputs.digest }} sbom-path: sbom.spdx.json push-to-registry: true - run: gh release upload "${{ github.ref_name }}" sbom.spdx.json env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` Three details in that file do the work. The scan targets the digest in `steps.push.outputs.digest` rather than the tag, so the inventory describes the exact manifest this run pushed even if the tag moves afterwards. The `format: spdx-json` input selects the serialization, and the generator writes CycloneDX from the same scan when the value is `cyclonedx-json` ([anchore/sbom-action](https://github.com/anchore/sbom-action), checked on 2026-08-13). The attestation step binds the document to that digest and to the workflow identity that produced it ([actions/attest-sbom](https://github.com/actions/attest-sbom), checked on 2026-08-13), while `gh release upload` leaves a copy a human can download without registry credentials. A consumer verifies the attested copy with the GitHub CLI, which resolves the image and checks the signature against the owner you name: Copy Text ``` gh attestation verify oci://ghcr.io/acme/api@sha256: --owner acme ``` BuildKit can also produce the document during the build instead of scanning the result afterwards, which turns the whole thing into one flag on the build command ([Docker SBOM attestations](https://docs.docker.com/build/metadata/attestations/sbom/), checked on 2026-08-13): Copy Text ``` docker buildx build --sbom=true --provenance=mode=max --push \ -t ghcr.io/acme/api:v1.4.0 . ``` That form stores the SBOM in the registry beside the image rather than as a file in the job workspace, so nothing needs uploading. Pointing the same command at a remote builder is a builder configuration question, covered in the [WarpBuild Docker builders documentation](/docs/ci/docker-builders). The three destinations behave differently once the run is over. | Destination | Retrieved with | Survives a tag move | Available without registry access | | -------------------- | ------------------------------------------------------------------ | ---------------------------------------- | ------------------------------------- | | Release asset | A browser or gh release download | Yes, the file is immutable once uploaded | Yes | | Registry attestation | gh attestation verify, cosign, or docker buildx imagetools inspect | Yes, the subject is a digest | No | | Workflow artifact | The run page, until retention expires | Yes | Yes, for users with repository access | ## [Related Terms](#related-terms)Copy Anchor Link * [Provenance attestations and the build facts they record](/glossary/provenance-attestation): the signed statement about who built an artifact and from which source, which sits beside the inventory. * [How to generate an SBOM during a GitHub Actions build](/answers/how-do-i-generate-an-sbom-during-a-github-actions-build): the step-level recipe, including where the file lands and how it is verified. * [Running security scanning jobs in GitHub Actions](/guides/security-scanning-jobs-github-actions): where scanning fits in a pipeline and how the job is kept off the critical path. * [Image digests and why a subject is pinned to one](/glossary/image-digest): the content address an attestation binds to, and how it differs from a tag. * [WarpBuild security documentation](/docs/ci/security): compute isolation, storage handling, and compliance posture for runners. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between an SBOM and a vulnerability scan? An SBOM is the inventory: the components, their versions, and the identifiers that name them. A vulnerability scan is a join between that inventory and an advisory database, performed at scan time. The same SBOM produces different findings a month later, because the advisory data moved while the artifact stayed the same. ### Should a build emit SPDX or CycloneDX? Emit whichever format the consumers of the document accept, and emit both when they disagree. SPDX 2.2.1 was published as ISO/IEC 5962:2021 and CycloneDX 1.6 was published as ECMA-424, so both are standardized and most generators can write either one from a single scan. Converters exist, and they lose fields that have no counterpart in the target format. ### Does an SBOM need to be signed? An unsigned SBOM is a JSON file that anyone with write access to the release can replace. Signing it, or publishing it as an attestation bound to the artifact digest, gives a consumer something to verify: that this document describes this exact artifact and was produced by the identity it claims. --- # Security Group | WarpBuild URL: https://www.warpbuild.com/glossary/security-group Description: A security group is a stateful firewall attached to a cloud instance, holding allow rules for inbound and outbound traffic. How runner rules are written. # Security Group A security group is a stateful firewall attached to a cloud instance, holding allow rules for inbound and outbound traffic. How runner rules are written. A security group is a stateful firewall attached to a cloud instance, holding a list of allow rules for traffic arriving at the instance and a second list for traffic leaving it. Traffic matching no rule is dropped, and because the filter tracks connections, the response to a connection the rules already allowed is admitted without a matching rule in the other direction. For a machine that runs GitHub Actions jobs, that statefulness moves nearly all of the interesting configuration to the outbound list. The runner agent dials out and waits for work, so the machine can carry zero inbound rules and still pick up jobs. ## [Definition](#definition)Copy Anchor Link Two things define a security group: the network interfaces it is attached to, and its rules. Attachment happens at the interface, so two instances in the same subnet can carry entirely different rule sets, which is what separates a security group from a subnet-wide filter. Each rule is a small record. The fields are the same whichever cloud is underneath, even where the names differ: | Field | What it holds | Example value | | --------------------- | -------------------------------------------------------------- | ----------------------------- | | Direction | inbound or outbound, kept as separate lists | outbound | | Protocol | tcp, udp, icmp, or all protocols | tcp | | Port range | one port or a range, meaningful for TCP and UDP | 443 | | Source or destination | a CIDR block, another security group, or a managed prefix list | 10.0.0.0/16 | | Description | free text carried with the rule for audit | "container registry endpoint" | Five properties follow from that shape, and together they explain most of the behavior people find surprising. **Rules only allow.** There is no deny rule in the AWS model. The default action is to drop, and every rule you write carves an exception out of that default. Narrowing access means removing rules rather than adding blocking ones ([AWS security group reference](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-security-groups.html), checked on 2026-08-13). **Evaluation is a union and order does not matter.** Several groups can attach to one interface, and the effective policy is every rule from every attached group merged into one set. A permissive rule anywhere in that union wins, so an audit has to read all of the attached groups rather than the one with the relevant name. **The filter is stateful.** A connection that an outbound rule permits gets its return packets admitted automatically, on whatever ephemeral port the kernel chose. This is why an outbound-only group is a working configuration rather than a half-written one. **A fresh group starts closed inbound and open outbound.** A newly created group has no inbound rules and a single outbound rule permitting all traffic. The starting posture is already correct for a runner on the inbound side, and the tightening work is on the outbound side. **A rule can name another group instead of an address range.** Using a group ID as the source is how "any instance carrying this role" is expressed without knowing addresses in advance, which matters when instances are created and destroyed per job and never hold a stable IP. ### [Security groups compared with network ACLs](#security-groups-compared-with-network-acls)Copy Anchor Link The two mechanisms sit at different layers and are often confused, because both look like lists of network rules. | Property | Security group | Network ACL | | ---------------- | ----------------------------------------- | -------------------------------------------- | | Attaches to | a network interface | a subnet | | Rule types | allow only | allow and deny | | Evaluation | all rules as one union, order independent | in rule-number order, first match wins | | Connection state | stateful, return traffic admitted | stateless, return traffic needs its own rule | | Typical use | per-instance policy | coarse boundary for everything in a subnet | The stateless column is where mistakes concentrate. An ACL that permits outbound HTTPS on port 443 and nothing else silently breaks every connection, because the reply arrives on an ephemeral high port that no inbound ACL rule covers ([AWS network ACL reference](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html), checked on 2026-08-13). ### [The same idea across clouds](#the-same-idea-across-clouds)Copy Anchor Link The term "security group" comes from the AWS and OpenStack model. The equivalents elsewhere carry the same per-instance intent with different rule semantics, which is worth knowing before a policy written for one cloud is translated to another. | Cloud | Name | Attaches to | Deny rules | Reference | | ------------ | ---------------------- | ----------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | | AWS | security group | network interface | none, allow only | [Security groups](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-security-groups.html) | | Azure | network security group | subnet or network interface | yes, with numeric priorities | [Network security groups](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview) | | Google Cloud | VPC firewall rule | the network, targeted by tag or service account | yes, with numeric priorities | [VPC firewall rules](https://cloud.google.com/firewall/docs/firewalls) | ## [Example](#example)Copy Anchor Link Take a GitHub Actions job that checks out a repository, installs dependencies, builds a container image, and pushes it to a registry. The job runs on a self-hosted runner in a private subnet. Copy Text ``` name: build-and-push on: push: branches: [main] jobs: image: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Install dependencies run: npm ci - name: Build the image run: docker build -t "$REGISTRY/app:$GITHUB_SHA" . - name: Push the image run: docker push "$REGISTRY/app:$GITHUB_SHA" ``` Reading the steps top to bottom gives the destination list the security group has to permit. `actions/checkout` and the runner agent itself talk to GitHub over HTTPS. `npm ci` reaches the package registry. `docker push` reaches the container registry. Nothing in the job accepts a connection from anywhere. So the group attached to the runner looks like this: | Direction | Protocol | Port | Destination | Why | | --------- | -------- | ---- | --------------------------- | ------------------------------------------------------------ | | Inbound | none | none | none | the job never accepts a connection | | Outbound | tcp | 443 | GitHub HTTPS endpoints | agent registration, job polling, checkout, log upload | | Outbound | tcp | 443 | package registry endpoints | dependency installation | | Outbound | tcp | 443 | container registry endpoint | image pull and push | | Outbound | tcp | 443 | object storage prefix list | cache and artifact traffic, kept off general internet egress | Written as Terraform, the empty inbound list is the whole point of the resource: Copy Text ``` resource "aws_security_group" "runner" { name = "actions-runner" description = "GitHub Actions runners: no inbound, narrowed egress" vpc_id = var.vpc_id # no ingress blocks: nothing may open a connection to a runner } resource "aws_vpc_security_group_egress_rule" "github" { security_group_id = aws_security_group.runner.id description = "GitHub API, git over HTTPS, and agent long poll" ip_protocol = "tcp" from_port = 443 to_port = 443 cidr_ipv4 = "0.0.0.0/0" } resource "aws_vpc_security_group_egress_rule" "s3_gateway" { security_group_id = aws_security_group.runner.id description = "Object storage traffic, routed via the S3 prefix list" ip_protocol = "tcp" from_port = 443 to_port = 443 prefix_list_id = data.aws_prefix_list.s3.id } ``` Three details in that snippet are worth calling out. The absence of an ingress block is the strongest rule on the machine. A group with no inbound rules drops every unsolicited packet, and the job still runs, because the agent's outbound connection carries its replies back under the stateful rule. The prefix list on the second egress rule is how an address range that the cloud provider owns and updates gets referenced without hardcoding CIDRs. It keeps object storage traffic on the provider's internal network instead of routing it out through the path used for general internet egress. The `0.0.0.0/0` destination on the first rule is the honest starting point rather than the finished state. Narrowing it by CIDR is fragile, because package registries and container registries sit behind content delivery networks whose address ranges rotate. The durable ways to tighten egress are an outbound proxy that filters by hostname, a firewall that resolves domain rules, or private endpoints for the services that offer them. Each rule keeps its `description`, so a later reviewer can tell which build step a destination belongs to. ## [Related Terms](#related-terms)Copy Anchor Link * [Networking requirements for GitHub Actions runners in your own AWS account](/byoc/aws/networking): subnets, outbound address translation, and the address capacity a runner fleet needs. * [Restricting outbound traffic from GitHub Actions runners](/byoc/outbound-traffic-controls): the destinations a build needs, and the proxy and endpoint patterns for narrowing egress past a CIDR rule. * [VPC, the private network a security group operates inside](/glossary/vpc): the address space, subnets, and route tables the rules above sit on top of. * [AWS security groups reference](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-security-groups.html): the upstream definition, the rule fields, and the default group behavior. * [AWS network ACL reference](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-network-acls.html): the stateless subnet filter and the ephemeral port range its return rules need. * [WarpBuild BYOC security hardening documentation](/docs/ci/byoc/aws/security): the runner security group posture, inter-instance isolation, and the egress checklist. * [WarpBuild BYOC AWS configuration documentation](/docs/ci/byoc/aws/config): the VPC, subnet, and security group prerequisites for a runner stack. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a security group in cloud networking? A security group is a stateful firewall attached to the network interface of a cloud instance. It holds two lists of allow rules, one for inbound traffic and one for outbound traffic, and each rule names a protocol, a port range, and a source or destination such as a CIDR block or another security group. Traffic that matches no rule is dropped, and because the filter tracks connections, the response to an allowed connection is admitted without a rule in the opposite direction. ### What is the difference between a security group and a network ACL? A security group attaches to a network interface, carries allow rules only, evaluates every rule as one set, and is stateful. A network ACL attaches to a subnet, carries both allow and deny rules, evaluates them in rule-number order until one matches, and is stateless, so return traffic needs its own rule covering the ephemeral port range. They are commonly used together, with the ACL as a coarse subnet-wide boundary and the group as the per-instance rule set. ### Can a GitHub Actions runner work with no inbound security group rules? Yes. The runner agent opens an outbound HTTPS connection to GitHub and holds it, so nothing needs to reach the machine from outside. Because a security group is stateful, the responses on that connection are admitted with no inbound rule present. Removing every inbound rule is the usual starting posture for a runner group, after which the work is deciding which outbound destinations a build actually needs. --- # Self-Hosted Runner | WarpBuild URL: https://www.warpbuild.com/glossary/self-hosted-runner Description: A self-hosted runner is a machine you operate that registers with GitHub and runs workflow jobs. How registration, labels, and the ownership split work. # Self-Hosted Runner A self-hosted runner is a machine you operate that registers with GitHub and runs workflow jobs. How registration, labels, and the ownership split work. A self-hosted runner is a machine you operate that registers itself with GitHub and executes GitHub Actions workflow jobs. GitHub hands that machine a job when every label in the job's `runs-on` key appears on the runner, and everything below the runner agent, meaning the hardware or instance, the operating system, the installed tooling, the network placement, and the bill for the machine, belongs to you. The term describes ownership of the machine and nothing else. It says nothing about the size of the machine, its operating system, how long it lives, or whether a person or a controller created it. A workstation under a desk, an instance in your cloud account, a container in a Kubernetes cluster, and a Mac mini in a rack are each a self-hosted runner once a registered agent is running on them. ## [Definition](#definition)Copy Anchor Link ### [The two parts](#the-two-parts)Copy Anchor Link A self-hosted runner is a host plus an agent, and only the host is yours. The agent is the open source runner published at [github.com/actions/runner](https://github.com/actions/runner) under the MIT license. GitHub runs the same binary on the machines it supplies, so the software that downloads actions, executes steps, and streams logs is identical in both cases. The self-hosted part is the host underneath it: you choose what the machine is, where it sits, what is installed on it, and how long it stays alive. That split explains why moving a workflow between fleets is usually a small change. The job definition talks to the agent, and the agent behaves the same way everywhere. ### [What registration establishes](#what-registration-establishes)Copy Anchor Link Registration is a one-time handshake performed by `config.sh` on Linux and macOS or `config.cmd` on Windows. The command takes a registration URL, a short-lived registration token that GitHub shows on the New self-hosted runner page, an optional runner group, and a list of labels. After that, the agent authenticates with credentials it stored on disk, and the registration token is never needed again. Three things are fixed at that moment. 1. **Scope.** A runner registers to a single repository, to an organization, or to an enterprise. Scope decides which workflows are allowed to queue jobs on it. A repository-scoped runner is invisible to every other repository, which is the most common reason a correct-looking label never matches. 2. **Group membership.** Organizations and enterprises place runners in groups, and a group carries a policy about which repositories and which workflows may use the runners inside it. 3. **Labels.** The routing keys GitHub matches against `runs-on`. Labels can be edited later from the runner settings page or with the API. Once configured, the agent opens an outbound HTTPS long poll to GitHub and holds it. Job messages arrive on that connection, so the machine can sit inside a private subnet behind a firewall that allows outbound traffic only. This is the property teams are usually buying when they register their own runners: a job running on that machine resolves internal hostnames, reaches a private package registry, and connects to a database that has no public endpoint. ### [The labels a runner advertises](#the-labels-a-runner-advertises)Copy Anchor Link GitHub assigns three labels to every self-hosted runner automatically, and you add more at configuration time. | Automatic label | Values GitHub assigns | | ---------------- | ---------------------------------------- | | Category | self-hosted, on every self-hosted runner | | Operating system | linux, windows, or macOS | | Architecture | x64, ARM, or ARM64 | Matching is a subset test in one direction. Every label named in `runs-on` has to appear on the runner, while labels the runner carries beyond that set are ignored. A runner advertising `self-hosted`, `linux`, `x64`, and `gpu-free` is eligible for a job asking for `[self-hosted, linux]`, and a runner advertising only `self-hosted` and `linux` is skipped by a job asking for `[self-hosted, linux, x64]`. Labels are opaque strings. GitHub performs no verification that a runner labeled `x64` is an x86-64 machine or that a runner labeled `secure-network` sits in one. Whoever registered the runner owns the accuracy of its labels. ### [What you own](#what-you-own)Copy Anchor Link The registration relationship is cheap to create. The operating responsibilities behind it are the substance of the term, and they persist for as long as the fleet exists. | Responsibility | What it involves | Typical failure when it is skipped | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | Provisioning | Creating machines, attaching disks, placing them in a subnet, installing the agent, holding the registration token | Capacity is fixed at whatever was created by hand, so a busy afternoon queues behind it | | Patching and image maintenance | Operating system updates, language runtimes, build tools, container engines, and the agent version itself | Jobs pass on one machine and fail on another because the toolchain drifted apart | | Scaling | Adding runners as queue depth grows, removing them when it falls, and paying for the machines in between | Either jobs wait for a free runner or idle machines bill for hours with nothing to run | | Isolation | Deciding the boundary between two jobs: a separate virtual machine, a container on a shared kernel, or a shared working directory | A job reads the previous job's checkout, its credentials on disk, or its exported environment | | Decommissioning | Deregistering the runner, terminating the machine, and deleting its disk | Offline runners accumulate in the settings page and orphaned disks keep billing | None of these responsibilities are held by GitHub for a self-hosted runner. GitHub accepts the registration, routes jobs to it, and records the result. The rest of the loop belongs to whoever operates the fleet, which is why the choice is usually framed as an operations budget rather than a workflow decision. Two policy defaults sit on top of the list. Self-hosted runners are blocked on public repositories until a runner group is changed to allow them, and GitHub [recommends against that combination](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#self-hosted-runner-security) because a pull request from a fork can propose a workflow change that then executes on your machine (checked on 2026-08-13). The setting itself lives on the runner group, described in the [public repositories documentation](/docs/ci/public-repos). ### [What the term does not decide](#what-the-term-does-not-decide)Copy Anchor Link Two questions people fold into the term are separate from it. **Lifecycle.** A self-hosted runner can accept one job and disappear, or stay registered for months and accept hundreds. Configuring the agent with `--ephemeral` produces the first shape, and leaving the flag off produces the second. The word "self-hosted" is silent on this, which is covered under [ephemeral runner](/glossary/ephemeral-runner). **Who built the machine.** A provider can operate machines and register them against your organization through the same registration path an engineer would use by hand. GitHub records those as self-hosted runners too, applies the same runner group rules and the same public repository policy to them, and routes jobs to them with the same label match. The registration is identical, and the difference is which team carries the five responsibilities in the table above. ## [Example](#example)Copy Anchor Link Take an organization named `acme` whose integration tests need a Postgres instance that lives inside a private subnet with no public endpoint. A job on a machine outside that network cannot reach the database, so `acme` registers a runner inside it. **Provision the machine.** One Ubuntu 24.04 instance in the private subnet, with outbound HTTPS allowed and no inbound rules. Docker, the language toolchain, and a `psql` client are installed by the image build. **Register the agent.** The runner is downloaded from the releases page GitHub links on the New self-hosted runner screen, then configured unattended: Copy Text ``` mkdir actions-runner && cd actions-runner curl -o actions-runner.tar.gz -L "$RUNNER_DOWNLOAD_URL" tar xzf actions-runner.tar.gz ./config.sh \ --url https://github.com/acme \ --token "$RUNNER_REGISTRATION_TOKEN" \ --runnergroup integration \ --labels internal-network,postgres-fixtures \ --unattended sudo ./svc.sh install sudo ./svc.sh start ``` The `--url` value is what creates the relationship: this agent now belongs to the `acme` organization, inside the runner group named `integration`. The agent adds `self-hosted`, `linux`, and `x64` to the two custom labels, so the runner advertises five labels in total and appears as Idle under the organization's runner settings. **Route a job to it.** The workflow asks for all five: Copy Text ``` name: integration on: pull_request: jobs: db-tests: runs-on: [self-hosted, linux, x64, internal-network, postgres-fixtures] steps: - uses: actions/checkout@v4 - run: ./scripts/wait-for-postgres.sh - run: ./scripts/integration-test.sh ``` When a pull request opens, GitHub queues `db-tests`, finds the runner in the `integration` group carrying all five labels, and sends it the job message on the connection the agent is already holding. The agent claims the job, spawns a worker, downloads `actions/checkout`, and runs the two scripts. Because the machine sits in the subnet, `wait-for-postgres.sh` resolves the private hostname and connects. ### [When the labels do not match](#when-the-labels-do-not-match)Copy Anchor Link Drop `postgres-fixtures` from the runner and leave the workflow alone, and the job queues forever with an empty log. GitHub reports no error, because a queued job waiting for a label combination is a normal state. The job is cancelled once it has waited 24 hours, per [GitHub's Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13\. A label typo therefore fails silently a day later rather than failing fast, so label changes are worth making on the runner and the workflow in the same change. ### [After the job ends](#after-the-job-ends)Copy Anchor Link The agent reports the conclusion and returns to waiting. Registration outlives the job, and so does the disk: the checkout stays in the working directory, the package manager cache stays warm, and pulled container images stay in the local store. The next job that lands on this runner starts inside that history, which is the tradeoff long-lived runners make. ### [Moving the same job to another fleet](#moving-the-same-job-to-another-fleet)Copy Anchor Link Because a managed fleet registers through the same path, redirecting the job is a change to one line: Copy Text ``` jobs: db-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/integration-test.sh ``` A label of the shape `warp-ubuntu-latest-x64-4x` packs an operating system, an architecture, and a machine size into one opaque routing string, and GitHub matches it exactly the way it matched the five-label array. The steps, the triggers, and the rest of the file stay where they are. What changes is the network the job runs in, so the Postgres step in the first example stays on the runner inside the subnet while jobs with no private dependency move freely. ## [Related Terms](#related-terms)Copy Anchor Link * [GitHub-hosted versus self-hosted runners, compared](/answers/what-is-the-difference-between-github-hosted-and-self-hosted-runners): the same ownership split laid out against GitHub-hosted and managed fleets, dimension by dimension. * [Ephemeral runner, defined](/glossary/ephemeral-runner): the lifecycle question the term leaves open, and what changes when a runner takes one job and disappears. * [Runner group, defined](/glossary/runner-group): the policy container that decides which repositories and workflows may queue jobs on a registered fleet. * [Runner label, defined](/glossary/runner-label): how the routing keys are assigned, matched, and edited after registration. * [Enabling runners in public repositories](/docs/ci/public-repos): the runner group setting that governs self-hosted access from public repositories. * [Runner security documentation](/docs/ci/security): the compute and storage isolation model applied to runner virtual machines. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a self-hosted runner in GitHub Actions? A self-hosted runner is a machine you supply and operate, with GitHub's open source runner agent installed on it and registered against a repository, an organization, or an enterprise. GitHub queues a workflow job for that machine when every label in the job's runs-on key appears on the runner, and the operating system, the disk, the network placement, and the cost of the machine stay yours. ### How does a self-hosted runner connect to GitHub? The agent opens an outbound HTTPS long poll to GitHub and waits for a job message on that connection. Nothing dials the machine from the internet, so a runner works inside a private subnet behind a firewall that allows outbound traffic only, and it can reach private services that a machine outside your network cannot. ### What do I have to operate myself when I run self-hosted runners? Provisioning the machines, building and patching the image the jobs run on, scaling the fleet up and down against queue depth, deciding the isolation boundary between jobs, and decommissioning machines so a deregistered runner leaves nothing running. GitHub operates none of that for a self-hosted runner. ### Can self-hosted runners run jobs from public repositories? Only after a runner group is changed to allow it. GitHub blocks self-hosted runners on public repositories by default and recommends against the combination, because a pull request from a fork can propose workflow changes that then execute on your machine. --- # Simulator Runtime | WarpBuild URL: https://www.warpbuild.com/glossary/simulator-runtime Description: A simulator runtime is the downloadable platform image a simulator boots, versioned apart from the tools that drive it. How to pin one in a test job. # Simulator Runtime A simulator runtime is the downloadable platform image a simulator boots, versioned apart from the tools that drive it. How to pin one in a test job. A simulator runtime is the downloadable platform image a simulator boots: the operating system build, frameworks, and system apps for iOS, tvOS, watchOS, or visionOS, packaged so a Mac can run them as a guest. It ships and is versioned separately from the developer tools that create and drive simulated devices, so the toolchain on a machine and the set of runtimes available to a test job move on independent schedules. That separation is the reason two machines with identical toolchain versions can run the same test suite against different operating system builds. A workflow that names no runtime version inherits whatever the machine happens to carry on the day the job runs. ## [Definition](#definition)Copy Anchor Link A runtime is a bundle on disk holding a full guest system: the kernel and userland for the guest platform, the frameworks an app links against, the system applications, and the SDK-matched dyld shared cache. Older toolchain releases expanded these bundles into `/Library/Developer/CoreSimulator/Profiles/Runtimes` with a `.simruntime` extension. Recent releases distribute them as signed disk images that mount under `/Library/Developer/CoreSimulator/Volumes`, which is why a runtime can be present as a download and still report as unusable until it finishes installing. A simulated device is assembled from two pieces plus the state written while it runs. | Piece | What it holds | Where it comes from | | ---------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | | Runtime | The guest OS build, frameworks, and system apps for one platform version | Bundled with the toolchain release, or downloaded afterwards | | Device type | A hardware profile: screen geometry, memory budget, and the capabilities the guest reports | Shipped with the toolchain, listed by xcrun simctl list devicetypes | | Simulated device | A pairing of one device type with one runtime, holding its own UDID and data container | Created by xcrun simctl create or by a destination that requests it | ### [Two version lines that move independently](#two-version-lines-that-move-independently)Copy Anchor Link Each toolchain release bundles one runtime per platform, matching the SDK it was built against. Every other version is a separate download. Apple documents the download path in [Installing additional simulator runtimes](https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes), and the command line form is `xcodebuild -downloadPlatform iOS` for one platform or `xcodebuild -downloadAllPlatforms` for the full set. Runtimes are large. A single platform runtime is measured in gigabytes, so a job that downloads one before it can test pays that transfer on every run, along with the failure modes of any large network fetch. The inventory on a machine is printed by one command: Copy Text ``` xcrun simctl list runtimes ``` Each row carries a marketing version such as `26.0`, a build number, a runtime identifier such as `com.apple.CoreSimulator.SimRuntime.iOS-26-0`, and an availability field. The identifier is the stable handle to pass to `xcrun simctl create` when a job builds a device explicitly. ### [How a job picks a runtime](#how-a-job-picks-a-runtime)Copy Anchor Link Test commands select a runtime through the destination specifier. `platform` names the simulator family, `name` names the device type, and `OS` names the runtime version. Writing `OS=latest` asks for the newest runtime installed on that machine, so the resolved version is a property of the machine rather than of the repository. Writing an explicit version pins the guest system, and a machine without that runtime fails at destination resolution instead of testing against a substitute. ### [What changes when the runtime version changes](#what-changes-when-the-runtime-version-changes)Copy Anchor Link The guest operating system version decides which APIs exist, which are deprecated, how permission prompts are presented, and which defaults apply for locale, time zone, and appearance. A snapshot test recorded against one runtime can fail on the next one because a system control was redrawn. A test that awaits a permission alert can hang when the alert moved. These read as flaky tests until the runtime version is written into the log. ## [Example](#example)Copy Anchor Link This job pins the runtime version in one place, fails fast when the machine does not carry it, and runs the suite against that pinned version. Copy Text ``` name: ios-tests on: push: branches: [main] jobs: unit-tests: runs-on: macos-latest env: SIM_DEVICE: "iPhone 17" SIM_RUNTIME: "26.0" steps: - uses: actions/checkout@v4 - name: Record the runtimes this machine carries run: xcrun simctl list runtimes - name: Fail early when the pinned runtime is missing run: | xcrun simctl list runtimes | grep -q "iOS ${SIM_RUNTIME}" || { echo "iOS ${SIM_RUNTIME} simulator runtime is not installed" exit 1 } - name: Test against the pinned runtime run: | xcodebuild test \ -scheme App \ -destination "platform=iOS Simulator,name=${SIM_DEVICE},OS=${SIM_RUNTIME}" \ -resultBundlePath TestResults.xcresult ``` The third step is the part that changes the failure shape. Without it, a missing runtime surfaces as an `xcodebuild` destination error buried in build output. With it, the job stops in seconds with a message naming the version it wanted, and the preceding step has already printed the full inventory into the log. The two destination forms behave differently over time: | Destination | Runtime that boots | What moves between runs | | ----------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------- | | platform=iOS Simulator,name=iPhone 17,OS=latest | Newest runtime installed on the machine | An image update that adds a runtime silently retargets the suite | | platform=iOS Simulator,name=iPhone 17,OS=26.0 | The 26.0 runtime, or the job fails | Only a commit that edits the workflow | When a suite needs a runtime the machine does not carry, the download can be made an explicit step rather than a surprise: Copy Text ``` - name: Download a runtime the image does not carry run: sudo xcodebuild -downloadPlatform iOS ``` That step makes the cost visible. It adds a multi-gigabyte transfer to every run of the job, so the cheaper arrangement is to run on a machine image that already carries the runtime versions the test matrix names, and to keep the pinned version in one environment variable so the whole matrix moves together. ## [Related Terms](#related-terms)Copy Anchor Link * [Simulator runtimes available on WarpBuild macOS runners](/runners/macos/simulator-runtimes): which platform runtimes each macOS image carries and how to check from inside a job. * [Running iOS simulator tests on GitHub Actions](/guides/ios-simulator-tests-github-actions): wiring `xcodebuild test` into a workflow, including destination pinning and result bundles. * [Code signing and why simulator builds skip it](/glossary/code-signing): what a simulator build avoids that a device build has to satisfy. * [Installing additional simulator runtimes](https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes): Apple's reference for downloading and managing runtime versions. * [GitHub runner image definitions](https://github.com/actions/runner-images): the upstream macOS image READMEs listing the toolchain and simulator runtime versions in each build. * [WarpBuild preinstalled software documentation](/docs/ci/preinstalled-software): per image software references by platform. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner catalog with images, sizes, and tooling notes. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a simulator runtime? It is the guest operating system image a simulator boots: the OS build, frameworks, and system apps for iOS, tvOS, watchOS, or visionOS, packaged so a Mac can run them. It is downloaded and versioned separately from the developer tools that create and drive simulated devices. ### How do I see which simulator runtimes a machine has? Run xcrun simctl list runtimes. Each line prints the platform, the marketing version, the build number, the runtime identifier such as com.apple.CoreSimulator.SimRuntime.iOS-26-0, and an availability field that says whether the runtime is usable or only partially installed. ### Why did my simulator tests change behavior with no code change? A destination written with OS=latest resolves to the newest runtime installed on the machine at the moment the job runs. When an image update adds a newer runtime, the same commit starts testing against a different operating system build, which moves API behavior, permission prompts, and locale defaults. --- # Single Sign On | WarpBuild URL: https://www.warpbuild.com/glossary/single-sign-on Description: Single sign-on is an arrangement where one identity provider authenticates a user once and asserts that identity to the other applications the user opens. # Single Sign On Single sign-on is an arrangement where one identity provider authenticates a user once and asserts that identity to the other applications the user opens. Single sign-on (SSO) is an arrangement where one identity provider authenticates a user once and asserts that identity to the other applications that user opens, so each application trusts a signed statement about the person instead of holding a password for them. The applications on the receiving end keep their own authorization rules and ask the identity provider only the question of who is signing in. The operational result is one account record per person for the whole application estate. Adding someone happens in one directory, multi-factor and device rules are enforced in one place, and disabling the account closes the door to every connected application at once. ## [Definition](#definition)Copy Anchor Link Three parties take part, and the names change between specifications while the roles stay the same. * **The principal.** The person signing in, working through a browser. * **The identity provider (IdP).** The system that holds the account record, runs the authentication challenge, and issues a signed statement describing the result. Directory products and hosted identity services fill this role. * **The service provider or relying party.** The application the person wants to use. It consumes the signed statement, reads the subject and attributes out of it, and creates its own local session. Trust between the identity provider and the application is configured once, before any user signs in. The two sides exchange the values each needs to recognize the other: an entity identifier and a callback URL on the application side, an issuer URL and a set of public signing keys on the identity provider side. After that exchange the browser carries every message, and the application verifies each statement against the keys it already has rather than calling the identity provider directly. A sign-in then runs in five steps. 1. The person opens the application without a session, or starts from a tile in the identity provider's portal. 2. The application redirects the browser to the identity provider with a request naming itself as the intended audience. 3. The identity provider authenticates the person, using a password, a second factor, a device check, or an existing browser session it already holds. 4. The identity provider returns a signed statement about that person to the callback URL the application registered. 5. The application verifies the signature, reads the subject identifier and attributes such as email and name, and issues its own session cookie. ### [The two protocols in common use](#the-two-protocols-in-common-use)Copy Anchor Link Two specifications cover almost all enterprise deployments. Both do the same job, and the difference shows up in the format of the statement and the shape of the configuration screen. | SAML 2.0 | OpenID Connect | | | -------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | | Published by | OASIS, approved as a standard in 2005 | The OpenID Foundation, as an identity layer on OAuth 2.0 | | Statement format | XML assertion | JSON Web Token, called the ID token | | Delivery to the application | Browser POST of the assertion to an assertion consumer service URL | Authorization code returned to a redirect URI, then exchanged at the token endpoint | | Values the application registers | Entity ID, assertion consumer service URL | Client ID, client secret, redirect URI | | How keys and endpoints are found | Metadata XML, fetched from a URL or pasted in | The {issuer}/.well-known/openid-configuration discovery document | The specifications themselves are public: [SAML 2.0 at OASIS](https://www.oasis-open.org/standard/saml/) and [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1%5F0.html), both checked on 2026-08-13. ### [What sits outside the definition](#what-sits-outside-the-definition)Copy Anchor Link Three neighboring things are often described as part of SSO and are separate mechanisms. **Authorization stays with each application.** The statement says who the person is and may carry group memberships as attributes. What that person may do is decided by the application's own roles and policies after the session exists. **Provisioning is a different protocol.** Creating, updating, and deactivating the account record inside each application is the job of SCIM, defined by [RFC 7644](https://datatracker.ietf.org/doc/html/rfc7644). Some deployments use just-in-time provisioning instead, creating the local record on first successful sign-in, which covers creation and leaves deactivation unsolved. **Machine credentials never touch the browser flow.** API tokens, deploy keys, and service accounts authenticate without a redirect, so a browser-based sign-in policy has no effect on them unless the application enforces one separately. The third point is where most surprises live. Sessions and tokens issued before a change survive the change. Disabling an account stops new sessions from starting, and an open session or a long-lived token keeps working until something revokes it. ## [Example](#example)Copy Anchor Link A team enforces SAML sign-in on their GitHub organization so that one directory controls who reaches the code. Their build workflow checks out a second private repository using a personal access token stored as a repository secret. Copy Text ``` name: build on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Check out the shared internal library uses: actions/checkout@v4 with: repository: example-org/shared-library token: ${{ secrets.INTERNAL_REPO_TOKEN }} path: vendor/shared-library - run: make build ``` The moment SAML SSO is enforced on `example-org`, that second checkout stops working. GitHub requires a personal access token or SSH key to be authorized for an organization that uses SAML SSO before it can reach that organization's resources, and an unauthorized token gets HTTP 403 back from the git request ([GitHub documentation on authorizing a personal access token for use with SAML single sign-on](https://docs.github.com/en/enterprise-cloud@latest/authentication/authenticating-with-saml-single-sign-on/authorizing-a-personal-access-token-for-use-with-saml-single-sign-on), checked on 2026-08-13). The token's owner authorizes it once from their token settings page, and the workflow starts passing again. That step is what connects the browser sign-in policy to a credential that never opens a browser. Enforcement gives the organization a list of exactly which tokens hold access, and revoking the authorization revokes the token's reach into the organization without touching the token itself. Now the person who created that token leaves, and their account is deactivated in the identity provider. Two different outcomes follow, depending on how far the integration goes. | Configuration | What happens after deactivation | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | SAML sign-in only | The person can no longer pass the challenge, so no new SSO session starts. Their GitHub account still exists, organization membership persists, and the token authorized above keeps working until someone removes the membership by hand. | | SAML sign-in plus SCIM provisioning | The identity provider sends a deactivation call, GitHub removes the user from the organization, and the SAML and SCIM identities linked to that user are removed along with any authorization their tokens and SSH keys held. | GitHub documents that removing a user through SCIM revokes the linked identity and the credential authorizations tied to it ([GitHub documentation on SCIM for organizations](https://docs.github.com/en/enterprise-cloud@latest/organizations/managing-saml-single-sign-on-for-your-organization/about-scim-for-organizations), checked on 2026-08-13). Sign-in enforcement alone closes the browser path. Provisioning is what closes the token path, which is why a rollout that stops at SAML leaves a build credential alive after the person who owned it is gone. The version of this workflow that survives a departure avoids the personal credential entirely. The automatic per job credential, [GITHUB\_TOKEN](/glossary/github-token), belongs to the run rather than a person, and a GitHub App installation token belongs to the organization. Neither is affected when a contributor's directory account is deactivated, and both leave an attributable trail in the [audit log](/glossary/audit-log). ## [Related Terms](#related-terms)Copy Anchor Link * [SSO for GitHub Actions runner access](/enterprise/sso-github-actions): how SAML 2.0 and OIDC sign-in is configured for a WarpBuild account, and what happens to `runs-on` labels during a rollout. * [Audit log, the record of who did what and when](/glossary/audit-log): what an audit log captures, how long entries are kept, and how a sign-in event is correlated with an action. * [Personal access token, the credential SSO enforcement gates](/glossary/personal-access-token): what a personal access token grants, how scopes and expiry work, and why SAML enforcement adds an authorization step. * [GITHUB\_TOKEN, the automatic per job credential](/glossary/github-token): where the token comes from, what it reaches, and how long it lasts. * [WarpBuild SSO documentation](/docs/sso): the protocol choice, service provider values, assertion attribute mapping, and connection methods. * [WarpBuild runner security documentation](/docs/ci/security): compute isolation, storage handling, and where build secrets sit. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is single sign-on? Single sign-on is an arrangement where one system, the identity provider, authenticates a user once and then asserts that identity to other applications. Each application trusts a signed statement from the identity provider instead of storing a password for that person, and it still applies its own authorization rules once it knows who the user is. ### What is the difference between SAML 2.0 and OIDC? Both carry a signed statement of identity from an identity provider to an application. SAML 2.0 is an OASIS standard that sends an XML assertion through the browser to the application's assertion consumer service URL. OIDC is an identity layer on OAuth 2.0 that returns a JSON Web Token called an ID token, with endpoints discovered from the issuer's well-known configuration document. ### Does single sign-on remove access everywhere when someone leaves? It removes the sign-in path. A disabled account in the identity provider can no longer complete a challenge, so no new session starts in any connected application. Sessions already open and credentials issued outside the browser flow, such as personal access tokens, survive until a provisioning protocol such as SCIM or a manual step revokes them. --- # SOC 2 Type 2 | WarpBuild URL: https://www.warpbuild.com/glossary/soc-2-type-2 Description: SOC 2 Type 2 is an independent audit report on whether a service organization's controls operated effectively across a period, and how a reviewer reads one. # SOC 2 Type 2 SOC 2 Type 2 is an independent audit report on whether a service organization's controls operated effectively across a period, and how a reviewer reads one. SOC 2 Type 2 is an independent examination, performed by a licensed CPA firm, of whether a service organization's controls were suitably designed and operated effectively across a stated period, commonly three to twelve months. A Type 1 report examines the same controls as of a single date, so the extra evidence in a Type 2 is operating history: samples drawn from across the period showing that each control actually ran. That difference decides most vendor security reviews. A design opinion says a control existed on paper on one day, while a period opinion says an auditor tested the control repeatedly and reported every exception found. ## [Definition](#definition)Copy Anchor Link SOC stands for System and Organization Controls. The framework is published by the AICPA, and a SOC 2 examination evaluates a service organization against the trust services criteria, a control set maintained by the AICPA's Assurance Services Executive Committee. Five categories exist, and each report names the ones in scope ([2017 trust services criteria with 2022 revised points of focus](https://www.aicpa-cima.com/resources/download/2017-trust-services-criteria-with-revised-points-of-focus-2022), checked on 2026-08-13). | Category | What the criteria cover | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | Security | Protection of the system against unauthorized access, disclosure, and damage. Present in every SOC 2 examination through the common criteria. | | Availability | Whether the system is available for operation and use as committed. | | Processing integrity | Whether processing is complete, valid, accurate, timely, and authorized. | | Confidentiality | Protection of information the organization designates as confidential. | | Privacy | Handling of personal information across collection, use, retention, and disposal. | Security appears in every SOC 2 report. The other four categories are included when the service organization commits to them, so the phrase "SOC 2 Type 2" on its own tells a reviewer nothing about which categories were tested. The scope statement inside the report does ([AICPA SOC 2 reporting guidance](https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-2), checked on 2026-08-13). ### [What the document contains](#what-the-document-contains)Copy Anchor Link A finished report is a bound document with a fixed shape. Five sections, in order: | Section | Contents | What a reviewer reads it for | | ------------------------------------ | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | Independent service auditor's report | The opinion and the period it covers | Whether the opinion is unqualified, and the exact start and end dates | | Management's assertion | The service organization's own statement about its system and the criteria | Which categories the organization committed to | | Description of the system | System boundaries, infrastructure, subservice organizations, complementary user entity controls | What sits inside the audited boundary, and what the customer is assumed to do | | Tests of controls and results | Each control, the test the auditor performed, and the outcome | Exceptions, sample sizes, and management's response to each exception | | Other information | Optional material supplied by management | Context, kept outside the scope of the opinion | Three details in the description section decide how much the report is worth to a buyer. **Complementary user entity controls**, usually shortened to CUECs, are controls the report assumes the customer operates. A vendor control can only be judged effective when the customer holds up its end, so a reviewer treats the CUEC list as a set of obligations landing on their own team. **Subservice organizations** are the providers the audited organization depends on, such as a cloud platform. They are handled with the carve-out method, which excludes their controls from the examination and leaves the reviewer to collect their reports separately, or the inclusive method, which pulls them into this examination. **The period** ends on a fixed date. Once months have passed since that date, the vendor issues a bridge letter covering the gap, in which management states that no material control changes occurred between the period end and the letter date. A bridge letter carries management's word rather than an auditor's opinion. Exceptions in the results section are normal and do not by themselves make a report weak. An unqualified opinion can sit alongside tested exceptions, so the useful questions are which control failed, how many samples failed, and what management changed afterwards. A qualified, adverse, or disclaimed opinion is the signal that deserves a closer read. ## [Example](#example)Copy Anchor Link A team wants to move its GitHub Actions jobs off GitHub-hosted runners and onto a managed fleet supplied by a third-party provider. The workflow change is one line, because the `runs-on` key decides which machines receive the job and its repository checkout ([workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidruns-on), checked on 2026-08-13). Copy Text ``` name: test on: pull_request: jobs: unit-tests: runs-on: [self-hosted, linux, x64, managed-fleet] steps: - uses: actions/checkout@v4 - run: make test ``` Source code, build secrets, and cache contents now pass through machines the vendor operates, so the security reviewer holds the merge until the vendor's SOC 2 Type 2 report is on file, then reads it for six answers. | Reviewer question | Where the report answers it | | ------------------------------------------------ | --------------------------------------------------------------- | | Which trust services categories were tested? | Management's assertion, plus the scope paragraph of the opinion | | Does the period cover the last twelve months? | The start and end dates on the opinion page | | Did any control fail while the period ran? | Tests of controls and results, with management's response | | What does the audit assume we operate ourselves? | Complementary user entity controls in the system description | | Which providers are carved out? | Subservice organizations in the system description | | Is the attestation still current? | The period end date, plus a bridge letter when needed | The gate itself lives in the repository. A CODEOWNERS entry routes any pull request touching workflow files to the security team, and GitHub requests that review automatically, which a branch protection rule can then make a merge requirement ([about code owners](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners), checked on 2026-08-13). Copy Text ``` /.github/workflows/ @acme/security-review ``` A second workflow turns the policy into a check rather than a habit. It runs on pull requests that touch workflow files and fails when a `runs-on` label appears that is missing from the approved list kept beside the compliance records: Copy Text ``` name: runner-policy on: pull_request: paths: - ".github/workflows/**" permissions: contents: read jobs: approved-labels: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: ./scripts/check-runner-labels.sh compliance/approved-runners.txt ``` The file `compliance/approved-runners.txt` becomes the audit trail: one line per approved label, carrying the vendor's report period and the date the review cleared it. When the period lapses, the label fails the check until someone requests the current report and clears it again. ## [Related Terms](#related-terms)Copy Anchor Link * [A security review checklist for GitHub Actions runners](/enterprise/ci-security-review): the five questions a runner vendor review has to answer, with isolation, storage lifetime, secrets, attestation, and network boundaries in the order reviewers ask them. * [Is WarpBuild SOC 2 compliant](/answers/is-warpbuild-soc-2-compliant): the attestation, the criteria in scope, and where to request the report. * [Data residency](/glossary/data-residency): the requirement that runner compute and cache storage sit inside a named geography, which is the question that usually follows the attestation question. * [WarpBuild security documentation](/docs/ci/security): compute isolation, storage handling, and compliance posture for runners. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between SOC 2 Type 1 and SOC 2 Type 2? A Type 1 report gives an opinion on whether controls were suitably designed as of a single date. A Type 2 report gives an opinion on design and on operating effectiveness across a stated period, and it carries a results section listing each control, the test the auditor performed, and any exception found. The added evidence in a Type 2 is the operating history. ### How long a period does a SOC 2 Type 2 report cover? The period is stated on the opinion page and commonly runs three to twelve months. A first report often covers a shorter window, and later reports settle into an annual cycle. A report whose period ended months ago is usually paired with a bridge letter, in which management states that no material control changes happened between the period end and the letter date. ### Can a customer read the SOC 2 Type 2 report before signing? Usually yes, under a non-disclosure agreement, because SOC 2 reports are restricted-use documents rather than published ones. Vendors normally distribute them through a trust portal that handles the agreement. SOC 3 is the general-use version that can be published openly, and it omits the test results a reviewer wants. --- # Spot Instance | WarpBuild URL: https://www.warpbuild.com/glossary/spot-instance Description: A spot instance is spare cloud capacity sold at a discount to on-demand rates, on the condition that the provider can reclaim the machine on short notice. # Spot Instance A spot instance is spare cloud capacity sold at a discount to on-demand rates, on the condition that the provider can reclaim the machine on short notice. ## [Definition](#definition)Copy Anchor Link A spot instance is spare capacity in a cloud provider's fleet, offered at a discount to that provider's standard on-demand rate on the condition that the provider can reclaim the machine when it wants the capacity back. The hardware, the operating system image, and the instance type are identical to an on-demand instance of the same shape, and the only thing you give up is tenure. Every large provider sells this model under its own name. AWS calls them Spot Instances, Google Cloud calls them Spot VMs, and Azure calls them Spot Virtual Machines. Google's earlier product for the same idea was the preemptible VM, and that word still appears in older tooling and scripts. "Interruptible" and "preemptible" are the generic adjectives for the model, and "spot" is the word that shows up on the invoice. ### [Where the spare capacity comes from](#where-the-spare-capacity-comes-from)Copy Anchor Link A cloud region is built out of pools of physical capacity, separated by instance family, generation, size, and availability zone. On-demand and reserved demand never lines up exactly with what the provider has racked, so at any given moment part of each pool is idle. Spot pricing sells that idle part, priced by the provider from supply and demand inside the individual pool rather than as one figure for the whole region. Two consequences follow from that, and they are the two that shape any design on top of spot capacity. First, availability is a per pool property. One instance type in one availability zone can be unavailable while a near identical type in the zone next door has capacity to spare. A request for spot capacity can therefore fail at launch time, before any workload has started, which is a different failure from being reclaimed mid run. Second, reclamation is a capacity decision rather than a penalty. When on-demand or reserved demand rises inside a pool, the provider takes back what it had lent out. The trigger sits entirely on the provider side, so a workload can prepare for reclamation while having no setting that avoids it. ### [The interruption contract](#the-interruption-contract)Copy Anchor Link Each provider publishes what warning it gives before a spot machine goes away. The windows are short, and the signal is best effort rather than a guarantee. | Provider | Product name | How the warning arrives | Published window | | ------------ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | | AWS | EC2 Spot Instances | Spot Instance interruption notice in instance metadata and as an EventBridge event, sometimes preceded by an instance rebalance recommendation | 2 minutes | | Google Cloud | Spot VMs | Preemption notice delivered as an ACPI shutdown signal, with the state visible in instance metadata | 30 seconds | | Azure | Spot Virtual Machines | Eviction notice delivered through Scheduled Events on the instance metadata service | 30 seconds | Sources, each checked on 2026-08-13: [AWS Spot Instance interruptions](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-interruptions.html), [Google Cloud Spot VMs](https://cloud.google.com/compute/docs/instances/spot), and [Azure Spot Virtual Machines](https://learn.microsoft.com/en-us/azure/virtual-machines/spot-vms). Read those windows as what they are. Thirty seconds to two minutes is enough to flush a log buffer, upload a partial artifact, drain a queue consumer, or deregister an agent. It is nowhere near enough to checkpoint a linker, a test suite, or a container build and resume it somewhere else. Any workload that plans to survive an interruption has to be able to restart from a known point, and the practical version of that for most build and test work is restarting from the beginning. The second half of the contract is the part people notice later: interruption ends the instance, and everything on its local disk goes with it. Anything the workload wrote to remote storage before the notice arrived is still there. Anything it held in memory or on the ephemeral volume is gone. ### [Fallback instance selection](#fallback-instance-selection)Copy Anchor Link Because availability is a per pool property, the standard mitigation is to stop asking for one instance type. A launcher that accepts a list of acceptable types can place the workload wherever capacity exists at that moment, and picks from that list based on availability and price. Three rules make the list useful rather than decorative: * Keep the types close in size and performance. A list that mixes a 4 vCPU type with a 32 vCPU type produces wildly different run times depending on which one wins, and every downstream timeout has to be sized for the slowest case. Types from the same size class in adjacent families, for example `m7a.xlarge` alongside `m7i.xlarge`, keep the behavior predictable. * Spread the subnets across availability zones. The list of types multiplies with the list of zones, and the product of the two is the real pool the launcher can draw from. * Keep an uninterruptible path for the work that cannot tolerate a restart, addressed separately, so moving a workload back is a configuration change rather than a redesign. The same idea appears in build infrastructure that launches runners inside a cloud account you own. The [BYOC setup documentation](/docs/ci/byoc) covers fallback instances directly, and the [AWS configuration guide](/docs/ci/byoc/aws/config) covers the account side, including the subnet spread across availability zones and the EC2 spot permissions the role carries. ## [Example](#example)Copy Anchor Link GitHub Actions is a clean place to watch the trade play out, because one job runs on exactly one machine and the workflow file says which machine it wants through `runs-on`. That makes the decision per job rather than per repository. A sharded unit test job tolerates interruption. Every shard reads the same checkout, writes its results, and produces the same answer if it runs again, so losing one shard costs one shard. A publish job does not tolerate interruption. It pushes tags, writes release notes, and touches state that lives outside the job, so an interruption halfway through leaves a partly finished release that the retry has to reconcile before it can do its own work. Written out, the split is one label per job. In a BYOC setup the runner ID carries the `warp-custom-` prefix, which the [BYOC documentation](/docs/ci/byoc) records as a requirement: .github/workflows/ci.yml Copy Text ``` name: ci on: pull_request: push: branches: [main] jobs: unit-tests: runs-on: warp-custom-linux-8x-spot strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - run: make test SHARD=${{ matrix.shard }} publish: needs: unit-tests if: github.ref == 'refs/heads/main' runs-on: warp-custom-linux-8x-ondemand steps: - uses: actions/checkout@v4 - run: make publish ``` Two runner configurations sit behind those two labels. One is backed by interruptible capacity and one is not, and the workflow chooses between them one job at a time. `fail-fast: false` on the matrix keeps a reclaimed shard from cancelling the three siblings that were about to pass. ### [What happens when the capacity is reclaimed](#what-happens-when-the-capacity-is-reclaimed)Copy Anchor Link Say the provider reclaims the machine running shard 3 at the seven minute mark. The sequence is the same on all three clouds, with only the warning window changing: 1. The provider issues the interruption notice to the instance metadata service and to its event bus. 2. The instance is stopped or terminated at the end of the published window. 3. The runner agent on that machine stops answering, and GitHub Actions marks the job as failed once the connection is lost. 4. The other three shards keep running, because `fail-fast: false` told the matrix to leave them alone. 5. A replacement instance can be launched for the retry, chosen from the fallback list rather than from the pool that just ran out. 6. Shard 3 runs again from its first step. The seven minutes it had already used are still billed by the cloud account that owns the instance. Step 6 is the whole cost model of spot capacity in one line. The discount is real and the retry is real, and which one wins depends on how long the job is and how often the pool gets reclaimed. ### [What survives the interruption](#what-survives-the-interruption)Copy Anchor Link The retry starts clean, so the useful question is which side of the instance boundary each piece of state sits on. | State | Survives an interruption | Why | | ----------------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------- | | Files on the runner's local disk | No | The disk is part of the instance and goes away with it. | | Steps already completed in the job | No | The retry starts the job again from its first step. | | Cache entries already written to remote storage | Yes | They live in object storage outside the instance. | | Artifacts already uploaded by actions/upload-artifact | Yes | Upload completed before the notice arrived. | | Image layers already pushed to a registry by digest | Yes | Content addressed pushes are idempotent, so the retry writes the same digest. | | A partly applied database migration | The side effect survives | This is the reason migration jobs stay off interruptible capacity. | Reading the table row by row is a quick eligibility test for any job. If every row that applies to the job lands in the "Yes" column or in the "No" column, the job can be retried safely. If a row lands in the last category, the job belongs on capacity that will not be taken away. ### [One more constraint worth knowing](#one-more-constraint-worth-knowing)Copy Anchor Link Warm pools and interruptible capacity work against each other. A pool of pre-initialized disks exists so that a machine is already waiting when a job arrives, and an instance that can be reclaimed at any moment cannot make that promise. Configurations that keep a warm pool are usually kept separate from the ones running on spot capacity, which the [standby disks documentation](/docs/ci/byoc/standby-disks) states directly. Choosing between interruptible and uninterruptible capacity is a decision you can only make once instance choice belongs to you, which is what the [BYOC deployment model](/glossary/byoc) describes and what the [hosted runners compared with BYOC runners](/guides/hosted-vs-byoc-runners) guide walks through for GitHub Actions specifically. The [spot instances for GitHub Actions runners guide](/guides/spot-instances-github-actions-runners) takes the same subject from job selection through to configuration, and the [WarpBuild pricing page](/pricing) lists the per minute rates for each runner type. ## [Related Terms](#related-terms)Copy Anchor Link * [Spot instances for GitHub Actions runners](/guides/spot-instances-github-actions-runners): the end to end version of this page, from picking eligible jobs to the runner configuration behind the labels. * [BYOC (bring your own cloud)](/glossary/byoc): the deployment model in which instances launch inside your own cloud account, which is what puts the purchase model in your hands. * [Hosted runners compared with BYOC runners](/guides/hosted-vs-byoc-runners): the trade between runners a provider operates in its account and runners that land in yours. * [Standby disks for BYOC runners](/byoc/standby-disks): what a warm pool of pre-initialized disks holds and why it sits on a separate runner configuration from interruptible capacity. * [BYOC setup documentation](/docs/ci/byoc): connecting a cloud account, creating a stack, and defining custom runners with fallback instance types. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a spot instance? A spot instance is spare capacity in a cloud provider's fleet, offered at a discount to that provider's standard on-demand rate on the condition that the provider can reclaim the machine when it needs the capacity back. The hardware, the image, and the instance type are the same as an on-demand instance; the difference is tenure, because the provider can end the instance after a short warning. ### How much warning does a spot instance give before it is reclaimed? The warning window is short and provider specific. AWS publishes a two minute Spot Instance interruption notice delivered through instance metadata and EventBridge, Google Cloud publishes a 30 second preemption notice delivered as a shutdown signal, and Azure publishes a 30 second eviction notice through Scheduled Events. All three are best effort signals rather than guarantees, and none of them is long enough to checkpoint a running compile. ### Which GitHub Actions jobs are safe to run on spot instances? Jobs that can be re-run from their first step without cleaning anything up. Unit test shards, lint, type checks, and container builds that push by digest all qualify, because a lost job costs the minutes it had used and nothing else. Release, publish, deploy, and database migration jobs do not qualify, because an interruption halfway through leaves side effects outside the job that a retry then has to reconcile. ### What is fallback instance selection and why does it matter on spot? Fallback instance selection lets a runner configuration name several acceptable instance types instead of one, so the launcher can pick whichever type has capacity at that moment. Spot availability is per pool, meaning per instance type, per zone, so a single pinned type can leave jobs queued while a near identical type nearby has capacity. A longer list of similarly sized types widens the pool the launcher can draw from. --- # Standby Disk | WarpBuild URL: https://www.warpbuild.com/glossary/standby-disk Description: A standby disk is a storage volume prepared ahead of demand so a new machine can attach it and start work without downloading a machine image first. # Standby Disk A standby disk is a storage volume prepared ahead of demand so a new machine can attach it and start work without downloading a machine image first. A standby disk is a storage volume that was prepared ahead of demand and kept ready, so a new machine can attach it and begin work without downloading and unpacking a machine image first. The volume already carries an installed operating system, a filesystem that has completed its first boot, and whatever agent or toolchain the machine exists to run. The term shows up wherever machines are created on demand and somebody is waiting for the first instruction to execute. In GitHub Actions that wait lands in the `Set up job` step, which runs before the first line of a workflow does anything useful. ## [Definition](#definition)Copy Anchor Link A standby disk has three properties. Drop any one of them and the result is a different object. 1. **It carries a prepared filesystem.** The operating system, the package set, the runtime versions, and any long-lived daemons are installed on the volume already. A machine attaching it starts from the state the image author intended rather than from a blank device. 2. **It exists before the request that consumes it.** The volume is created and initialized on a schedule or by a background controller, so the work of materializing it sits outside the path a waiting job travels. 3. **It can be attached by a machine that does not exist yet.** The volume is addressable independently of any one instance, either as the root volume of a stopped machine that can be powered back on or as a detached volume that a newly created instance binds at launch. When no prepared volume is waiting, creating a machine runs a fixed sequence: allocate compute capacity, create a blank volume, copy the image contents into it, boot the operating system, run first-boot configuration such as cloud-init, attach networking, and start the agent. A standby disk moves the middle of that sequence ahead of the request, because the copy and the first boot are already finished when the request arrives. | Startup work | Machine created from scratch | Machine started from a standby disk | | ---------------------------------------- | ---------------------------- | ----------------------------------- | | Compute capacity allocated | At request time | At request time | | Volume created | At request time | Already done | | Image contents written to the volume | At request time | Already done | | Operating system first boot | At request time | Already done | | Network attachment and instance metadata | At request time | At request time | | Agent start and registration | At request time | At request time | ### [Neighboring terms](#neighboring-terms)Copy Anchor Link Four terms in this area are routinely swapped for one another, and they describe different objects with different lifetimes. | Term | What it is | Lifetime | | ------------- | --------------------------------------------------------------------------- | ------------------------------------------------- | | Machine image | A template describing the disk contents a machine starts from | Indefinite, and one template serves many machines | | Snapshot | A point-in-time copy of a volume, stored for restore or for cloning | Indefinite, until deleted | | Standby disk | A live volume already materialized from a template and held ready to attach | Until it is attached, recycled, or deleted | | Warm pool | A set of prepared machines or disks maintained at a target count | Maintained continuously by a controller | A single standby disk is the unit. The [warm pool](/glossary/warm-pool) is the accounting layer above it: a target count, a current count, and a reconciliation loop that creates replacements after disks are consumed. ### [The cost shape](#the-cost-shape)Copy Anchor Link Two states are used in practice. The first keeps the volume attached to a machine that has been stopped rather than terminated, which preserves the instance and its configuration alongside the disk. The second keeps the volume detached and attaches it to an instance created at request time. AWS documents the billing consequence of the first form for EC2\. Its instance state table marks `pending`, `stopping`, `stopped`, and `shutting-down` as not billed and `running` as billed, and the text states that you are not charged for usage or data transfer fees for an instance while it is stopped, while charges are incurred for the storage of any Amazon EBS volumes ([Amazon EC2 instance lifecycle](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-lifecycle.html), checked on 2026-08-13). A prepared disk therefore bills as provisioned storage the whole time it waits, and as compute only during the initialization boot and during the job it eventually serves. That split is why pool sizing tracks peak concurrent demand rather than total volume. Each waiting disk serves one machine at a time, so a workload with four jobs arriving together needs four prepared disks to cover all four, however many jobs the same fleet runs across a week. ## [Example](#example)Copy Anchor Link Take a self-hosted GitHub Actions fleet in a cloud account, running ephemeral runners. GitHub recommends implementing autoscaling with ephemeral self-hosted runners and advises against autoscaling with persistent ones, because GitHub assigns only one job to an ephemeral runner ([self-hosted runners reference](https://docs.github.com/en/actions/reference/runners/self-hosted-runners), checked on 2026-08-13). Every job in that model needs a machine that did not exist a moment earlier, which is exactly the case a standby disk addresses. The workflow asks for the fleet by label and says nothing about disks: Copy Text ``` name: test on: pull_request: branches: [main] jobs: unit: runs-on: [self-hosted, linux, x64] strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npx jest --shard=${{ matrix.shard }}/4 ``` A push to the pull request queues four jobs at once. With a pool of prepared disks in place, each of them travels this path: 1. GitHub queues the job and holds it until an online runner advertises `self-hosted`, `linux`, and `x64`. 2. The controller watching the queue claims one prepared disk from the pool. 3. A machine attaches that disk and powers on. The operating system already finished first boot, so it reaches userspace without repeating installation or configuration work. 4. The runner agent baked into the disk starts, registers against the repository or organization, and advertises its labels. 5. GitHub sends the job to the newly registered runner. `Set up job` completes and the first workflow step runs. 6. The job finishes, the agent deregisters because it was configured with `--ephemeral`, and the machine is destroyed. 7. The controller materializes a replacement disk so the pool returns to its target count. The agent in step 4 is the open source runner published at [github.com/actions/runner](https://github.com/actions/runner), and `--ephemeral` is a flag passed to its `config.sh` configuration step. Installing that agent on the disk before the job arrives is what allows step 4 to start immediately; a disk lacking it has to download and configure the agent while the job waits. Two failure modes are worth naming, because both read as a broken feature from the run log. **An empty pool.** When demand exceeds the prepared disks available, the fleet falls back to building a machine from scratch, and those jobs start at the uncached time. The jobs still run, so the symptom is a run where the first shards start promptly and the rest do not. The fix is a pool sized against peak concurrency, plus enough reconciliation headroom for the controller to refill between bursts. **A reused disk.** A disk that has already served a job carries that job's leftovers: a populated package cache, a checked out repository, and any credentials a step wrote to the filesystem. Returning it to the pool as-is hands the next job a dirty machine and defeats the guarantee that ephemeral registration exists to provide. Consumed disks are rebuilt from the template instead of recycled in place. Measuring the effect needs no instrumentation. Hold the workflow file constant, change the pool count, and compare the `Set up job` duration across a week of runs on that fleet. Every other number in the run stays where it was, since a standby disk changes when a job starts rather than what the job does. ## [Related Terms](#related-terms)Copy Anchor Link * [Standby disks for GitHub Actions runners in your own cloud account](/byoc/standby-disks): how a prepared disk pool is sized and configured on runners that live in your own cloud account, including the spot instance constraint. * [Warm pool, the set of machines kept ready ahead of demand](/glossary/warm-pool): the pool abstraction that maintains prepared machines or disks at a target count. * [Cold start, the startup work a prepared disk moves off the request path](/glossary/cold-start): the steps between a queued job and its first executed line, and which of them a prepared disk removes. * [WarpBuild standby disks documentation](/docs/ci/byoc/standby-disks): the configuration surface for pool size, the instance state a waiting disk sits in, and the cost mechanics. * [WarpBuild BYOC documentation](/docs/ci/byoc): connecting a cloud account and defining the custom runners a pool attaches to. * [Amazon EC2 instance lifecycle](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-lifecycle.html): the instance states, which of them are billed, and what a stopped instance retains. * [GitHub self-hosted runners reference](https://docs.github.com/en/actions/reference/runners/self-hosted-runners): ephemeral registration, autoscaling guidance, and the queue behavior behind step 1. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a standby disk? A storage volume that was prepared before any request for it arrived and is held ready to be attached. It already carries an installed operating system, a filesystem that has completed first boot, and whatever agent the machine is meant to run, so a machine attaching it reaches userspace without repeating that work. ### How does a standby disk differ from a machine image or a snapshot? A machine image and a snapshot are templates held in storage and copied when something needs them. A standby disk is a live volume that was already materialized from one of those templates, so the copy step has finished and the volume is one attach operation away from running. ### Does a standby disk cost money while nothing is running on it? Provisioned storage is billed continuously, and compute is billed only while a machine is running. AWS documents this split for EC2: an instance in the stopped state is not billed for usage or data transfer, while charges are incurred for the storage of any Amazon EBS volumes. --- # Subnet | WarpBuild URL: https://www.warpbuild.com/glossary/subnet Description: A subnet is a range of IP addresses inside a cloud network, bound to one zone and to a route table that decides whether machines in it reach the internet. # Subnet A subnet is a range of IP addresses inside a cloud network, bound to one zone and to a route table that decides whether machines in it reach the internet. A subnet is a range of IP addresses carved out of a larger cloud network, attached to one availability zone and to a route table that decides its reachability. Every machine launched into the subnet takes one address from that range and inherits those routes, so the subnet sets both how many machines can run at once and whether they can reach the public internet. Both of those properties matter for GitHub Actions. A self hosted runner is a machine, the machine needs an address, and the job running on it needs to reach GitHub, package registries, and internal services. ## [Definition](#definition)Copy Anchor Link A subnet is a subdivision of a virtual network's address space, written in CIDR notation. Three things travel with it: a range of addresses, a placement inside the provider's failure domains, and a set of routes. The wording differs across providers and the shape holds. | Provider | What a subnet is scoped to | Reserved addresses per IPv4 range | Smallest IPv4 range | | ------------ | ----------------------------------------------- | -------------------------------------------------- | ------------------- | | AWS | One Availability Zone, and it cannot span zones | 5: the first four and the last | /28 | | Google Cloud | One region | 4: the first two, the second to last, and the last | /29 | | Azure | A virtual network, which lives in one region | 5: the first four and the last | /29 | Sources: [Subnets for your VPC](https://docs.aws.amazon.com/vpc/latest/userguide/configure-subnets.html) and [subnet CIDR blocks](https://docs.aws.amazon.com/vpc/latest/userguide/subnet-sizing.html) on AWS, [subnets](https://docs.cloud.google.com/vpc/docs/subnets) on Google Cloud, and the [virtual network FAQ](https://learn.microsoft.com/en-us/azure/virtual-network/virtual-networks-faq) on Azure, all checked on 2026-08-13. ### [Reserved addresses and usable capacity](#reserved-addresses-and-usable-capacity)Copy Anchor Link The reserved addresses are the part people forget when sizing a range. AWS documents that the first four IP addresses and the last IP address in each subnet CIDR block cannot be assigned to a resource: the network address, the VPC router, the DNS server address, one held for future use, and the broadcast address. AWS allows a subnet between a `/28` and a `/16` netmask. That arithmetic decides how many machines can exist in the subnet at the same time. | Subnet CIDR | Total addresses | Reserved by AWS | Usable | Single interface machines that fit | | ----------- | --------------- | --------------- | ------ | ---------------------------------- | | /28 | 16 | 5 | 11 | 11 | | /26 | 64 | 5 | 59 | 59 | | /24 | 256 | 5 | 251 | 251 | | /22 | 1,024 | 5 | 1,019 | 1,019 | | /20 | 4,096 | 5 | 4,091 | 4,091 | A machine with a second network interface consumes a second address, and load balancers, endpoints, and container networking modes that give each pod an address draw from the same pool. Running out of addresses shows up as a launch failure rather than a slow job, so the ceiling is worth checking before a fleet is scaled up. ### [Routes decide reachability](#routes-decide-reachability)Copy Anchor Link AWS documents that each subnet must be associated with a route table, which specifies the allowed routes for outbound traffic leaving the subnet, and that every new subnet is associated with the VPC main route table until the association is changed. The subnet type follows from that table: * **Public subnet**: the route table has a direct route to an internet gateway, and resources in it can reach the public internet. * **Private subnet**: no direct route to an internet gateway, so resources need a NAT device to reach the public internet. * **Isolated subnet**: no routes to destinations outside the virtual network at all. Azure follows the same pattern with system routes plus optional user defined route tables attached to a subnet. Google Cloud differs: it creates a subnet route for each range automatically and applies other routes at the network level, selecting them with instance tags rather than binding a table to one subnet. Address assignment is separate from routing. On AWS, whether a network interface created in the subnet gets a public IPv4 address is a modifiable subnet attribute, and a launch can override it. A machine with no public address in a subnet whose route table points at a NAT device still reaches the internet outbound, and nothing on the internet can open a connection to it. ### [Zones, regions, and how many subnets you need](#zones-regions-and-how-many-subnets-you-need)Copy Anchor Link Because an AWS subnet sits in exactly one Availability Zone, spreading a workload across three zones means three subnets, each with its own CIDR block and its own route table association. Capacity for a given instance type is also decided per zone, so a fleet pinned to one subnet is pinned to the capacity of one zone. ## [Example](#example)Copy Anchor Link A self hosted GitHub Actions runner does not need an inbound path. The runner agent opens an outbound HTTPS connection to GitHub and waits for work on it, so a private subnet whose route table sends outbound traffic through a gateway is enough, and no machine in the fleet needs a public address. The route table on that private subnet has two entries, plus a third when a gateway endpoint exists: | Destination | Target | Effect | | --------------------------- | ------------------------------ | -------------------------------------------------------------- | | 10.0.0.0/16 | local | Traffic inside the virtual network stays inside it | | 0.0.0.0/0 | NAT gateway in a public subnet | Outbound internet traffic leaves through the gateway's address | | Storage service prefix list | Gateway endpoint | Traffic to that service follows the endpoint route instead | A job scheduled onto a runner in that subnet sees the result directly: Copy Text ``` name: integration on: pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Show which addresses the job is using run: | hostname -I curl -s https://api.ipify.org - name: Reach an internal service run: curl -sf http://db.internal:5432 || true - run: ./scripts/integration-test.sh ``` `hostname -I` prints an address from the subnet's CIDR block, such as `10.0.12.37`. The `curl` to an echo service prints the NAT gateway's public address instead, because that is the address the outbound traffic is translated to. Every runner in the subnet presents that same source address, which is what makes an allowlist on an internal database or a partner API practical for a fleet whose machines are created and destroyed constantly. Sizing the range for this fleet is the arithmetic from the table above. A team that wants 250 runners in one zone at peak needs at least 250 usable addresses there, so a `/24` fits and a `/25` does not. The [AWS BYOC configuration checklist](/docs/ci/byoc/aws/config) asks for the same shape: at least one public and one private subnet with internet connectivity, a NAT gateway on the private subnets, three public and three private subnets across separate availability zones, and a minimum of 250 addresses per subnet. Two failure modes follow from getting this wrong. A range that is too small stops launching machines at peak, and jobs queue while the fleet looks healthy. A private subnet with no route to a NAT device launches machines that come up, fail to register with GitHub, and are torn down, which reads as a provisioning problem rather than a routing one. ## [Related Terms](#related-terms)Copy Anchor Link * [VPC and the address space a subnet is carved out of](/glossary/vpc): the network a subnet subdivides, and the CIDR planning that sits above it. * [NAT gateway and the outbound path a private subnet depends on](/glossary/nat-gateway): how machines with no public address reach the internet, and the source address they present. * [Network layout for GitHub Actions runners in your own AWS account](/byoc/aws/networking): subnet placement, security groups, and route tables for a runner fleet. * [AWS BYOC configuration checklist](/docs/ci/byoc/aws/config): the subnet, NAT gateway, and address count prerequisites for a runner stack. * [Runner networking documentation](/docs/ci/features/networking): joining runners to a private network so jobs can reach internal services. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a subnet in a cloud network? A subnet is a range of IP addresses carved out of a larger virtual network. Every machine launched into it takes an address from that range, and the routes attached to the subnet decide where traffic leaving those machines is allowed to go. AWS scopes a subnet to a single Availability Zone, while Google Cloud subnets are regional. ### What is the difference between a public subnet and a private subnet? The difference is one route. AWS documents a public subnet as one whose route table has a direct route to an internet gateway, and a private subnet as one without that route, where resources need a NAT device to reach the public internet. Nothing about the address range itself changes. ### How many IP addresses does a subnet need for GitHub Actions runners? One address per running machine, plus the addresses the provider reserves. AWS reserves the first four and the last address in every subnet CIDR block, so a /24 offers 251 usable addresses and a /28 offers 11\. A fleet that scales to 250 concurrent runners does not fit in a /25. --- # Task Graph | WarpBuild URL: https://www.warpbuild.com/glossary/task-graph Description: A task graph is the dependency graph a build tool computes over its tasks. How the graph fixes execution order, what runs in parallel, and what is skipped. # Task Graph A task graph is the dependency graph a build tool computes over its tasks. How the graph fixes execution order, what runs in parallel, and what is skipped. A task graph is the directed acyclic graph a build tool computes over the tasks it has been asked to run, with one node per task and one edge per dependency between tasks. That graph fixes three things about the run: the order tasks execute in, which tasks are allowed to run at the same time, and which tasks can be answered from a stored result instead of being executed. Every build tool that manages more than one package keeps a structure of this shape. Some call the nodes tasks, some call them targets, and some call them actions. The vocabulary moves between tools; the three decisions the graph drives stay the same. ## [Definition](#definition)Copy Anchor Link A node is one unit of work bound to one package or target, written in most tools as a pair such as `api#build` or `web#test`. An edge is a "must finish first" relation pointing from the dependency to the dependent, so an edge from `utils#build` to `api#build` means the second cannot start until the first has a result. Edges come from two places. Task-to-task rules inside a package produce the local edges, such as a test task that requires its own build task. The package dependency graph, read from the manifests, produces the cross package edges: if `web` imports `utils`, then the build task of `web` depends on the build task of `utils`. The graph must be acyclic. A cycle describes a set of tasks that each wait on another, so no valid order exists, and build tools reject the configuration with a cycle error rather than picking one arbitrarily. | Term | What it names | What changes when it moves | | ------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | Node | One task bound to one package or target | Adding a task to a package adds nodes across the whole graph, one per package that defines it. | | Edge | A "must finish first" relation between two nodes | A new dependency between packages serializes work that used to run at the same time. | | Wave | The set of nodes whose dependencies are all satisfied at a given moment | The size of the wave is the ceiling on how much can run at once right then. | | Critical path | The longest chain of edges from any entry node to any exit node | The floor on wall clock time. More cores cannot make a run shorter than this chain. | | Inputs | The files, task definition, declared environment variables, and upstream hashes folded into a node's hash | Any one of them changing gives the node a new hash and forces execution. | | Outputs | The files and logs a node records when it executes | These are what a later run replays when the hash matches. | | Affected set | The subgraph reachable from the nodes whose inputs changed | This is the work a run actually has to do after a small commit. | ### [What the graph decides](#what-the-graph-decides)Copy Anchor Link Order comes from a topological sort. The tool repeatedly takes the nodes with no unsatisfied dependencies, runs them, and moves on, which guarantees no task sees a half built dependency. Parallelism comes from the width of each wave. Two nodes with no path between them are independent and may run at once, bounded by the concurrency the tool was given. The critical path is the part width cannot help with: a chain of four nodes takes at least four task durations on any machine. Reuse comes from hashing. Before executing a node the tool computes a hash over that node's inputs, including the hashes of its dependencies, and looks for a stored result. A hit replays the recorded outputs and logs. This is why an edge is more than an ordering constraint: it carries the upstream hash into the downstream one, so a changed package invalidates everything downstream of it and nothing else. ### [The task graph and the workflow job graph](#the-task-graph-and-the-workflow-job-graph)Copy Anchor Link GitHub Actions maintains its own graph at the job level. Jobs with no `needs` key start together, and a job that lists `needs` waits for every job it names to finish first ([GitHub workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidneeds), checked on 2026-08-13). A matrix expands one job definition into many, up to 256 jobs per workflow run, and `max-parallel` caps how many of those run at once ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). The two graphs sit at different altitudes and answer to different owners. | Property | Workflow job graph | Build tool task graph | | -------------------- | ----------------------------------------------- | ------------------------------------------------------------- | | Unit of work | One job on one machine | One task inside one package | | Where it is declared | needs keys written by hand in the workflow file | Derived by the tool from its config and the package manifests | | When it is computed | Before the run starts, from the workflow file | At invocation, from the state of the checkout | | Typical size | Tens of nodes | Hundreds to tens of thousands of nodes | | How work is skipped | if conditions evaluated per job | Input hashes matched against stored results | ## [Example](#example)Copy Anchor Link Take a repository with four packages. `web` imports `ui` and `utils`, `api` imports `utils`, and every package defines a `build` task and a `test` task. A monorepo task runner config states the two edge rules once, and the tool applies them across every package: Copy Text ``` { "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "test": { "dependsOn": ["build"], "inputs": ["src/**", "test/**"] } } } ``` `^build` means the build task of every package this package depends on. `build` without the caret means the build task of this same package. Those two lines expand into eight nodes and their edges. The workflow runs the whole graph inside one job, and checks out full history so the tool can compare the branch against the merge base: Copy Text ``` name: build on: pull_request: jobs: build-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npx turbo run build test --filter=...[origin/main] ``` Now trace one pull request that edits a single file in `utils`. The tool hashes every node, finds that only the `utils` subtree moved, and treats the rest of the graph as answered. | Node | Waits on | Wave | Hash vs the base branch | What the run does | | ----------- | --------------------- | ---- | ----------------------- | ----------------------------------- | | ui#build | nothing | 1 | unchanged | Replays the stored dist/\*\* output | | utils#build | nothing | 1 | changed | Executes | | ui#test | ui#build | 2 | unchanged | Replays the stored result and logs | | utils#test | utils#build | 2 | changed | Executes | | api#build | utils#build | 2 | changed | Executes | | web#build | ui#build, utils#build | 2 | changed | Executes | | api#test | api#build | 3 | changed | Executes | | web#test | web#build | 3 | changed | Executes | Two behaviors show up in that table. Wave 2 holds four independent nodes, so a runner with enough cores executes them in one pass, while a two core machine runs them in two passes and the run takes longer for reasons that have nothing to do with the code. And the `ui` branch is downstream of nothing that changed, so both of its nodes replay from stored results and never compile anything, which is the whole payoff of computing the graph before running it. The critical path here is `utils#build` to `web#build` to `web#test`. That chain of three sets the floor on how fast this pull request can be verified, and the way to shorten it is to remove an edge from the graph rather than to add machines. Where the graph meets a GitHub Actions run is a sizing decision. One job on one large runner keeps the whole graph in a single process, so waves fill the cores of that machine and the tool's local cache is already warm. Splitting the graph across matrix jobs adds machines and adds startup, checkout, and cache restore to each shard. [Monorepo pipelines on GitHub Actions](/solutions/monorepo-github-actions) covers that tradeoff with worked numbers. When a tool dispatches nodes to a pool of worker machines rather than running them locally, the mode is [remote execution](/glossary/remote-execution), and the graph is what tells the coordinator which nodes are ready to dispatch. ## [Related Terms](#related-terms)Copy Anchor Link * [Monorepo pipelines on GitHub Actions](/solutions/monorepo-github-actions): how a task graph is split across jobs and matrix shards, and what each split costs in startup and cache restore time. * [Remote execution and worker pools](/glossary/remote-execution): the mode where a build tool sends ready graph nodes to worker machines and fetches back only the outputs it needs. * [Running only the tests a change affects](/guides/test-impact-analysis-github-actions): selecting the affected subgraph on a pull request and the failure modes of getting the base commit wrong. * [WarpBuild caching documentation](/docs/ci/features/caching): cache action inputs and key examples for holding a tool's local cache between runs. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a task graph in a build tool? The directed acyclic graph a build tool computes over the tasks it was asked to run. Each node is one task bound to one package or target, and each edge says that one task must finish before another starts. The graph fixes the order, the set of tasks that may run at the same time, and the set whose results can be reused. ### How is a task graph different from the job graph in a GitHub Actions workflow? The job graph is declared by hand with the needs key and its unit is a whole job on a whole machine. The task graph is derived by the build tool from its config and the package manifests, and its unit is one task inside one package. A single job on one runner usually executes hundreds of task graph nodes. ### Why does a task graph skip tasks? The tool hashes each node's inputs, which include the source files it matches, the task definition, declared environment variables, and the hashes of the nodes it depends on. When a hash matches a stored result, the tool replays the recorded outputs and logs instead of executing the task, so a change in one package leaves the untouched branches of the graph alone. --- # Test Sharding | WarpBuild URL: https://www.warpbuild.com/glossary/test-sharding Description: Test sharding splits a test suite into disjoint slices that run on separate machines at the same time, then merges the shard results into one verdict. # Test Sharding Test sharding splits a test suite into disjoint slices that run on separate machines at the same time, then merges the shard results into one verdict. Test sharding splits a test suite into disjoint slices, called shards, and runs each shard on a separate machine at the same time. Every test lands in exactly one shard, and the per shard results are merged afterwards into a single pass or fail verdict for the commit. Sharding trades total machine time for wall clock time. The same tests still execute, and every shard repeats the fixed setup work of a job, so the sum of machine minutes goes up while the time a developer waits for a verdict goes down. ## [Definition](#definition)Copy Anchor Link A sharding scheme is defined by three properties. Break any one of them and the run stops being a faithful substitute for executing the whole suite on one machine. 1. **Disjoint and complete.** The shards form a partition of the suite. Every test appears in exactly one shard, and the union of the shards is the entire suite. Overlap burns minutes twice and double counts flaky results. A gap lets an untested change ship behind a green check. 2. **Deterministic.** The same suite at the same shard count produces the same assignment on every run. A failure reported by shard 3 can then be reproduced by running shard 3 alone on a laptop. 3. **Merged reporting.** Each shard reports its own outcome, so the workflow needs a step that collects the per shard results into one status. Without it, a passing summary can hide a red shard. Most test runners express the split as an index and a total, written `--shard=2/4` for the second of four slices. Runners without such a flag are sharded from the outside: the workflow generates the file list, splits it, and passes one slice to the test command as arguments. ### [How tests are assigned to shards](#how-tests-are-assigned-to-shards)Copy Anchor Link | Strategy | How the split is computed | Balance | Stability as tests change | | ----------------- | ------------------------------------------------------------------------ | --------------------------------------------------- | --------------------------------------------- | | File order | Sort the file list, then deal files round robin or in contiguous blocks | Poor when a few files hold most of the runtime | Adding one file shifts every later assignment | | Hash of test id | Hash each test name or path, take the result modulo the shard count | Good on large suites, uneven on small ones | Stable, only the changed tests move | | Duration weighted | Read recorded per test durations and pack shards to equal predicted time | Best available, and only as good as the timing data | Shifts whenever the timings are refreshed | | Directory or tag | Assign by folder, suite name, or a marker on the test | Depends entirely on how the tests are organized | Stable until the directories are reorganized | The wall clock of the job set is the duration of the slowest shard, so balance decides the result more than the shard count does. A four shard split where one shard inherits 25 minutes of a 40 minute suite finishes in about 27 minutes, while an even split of the same suite finishes in about 12 minutes. Duration weighted assignment exists to close that gap. ### [Related forms of parallelism](#related-forms-of-parallelism)Copy Anchor Link Two nearby mechanisms are often confused with sharding. Worker level parallelism runs several test processes on a single machine, sized by the vCPU count of that machine. The workers share one checkout and one dependency install, so they add no extra setup cost, and their ceiling is the size of that machine. A [build matrix](/glossary/build-matrix) fans one job definition out over a list of configurations, such as three language versions or two architectures. Each leg runs the whole suite under a different configuration. Sharding borrows the same matrix syntax to fan out over slices of a single configuration, which is why the two get mixed up. ### [Why the wall clock flattens](#why-the-wall-clock-flattens)Copy Anchor Link Every shard repeats the fixed work of a job: provisioning a machine, checking out the repository, restoring caches, and installing dependencies. Call that fixed cost F and the pure test time T. One job takes F plus T. N shards take F plus T/N at best, and the machine time billed across the fleet is N times F, plus T. Returns flatten once T/N falls near F. ## [Example](#example)Copy Anchor Link A four shard split in GitHub Actions is a matrix over the shard index. Setting `fail-fast: false` keeps the other three shards running when one of them fails, which is what makes the merged report worth reading. Copy Text ``` name: test on: push: jobs: test: runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npx jest --shard=${{ matrix.shard }}/4 --reporters=default --reporters=jest-junit env: JEST_JUNIT_OUTPUT_NAME: results-${{ matrix.shard }}.xml - uses: actions/upload-artifact@v4 if: always() with: name: results-${{ matrix.shard }} path: results-${{ matrix.shard }}.xml merge: needs: test if: always() runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/download-artifact@v4 with: pattern: results-* merge-multiple: true path: results - run: ./scripts/merge-junit.sh results ``` The matrix creates four jobs from one definition, each carrying a different value of `matrix.shard`. The test command receives that index and the total, so the third job runs the third quarter of the suite and skips the rest. Artifact names carry the shard index because version 4 of the upload action rejects two uploads under one name in the same run ([actions/upload-artifact](https://github.com/actions/upload-artifact), checked on 2026-08-13). `if: always()` on the upload step preserves the report from a failing shard, and the same condition on the merge job lets it run after a red shard instead of being skipped. The merge job pulls every artifact matching the pattern into one directory and produces a single result file, which is the merged reporting property from the definition. The [matrix sharding guide](/guides/matrix-sharding-github-actions) walks through the shard aware command and the merge step in full. ### [What the split does to the clock](#what-the-split-does-to-the-clock)Copy Anchor Link Take a suite that needs 40 minutes of pure test time, with 2 minutes of setup per job, split evenly: | Shards | Test minutes per shard | Job wall clock | Total machine minutes | Setup share | | ------ | ---------------------- | -------------- | --------------------- | ----------- | | 1 | 40 | 42 | 42 | 2 of 42 | | 2 | 20 | 22 | 44 | 4 of 44 | | 4 | 10 | 12 | 48 | 8 of 48 | | 8 | 5 | 7 | 56 | 16 of 56 | | 16 | 2.5 | 4.5 | 72 | 32 of 72 | Wall clock drops from 42 minutes to 12 at four shards and to 4.5 at sixteen, while machine minutes rise from 42 to 72\. Setup is what bends the curve: at sixteen shards, 32 of the 72 machine minutes go to work that has nothing to do with running tests. Two documented limits cap the fan out. A single matrix produces at most 256 jobs per workflow run, and jobs beyond the concurrency ceiling of the account wait in the queue rather than starting ([GitHub Actions limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). A split wider than the available concurrency serializes itself, and the wall clock stops improving while the machine minutes keep climbing. ## [Related Terms](#related-terms)Copy Anchor Link * [Shard a test suite across a GitHub Actions matrix](/guides/matrix-sharding-github-actions): the shard index matrix, a shard aware test command, and the merge job written out end to end. * [Build matrix](/glossary/build-matrix): the matrix syntax that sharding borrows, and how fanning out over configurations differs from fanning out over slices. * [Sharding pytest suites across runners](/guides/pytest-parallel-test-sharding): worker processes on one machine against shards across jobs, and how the two levers combine. * [GitHub workflow syntax for strategy.matrix](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstrategymatrix): the documented behavior of `matrix`, `include`, `exclude`, and `fail-fast`. * [WarpBuild reports documentation](/docs/ci/features/reports): per job duration and queue time metrics, which are the inputs a duration weighted split needs. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner labels and machine sizes a shard matrix can target. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between test sharding and running tests in parallel? Parallel workers run several test processes on one machine and share its checkout, its installed dependencies, and its vCPU budget. Sharding splits the suite across separate machines, so each shard pays its own setup cost and the ceiling is the number of jobs that can run at once rather than the size of one machine. Large suites usually use both. ### How many shards should a test suite use? Add shards while the pure test time per shard stays well above the fixed setup time of a job. A suite with 40 minutes of tests and 2 minutes of setup per job lands around four to eight shards. Past that point the total machine minutes climb quickly while the wall clock barely moves. ### What happens when one shard fails? With fail-fast set to false the remaining shards keep running, so one run reports every failure in the suite instead of stopping at the first. The merge job needs if always() so it still collects artifacts from failed shards, otherwise the merged report is skipped whenever a shard is red. --- # vCPU | WarpBuild URL: https://www.warpbuild.com/glossary/vcpu Description: A vCPU is the virtual processor a cloud instance exposes to its guest operating system, usually one hardware thread of a core. Why build tools count them. # vCPU A vCPU is the virtual processor a cloud instance exposes to its guest operating system, usually one hardware thread of a core. Why build tools count them. A vCPU (virtual CPU) is one virtual processor that a cloud instance exposes to its guest operating system, appearing as a single entry in the processor list the guest can schedule threads onto. On most x86 instances a vCPU is one hardware thread of a physical core, so a machine advertised with 4 vCPU commonly holds 2 cores that run 2 threads each. The number matters in GitHub Actions because build tools read it. Compilers, test runners, and task graphs size their worker pools from the processor count they detect at startup, so the vCPU count of the machine a job lands on sets the parallelism of that job even when nobody configured a worker count. ## [Definition](#definition)Copy Anchor Link A hypervisor divides one physical host among several guests. The guest kernel sees a set of logical processors and schedules threads onto them, and the hypervisor maps each of those logical processors onto time on the physical hardware. Each logical processor presented to the guest is a vCPU. It is an allocation unit for scheduling, and the cloud provider bills and quotas by it. The mapping from vCPU to hardware follows simultaneous multithreading (SMT), which lets several threads run concurrently on one physical core. AWS documents each thread as a vCPU on the instance and gives `m5.xlarge` as the worked case: two CPU cores with two threads per core, for four vCPUs in total ([Amazon EC2 CPU options](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-optimize-cpu.html), checked on 2026-08-13). Google Cloud uses the same model and states that each hardware thread is called a virtual CPU, adding that for Arm processors Compute Engine uses one thread per core and each vCPU maps to a physical core with no SMT ([Compute Engine CPU platforms](https://cloud.google.com/compute/docs/cpu-platforms), checked on 2026-08-13). Both halves of that rule show up in the same catalog: | Instance shape | Physical cores | Threads per core | vCPUs reported | | ------------------------------------------------- | -------------- | ---------------- | -------------- | | m5.xlarge, x86 with SMT on | 2 | 2 | 4 | | m5.xlarge launched with threads per core set to 1 | 2 | 1 | 2 | | m6g.large, AWS Graviton | 2 | 1 | 2 | | Arm machine type on Compute Engine | n | 1 | n | Core and thread counts are from AWS's [supported CPU options table](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/cpu-options-supported-instances-values.html), checked on 2026-08-13, where every Graviton family lists a default of 1 thread per core. ### [What the number does not tell you](#what-the-number-does-not-tell-you)Copy Anchor Link A vCPU count is a quantity with no quality attached. It carries no clock speed, no processor generation, no cache size, and no memory bandwidth, so two machines advertising 8 vCPU can finish the same compile in different times. It also hides topology. Two vCPUs that are sibling threads on one core share that core's execution units and caches, so saturating both delivers less throughput than two threads on two separate cores. An Arm instance with 4 vCPU and no SMT holds four full cores, while an x86 instance with 4 vCPU and SMT holds two. ### [Reading the count from inside a job](#reading-the-count-from-inside-a-job)Copy Anchor Link Several numbers are available inside a running job, and they answer different questions. `nproc` prints the processing units available to the current process, which can be fewer than the online processors when an affinity mask has pinned the process to a subset. `/proc/cpuinfo` lists every logical processor the kernel sees. In Node, the documentation says `os.cpus().length` should not be used to calculate available parallelism and points at `os.availableParallelism()` instead ([Node os module](https://nodejs.org/api/os.html), checked on 2026-08-13). That function wraps libuv's `uv_available_parallelism()`, which on Linux inspects the calling thread's CPU affinity mask to see whether it has been pinned ([libuv miscellaneous utilities](https://docs.libuv.org/en/v1.x/misc.html), checked on 2026-08-13). A CPU quota is a separate mechanism from an affinity mask. Docker's `--cpus="1.5"` guarantees a container at most one and a half CPUs of the host ([Docker resource constraints](https://docs.docker.com/engine/containers/resource%5Fconstraints/), checked on 2026-08-13), and it does that by capping CPU time rather than by shortening the processor list. A tool that counts entries in that list therefore reads the host's number and starts one worker per entry while the job holds a much smaller share of real CPU time. ## [Example](#example)Copy Anchor Link This workflow never mentions a worker count. The parallelism comes entirely from the vCPU count of whatever machine claims the job. Copy Text ``` name: build on: push: branches: [main] jobs: compile: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: nproc - run: make -j"$(nproc)" - run: npx jest ``` GitHub documents its standard hosted Linux runner for private repositories as 2 processors with 8 GB of RAM, and the same label on a public repository as 4 processors with 16 GB ([GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13). The file above therefore behaves differently on the two shapes without a single line changing. On the private-repository shape, `nproc` prints 2, `make` runs two recipes at a time, and Jest starts one worker, because its documented default in a single run is the number of cores available minus one for the main thread ([Jest CLI options](https://jestjs.io/docs/cli), checked on 2026-08-13). On the public-repository shape the same commands give four recipes and three workers. ### [What that means on a shared runner](#what-that-means-on-a-shared-runner)Copy Anchor Link Now put the job in a container or a slice that holds a quota rather than a dedicated machine. The tool reads the processor list, which still describes the host, and sizes its pool from a number the job cannot use. A pool of 63 Jest workers on a 2 vCPU budget runs 63 module graphs in memory while the scheduler hands out two cores of time, so the job gets slower and the memory ceiling is the first thing to break. Pinning the number turns parallelism into a property of the workflow: Copy Text ``` jobs: compile: runs-on: ubuntu-latest env: MAKEFLAGS: -j2 steps: - uses: actions/checkout@v4 - run: make - run: npx jest --maxWorkers=50% ``` Jest documents the percentage form for environments where the CPU count varies, which keeps the pool proportional to the machine instead of fixed. The defaults worth knowing before pinning anything: | Tool | Default parallelism | Where to set it | | ---------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------- | | GNU make | serial, and unlimited when \-j is passed with no number | \-j or the MAKEFLAGS environment variable | | Jest | cores minus one in a single run, half of the cores in watch mode | \--maxWorkers= or \--maxWorkers=% | | Node code calling os.cpus().length | every logical processor the host reports | os.availableParallelism(), which honors the affinity mask | Once the worker counts are explicit, a change in runner size becomes a deliberate edit in two places rather than a silent change in behavior. ## [Related Terms](#related-terms)Copy Anchor Link * [How to choose a GitHub Actions runner size](/answers/how-do-i-choose-a-github-actions-runner-size): how to pick a vCPU count from measured CPU, memory, and disk utilization instead of guessing. * [Instance type](/glossary/instance-type): the vendor label that fixes the vCPU count, memory, and storage of a machine. * [Runner utilization](/glossary/runner-utilization): how much of an allocated vCPU count a job actually uses, and how that is measured. * [Amazon EC2 CPU options](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-optimize-cpu.html): the vendor definition of a vCPU as a thread of a core, with threads-per-core settings. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner labels available, with the vCPU and memory shape behind each one. * [Runner observability documentation](/docs/ci/features/observability): where per-job CPU utilization is reported. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a vCPU? A vCPU is one virtual processor a cloud instance exposes to the guest operating system, appearing as a single entry in the processor list the guest schedules work onto. On most x86 instances one vCPU is one hardware thread of a physical core, so a 4 vCPU machine is commonly 2 cores running 2 threads each. ### Is a vCPU the same as a physical CPU core? Usually not on x86\. AWS documents each thread of an SMT-capable core as a vCPU, so an m5.xlarge with 2 cores and 2 threads per core reports 4 vCPUs. On Arm hardware the mapping is one to one: AWS Graviton instance types default to 1 thread per core, and Compute Engine documents one thread per core for Arm processors. ### Why does my build tool spawn more workers than the runner has vCPUs? Most tools size their worker pool from the processor list they read at startup. A cgroup CPU quota limits how much CPU time the process receives without shortening that list, so a tool can read the host's processors and start one worker per entry while the job holds a much smaller share. Pin the worker count with a flag instead. --- # VM Snapshot | WarpBuild URL: https://www.warpbuild.com/glossary/vm-snapshot Description: A VM snapshot is a saved copy of a virtual machine's disk at a point in time. What it captures, what it drops at boot, and how a job boots from one. # VM Snapshot A VM snapshot is a saved copy of a virtual machine's disk at a point in time. What it captures, what it drops at boot, and how a job boots from one. A VM snapshot is a saved copy of a virtual machine's disk, and in some implementations its memory and CPU state, recorded at one point in time. A machine started later from that copy comes up with the filesystem exactly as it stood at capture, so the work already done on that disk is present before the first command runs. In GitHub Actions the appeal is direct. A job that installs the same dependency tree on every run repeats work that a previous machine already finished, and a snapshot lets a later job begin from a machine where that work has already happened. ## [Definition](#definition)Copy Anchor Link A virtual machine reads and writes a virtual disk, which the hypervisor backs with a file or a block device. A snapshot records the contents of that disk at an instant. The record can be a full copy of every block, though implementations more often store a pointer to an earlier copy plus the blocks that changed since it, which is why the second snapshot of a machine is usually far smaller than the first. Most systems capture the disk without pausing the machine, using copy-on-write. Once the capture starts, new writes go to a fresh layer while the frozen blocks stay readable, so the snapshot is consistent with the disk at the instant it began rather than with the disk as it looks when the capture finishes. Snapshots therefore form a chain: a base image, then a delta, then another delta, each one readable only alongside the layers beneath it. Two kinds of capture exist, and the difference decides what a restored machine can do. A disk-only snapshot records the virtual disk alone. Restoring it is equivalent to cutting power to the machine and booting the saved disk, which is why this kind is described as crash consistent: a file half written when the capture ran is half written in the snapshot. Block storage snapshots from the major clouds work this way, including [Amazon EBS snapshots](https://docs.aws.amazon.com/ebs/latest/userguide/ebs-snapshots.html) and [Google Compute Engine disk snapshots](https://cloud.google.com/compute/docs/disks/snapshots) (checked on 2026-08-13). A full state snapshot adds the contents of RAM, the CPU registers, and virtual device state. A machine restored from one resumes mid-instruction with its processes still running. This kind is common in desktop hypervisors and in live migration, and it is rare in build infrastructure because the captured memory is large and a resumed process holds stale network connections. The practical consequence for a build machine is a short list of what survives a disk-only capture. | State on the machine | Present after booting the snapshot | Why | | -------------------------------------------------------------------- | ------------------------------------ | ----------------------------------------------- | | Installed system packages and language toolchains | Yes | Written to the filesystem the snapshot recorded | | Dependency directories such as node\_modules, vendor/, and \~/.cargo | Yes | Ordinary files on the captured disk | | Package manager caches under $HOME | Yes | Ordinary files on the captured disk | | A git working tree and its .git directory | Yes | Ordinary files on the captured disk | | Container images already pulled into the local image store | Yes | Stored on disk by the container runtime | | Files written under /tmp | No, on systems that clear it at boot | A restore is a boot, and the clear runs again | | Running processes, listening ports, and started containers | No | A disk-only capture holds no process state | | Contents of RAM, including a database page cache | No | Memory is outside a disk-only capture | | Hostname, MAC address, and cloud instance metadata | No | Assigned fresh to the new machine at boot | Four neighboring terms get used interchangeably and should not be. * **Image.** A prepared disk built by a pipeline and published as a versioned artifact for many machines to boot from. A snapshot is captured from one running machine rather than built from a definition, and it is usually short lived. * **Backup.** An independent copy stored elsewhere on a retention schedule, sized to survive the loss of the original storage. A snapshot chained to a live disk does not meet that bar. * **Checkpoint.** A full state capture taken so execution can resume from the same instant. Every checkpoint is a snapshot; most snapshots are disk-only and are not checkpoints. * **Clone.** A new machine created from a snapshot or an image. The snapshot is the record; the clone is the machine that reads it. Because a snapshot occupies storage for as long as it is kept, providers charge for it by time and expire it on a retention window. That turns the interesting question from whether a snapshot can be taken into how often it should be refreshed and how long the captured state stays close enough to the current commit to be worth booting. ## [Example](#example)Copy Anchor Link The pattern in GitHub Actions is a capture after the expensive preparation step, followed by later jobs that boot from the captured disk. This workflow installs a dependency tree, builds, tests, removes credentials, then captures the machine under an alias: Copy Text ``` name: build on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-2x;snapshot.key=web-deps steps: - uses: actions/checkout@v5 - run: npm ci - run: npm run build - run: npm test - name: Remove credentials before capture run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - uses: WarpBuilds/snapshot-save@v1 with: alias: web-deps ``` The label suffix and the capture action follow the syntax in the [snapshot runners documentation](/docs/ci/features/snapshot-runners), checked on 2026-08-13. The first run finds no saved disk for the alias `web-deps`, so the machine boots the base image and every step does its full work. The final step captures the disk after `npm ci` has populated `~/.npm` and `node_modules`, and after the checkout has written the `.git` directory into the workspace. The next run boots that captured disk, so the machine reaches its first step with the state below already in place. | At the first step | Machine booted from the base image | Machine booted from the snapshot | | ----------------------------------------- | ---------------------------------- | --------------------------------------- | | node\_modules | Absent | Present as captured | | \~/.npm package cache | Empty | Populated from the earlier install | | .git objects in the workspace | Absent | Present from the earlier checkout | | Container images pulled by an earlier job | Absent | Present in the local image store | | Scratch files under /tmp | Absent | Absent, cleared during boot | | A database started by an earlier job | Absent | Absent, no process survives the capture | Two details decide whether the pattern holds up. The capture takes whatever is on disk, so an SSH key or a cloud credential file written during the job lands in the saved copy and reaches every machine that later boots it, which is what the cleanup step above removes. And the captured tree ages against the repository: once a lockfile changes, the versions the install step asks for sit outside the captured cache and the step fetches them again, so a stale alias quietly stops helping and needs a fresh capture. ## [Related Terms](#related-terms)Copy Anchor Link * [Snapshot runners and the label options that select them](/runners/snapshot): the `snapshot.enabled` and `snapshot.key` label suffixes, the platforms that accept them, and the retention window. * [What carries over in a runner snapshot](/answers/what-carries-over-in-a-runner-snapshot): the state a later job finds on disk and the state it has to rebuild. * [Warm caches and what keeps a cache warm between jobs](/glossary/warm-cache): the broader idea a snapshot is one implementation of. * [Snapshot runners documentation](/docs/ci/features/snapshot-runners): capture syntax, cleanup guidance, and the supported platforms. * [Cloud runners documentation](/docs/ci/cloud-runners): the runner labels a workflow can point `runs-on` at. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is the difference between a VM snapshot and a backup? A snapshot is a point-in-time record of a virtual disk that usually lives beside the disk it came from and often depends on earlier blocks in a chain. A backup is an independent copy held somewhere else, kept on a retention schedule, and designed to survive the loss of the original storage. A snapshot restores a machine quickly; a backup survives the failure of the system that held the machine. ### Does a VM snapshot include the contents of memory? Only when the implementation captures machine state as well as disk. Block storage snapshots from cloud providers record the virtual disk alone, so a machine that boots from one comes up as though it had been powered off and started again. Running processes, listening ports, page caches, and anything held only in RAM are gone, and any service the job needs has to be started again. ### Why does a job still reinstall dependencies after booting from a snapshot? The snapshot holds the filesystem as it was at capture time, so an install step still runs and still checks its inputs. What changes is where the bytes come from: a populated package manager cache on the restored disk answers the request locally instead of over the network. A lockfile change after the capture puts the requested versions outside the captured cache, and the step goes back to fetching them. --- # VPC (Virtual Private Cloud) | WarpBuild URL: https://www.warpbuild.com/glossary/vpc Description: A VPC is an isolated virtual network inside a cloud account, with its own address range, subnets, and routing rules. What it holds and why builds run in one. # VPC (Virtual Private Cloud) A VPC is an isolated virtual network inside a cloud account, with its own address range, subnets, and routing rules. What it holds and why builds run in one. A VPC (virtual private cloud) is an isolated virtual network inside a cloud account, with its own private IP address range, subnets that divide that range, and routing rules that decide which traffic stays inside and which leaves. Every instance a cloud provider launches for you sits in one, and the VPC configuration decides what that instance can reach and what can reach it. For a GitHub Actions workload the practical consequence is reachability. A build step that installs from an internal package registry or runs migrations against a private database needs a machine inside the network where those endpoints live, and the VPC is that network. ## [Definition](#definition)Copy Anchor Link Each major cloud provider ships the same construct under its own name. * **AWS** calls it a VPC and defines it as a virtual network dedicated to your AWS account, logically isolated from other virtual networks in the cloud ([What is Amazon VPC](https://docs.aws.amazon.com/vpc/latest/userguide/what-is-amazon-vpc.html), checked on 2026-08-13). * **Google Cloud** calls it a VPC network: a virtual version of a physical network implemented inside Google's production network ([VPC network overview](https://cloud.google.com/vpc/docs/vpc), checked on 2026-08-13). * **Azure** calls it a virtual network, or VNet, and describes it as the fundamental building block for a private network in Azure ([Azure Virtual Network overview](https://learn.microsoft.com/en-us/azure/virtual-network/virtual-networks-overview), checked on 2026-08-13). The names differ and the parts are the same everywhere. | Part | What it decides | AWS resource | | --------------------- | --------------------------------------------------------------- | ----------------------------------------------------- | | Address range | The pool of private IP addresses available to everything inside | VPC IPv4 CIDR block | | Subnets | How the range is split, and which zone a machine lands in | Subnet | | Route tables | Where traffic for a given destination is sent | Route table | | Gateways | Whether and how traffic reaches the public internet | Internet gateway, NAT device | | Private service paths | Whether provider service traffic stays off the public internet | A private route to AWS services configured on the VPC | | Packet filters | Which flows are allowed between machines and out of the network | Security group, network ACL | ### [The address range constrains everything downstream](#the-address-range-constrains-everything-downstream)Copy Anchor Link A VPC is created with a private IPv4 CIDR block. On AWS that block can be between `/16` and `/28`, and every subnet is carved from it. AWS also reserves five addresses in each subnet CIDR: the first four and the last. | CIDR | Total addresses | Usable in an AWS subnet | | ---- | --------------- | ----------------------- | | /16 | 65,536 | 65,531 | | /20 | 4,096 | 4,091 | | /24 | 256 | 251 | | /26 | 64 | 59 | | /28 | 16 | 11 | Two limits follow from that arithmetic. A fleet of short lived machines consumes an address per running machine, so a subnet sized at `/26` supports 59 concurrent instances and no more, whatever the compute quota says. And the range has to avoid overlapping with any network it is later peered or tunneled to, because overlapping ranges cannot be routed between. AWS documents default quotas of 5 VPCs per Region and 200 subnets per VPC, both adjustable on request ([Amazon VPC quotas](https://docs.aws.amazon.com/vpc/latest/userguide/amazon-vpc-limits.html), checked on 2026-08-13). ### [Scope: what a VPC spans](#scope-what-a-vpc-spans)Copy Anchor Link Scope differs by provider and it changes how the network is laid out. | Provider | VPC scope | Subnet scope | | ------------ | ---------------------------- | --------------------------------------------- | | AWS | One Region | One Availability Zone | | Google Cloud | Global, spanning all regions | One region | | Azure | One region | Within the VNet address space, spanning zones | On AWS the zone binding is the part that shapes a build fleet. Capacity for a given instance type is per zone, so a fleet restricted to one subnet is restricted to one zone's capacity. Spreading subnets across three Availability Zones spreads that risk. ### [Public subnets and private subnets](#public-subnets-and-private-subnets)Copy Anchor Link The distinction is a routing property rather than a subnet setting. A subnet whose route table sends `0.0.0.0/0` to an internet gateway is public, and machines in it can hold public IP addresses. A subnet whose default route points at a NAT device is private: outbound connections work, and nothing on the public internet can open a connection inward. Private placement has a metered cost, because NAT devices are billed by the hour and by the gigabyte processed. In US East (N. Virginia) AWS charges $0.045 per hour per device plus $0.045 per GB processed in each direction ([Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13). Configuring S3 access directly on the VPC keeps that traffic off the metered path. ## [Example](#example)Copy Anchor Link Take an existing VPC in `us-east-1` with the range `10.20.0.0/16`, three private subnets holding the build machines and three public subnets holding the NAT devices. | Subnet | CIDR | Availability Zone | Default route | What runs there | | --------- | -------------- | ----------------- | ---------------------- | -------------------------- | | private-a | 10.20.0.0/20 | us-east-1a | NAT device in public-a | Build machines | | private-b | 10.20.16.0/20 | us-east-1b | NAT device in public-b | Build machines | | private-c | 10.20.32.0/20 | us-east-1c | NAT device in public-c | Build machines | | public-a | 10.20.240.0/24 | us-east-1a | Internet gateway | NAT device, load balancers | | public-b | 10.20.241.0/24 | us-east-1b | Internet gateway | NAT device, load balancers | | public-c | 10.20.242.0/24 | us-east-1c | Internet gateway | NAT device, load balancers | An internal package registry and a staging database already run in this VPC and resolve through a private hosted zone. A self hosted runner launched into `private-a` is inside the same address range, so the job below reaches both over the VPC's local route with no public exposure of either service. Copy Text ``` name: integration on: push: branches: [main] jobs: test: runs-on: [self-hosted, linux, x64] steps: - uses: actions/checkout@v4 - name: Install from the internal registry run: npm ci --registry https://npm.internal.example.com - name: Apply migrations against the staging database run: psql "postgres://db.internal.example.com:5432/app" -f migrations.sql - name: Show where this machine sits run: | TOKEN=$(curl -sX PUT http://169.254.169.254/latest/api/token \ -H "X-aws-ec2-metadata-token-ttl-seconds: 60") curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \ http://169.254.169.254/latest/meta-data/local-ipv4 ``` The final step prints a `10.20.x.x` address, which is the machine's place in the range above. Each destination the job touches takes a different path out of the subnet. | Destination in the job | Path | Leaves the VPC | | ------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------- | | npm.internal.example.com at 10.20.x.x | Local route inside the VPC | No | | db.internal.example.com at 10.20.x.x | Local route inside the VPC | No | | github.com and api.github.com | Default route to the NAT device, then the internet gateway | Yes, and the bytes are metered | | An S3 bucket in the same Region | A direct route to S3 when the VPC is configured for it, otherwise the NAT path | No when the direct route is configured | Run the same job on a machine outside this VPC and the first two steps fail at DNS resolution, because neither hostname exists in public DNS and neither address is routable from outside. The usual workarounds all widen the network surface: publishing the registry, allowlisting a changing set of source addresses, or proxying through a bastion host. Placing the machine in the subnet removes the question, and the security group on that machine becomes the control that decides what the build is allowed to open. ## [Related Terms](#related-terms)Copy Anchor Link * [Subnets and the address block each one carves out of a VPC](/glossary/subnet): how a range is divided, and why the split decides zone placement and concurrency headroom. * [Running GitHub Actions runners inside your own VPC](/guides/github-actions-runners-in-your-vpc): the network shape a build fleet needs, the outbound allowlist, and who pays for what. * [Networking requirements for BYOC runners on AWS](/byoc/aws/networking): subnet layout, route tables, and endpoint configuration for a runner stack in your own account. * [What is Amazon VPC](https://docs.aws.amazon.com/vpc/latest/userguide/what-is-amazon-vpc.html): the AWS definition, the components, and the default VPC behavior. * [VPC and subnet requirements for an AWS stack](/docs/ci/byoc/aws/config): the public and private subnet layout, NAT device placement, and address headroom to plan for. * [Runner networking documentation](/docs/ci/features/networking): connecting a runner to a private network for the length of one job. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a VPC in plain terms? A VPC is a private network you own inside a cloud provider's account. It has an IP address range you choose, subnets that carve that range into smaller blocks, route tables that decide where traffic goes, and gateways that decide whether traffic can leave. Every instance the provider launches for you sits in one. ### What is the difference between a VPC and a subnet? The VPC holds the whole address range and the routing configuration. A subnet is one block of that range with its own route table, and on AWS a subnet lives in exactly one Availability Zone. Machines are placed in subnets rather than in the VPC directly, so subnet choice decides the zone and the default route a machine gets. ### Why would build machines run inside a VPC? Because the services a build step needs often have no public address. A machine in the same VPC reaches an internal package registry or a database endpoint directly over the private range, which leaves both services unpublished and removes the proxy that would otherwise sit in front of them. Outbound access to the public internet is then a separate routing decision. --- # Warm Cache | WarpBuild URL: https://www.warpbuild.com/glossary/warm-cache Description: A warm cache is a cache that already holds the entries a run needs, so the run restores them and skips rebuilding them. What keeps a cache warm. # Warm Cache A warm cache is a cache that already holds the entries a run needs, so the run restores them and skips rebuilding them. What keeps a cache warm. A warm cache is a cache that already holds the entries a run needs, so the run restores them and skips the work of building them again. A cache is warm for a given run only when the key that run computes finds a matching entry in a store the run is allowed to read. Warmth is therefore a property of a lookup at a moment in time. The same store can be warm for one job and cold for the job beside it, because the two jobs compute different keys or read from different scopes. ## [Definition](#definition)Copy Anchor Link A build cache is a keyed store. The workflow computes a key from its inputs, asks the store for an entry under that key, and either receives one or receives nothing. A hit means the run unpacks the entry into the paths it named and skips the step that would have produced those files. A miss means the run does the work and usually writes a new entry so the next run has something to find. Warm names the state where that lookup hits. Cold names the state where it misses. Neither word describes the machine, the provider, or the workflow file. Both describe what the store contained at the moment the run asked. That distinction matters because teams often describe a whole pipeline as warm or cold when the reality is per key. A job can restore its npm directory from a hit and then rebuild its Rust target directory from a miss in the same five minutes. Counting warmth per lookup is what makes a cache problem findable. ### [The four conditions behind a hit](#the-four-conditions-behind-a-hit)Copy Anchor Link A lookup succeeds when four separate conditions hold. Any one of them failing produces a miss, and the four fail for different reasons and leave different traces in the log. | Condition | What the store checks | Why it fails | | ------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | | Key | An entry exists under the exact key this run computed | Any input inside the key changed since the entry was written, such as a lockfile hash or an operating system prefix | | Compatibility | The entry was written for the same path set and with the same compression tool | The entry was saved on a different platform or with a different list of paths, so the store treats it as a separate entry | | Scope | The entry is visible to this run's branch | The entry was written on a sibling branch, which a run cannot read | | Retention | The entry is still in the store | The entry aged past the retention window, or a size ceiling evicted it in least recently used order | GitHub documents all four for its own cache implementation: keys and `restore-keys`, the cache version derived from the compression tool and the cached paths, the rule that a run reads entries from its own branch and its base branch, the 7 day window after last use, and the 10 GB default ceiling per repository with least recently used eviction above it ([GitHub dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13). ### [Partial warmth](#partial-warmth)Copy Anchor Link Most cache actions accept a list of fallback prefixes alongside the primary key. When the primary key misses, the store looks for the most recent entry whose key starts with one of those prefixes and restores that instead. The result sits between warm and cold. A dependency directory restored from last week's entry still holds most of the packages the current lockfile asks for, so the install step runs and fetches the difference rather than the whole tree. The step still runs, and the work it does is smaller. Two details follow from that and both catch people out. The hit output most actions expose stays false after a fallback restore, because it reports an exact primary key match only, so a step guarded on that output will still execute. And a save on top of a fallback restore carries forward whatever the older entry held, including files the current inputs no longer need, so entries drift upward in size across generations. ### [What keeps a cache warm](#what-keeps-a-cache-warm)Copy Anchor Link Three properties decide whether a cache is warm on the next run, and each maps to a decision someone made in the workflow file. **Key stability.** A key derived from inputs that change on every commit guarantees a miss on every run. A commit SHA, a build number, and a timestamp all have this property. A key derived from a lockfile hash changes only when the dependency set changes, which is what makes the entry reusable across the commits in between. The rule of thumb is that a key should change exactly when the cached content should change. **Scope.** Cache entries are partitioned so that one branch cannot read another branch's writes. A run reads entries written on its own branch and entries written on the branch it was created from. So the first run on a new branch is warm only if some earlier run on the base branch wrote an entry the fallback prefixes can reach. A workflow that writes entries on pull request branches only leaves every new branch cold. **Retention.** Stores drop entries on two rules. Age since last use is the common one, and a size ceiling with least recently used eviction is the other. Both reward frequency. A key restored every weekday keeps resetting its own clock and stays warm indefinitely. A key touched once a month is cold every time it is asked for, whatever the workflow file says. ## [Example](#example)Copy Anchor Link Take a Node repository that caches its npm download directory. The workflow computes a primary key from the lockfile hash and lists one fallback prefix. Copy Text ``` name: test on: push jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore npm cache id: npm-cache uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - run: npm ci - run: npm test ``` The workflow is new, so the store holds nothing for this repository yet. A developer creates the branch `feature/checkout-copy` from `main` and pushes to it three times. **Run 1, the first push on the new branch.** The restore step computes `Linux-npm-1a2b3c` and finds nothing. The fallback prefix `Linux-npm-` also finds nothing, because no run has ever written an entry. The log reads `Cache not found for input keys`, and `npm ci` downloads every package in the tree. The post-run save step writes the entry under `Linux-npm-1a2b3c`. **Run 2, the second push on the same branch.** The commit edits one source file and leaves `package-lock.json` alone, so the key is still `Linux-npm-1a2b3c`. The entry from run 1 is there, on the same branch, minutes old. The lookup hits, the log reads `Cache restored from key: Linux-npm-1a2b3c`, and `npm ci` installs from the restored directory with no downloads. The post-run save step skips writing, because an entry already exists under that exact key. That contrast is the whole idea: the same workflow file, the same machine size, and the difference between the two runs is what the store held when the run asked. **Run 3, a dependency added.** `package-lock.json` changes, so the key becomes `Linux-npm-4d5e6f` and misses. The fallback prefix `Linux-npm-` matches the run 1 entry and restores it. The install fetches the new package and revalidates the rest. The save step writes a fresh entry under the new key, so the branch now has two entries. The table below tracks the same store across five runs, including a sibling branch and the merge that seeds the default branch. | Run | Trigger | Primary key | Fallback prefix | State | Written after the run | | --- | --------------------------------------------------- | ----------- | ----------------------- | ----------- | ------------------------------- | | 1 | First push on feature/checkout-copy | miss | miss | cold | Linux-npm-1a2b3c | | 2 | Source-only push on the same branch | hit | not used | warm | nothing, the key already exists | | 3 | Lockfile bump on the same branch | miss | hit on Linux-npm-1a2b3c | partly warm | Linux-npm-4d5e6f | | 4 | First push on feature/error-copy, created from main | miss | miss | cold | Linux-npm-4d5e6f on that branch | | 5 | Push on main after the merge | miss | miss | cold | Linux-npm-4d5e6f on main | Rows 4 and 5 are the scope rule in action. The sibling branch cannot read entries written on `feature/checkout-copy`, so it starts cold even though an identical entry exists a few commits away. Once run 5 writes the entry on `main`, every branch created from `main` can reach it through the fallback prefix, and the first push on a new branch lands warm. Seeding the default branch is the single change that moves most first-run misses. Retention closes the loop. If the team leaves this repository alone for longer than the store's window, every entry ages out and run 6 is cold again on a workflow nobody edited. Cache misses that appear without a workflow change are usually retention or eviction rather than key drift. One boundary is worth stating plainly. A restore repopulates the paths the workflow named and leaves the rest of the machine as it found it. Files outside those paths, such as a container image store or a compiler's incremental output directory, are gone when the machine is discarded, however warm the cache was. Carrying that state forward is a machine level mechanism rather than a cache lookup: the [WarpBuild caching documentation](/docs/ci/features/caching) covers the store side, and the [snapshot runner documentation](/docs/ci/features/snapshot-runners) covers booting a job from a disk image captured during an earlier run. ## [Related Terms](#related-terms)Copy Anchor Link * [Cold start](/glossary/cold-start): the run that finds nothing to restore, and the work it pays as a result. * [Artifact cache](/glossary/artifact-cache): the file level cache a workflow keys itself, and how its lifetime is decided. * [Persistent caches for GitHub Actions](/guides/persistent-caches-github-actions): carrying state across runs when the machine is destroyed after every job. * [Snapshot runners](/runners/snapshot): booting a job from a saved disk image so machine state survives between runs. * [WarpBuild caching documentation](/docs/ci/features/caching): cache keys, scopes, and the actions that read and write entries. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a warm cache? A warm cache is a cache that already holds the entries a run needs, so the run restores them and skips the work that produced them. Warmth is measured per lookup: a cache is warm for a run when the key that run computes finds a matching entry in a store the run is allowed to read. ### What makes a cache cold? Four things, and any one of them is enough. The key changed, so no entry exists under it. The entry was written with a different path set or compression tool, so the store treats it as a different entry. The entry lives in a scope this run cannot read, such as a sibling branch. Or the entry aged out of the store or was evicted to make room. ### How do you keep a cache warm across runs? Keep the key stable, keep the scope shared, and keep the entry in use. Derive the key from inputs that change when the cached content changes, such as a lockfile hash, rather than from a commit SHA or a timestamp. Write the entry on the default branch so every branch created from it can fall back to that copy. And restore it often enough that it stays inside the retention window. ### Is a warm cache the same as a warm machine? No. A warm cache is a store the run reads from over the network into named paths. A warm machine is a runner whose disk already carries the state, including files no cache key covers, such as a populated container image store or a build tool's incremental output directory. A cache restore repopulates the paths you named and nothing else. --- # Warm Pool | WarpBuild URL: https://www.warpbuild.com/glossary/warm-pool Description: A warm pool is a set of machines kept booted and ready ahead of demand, so a queued GitHub Actions job attaches to one and skips the cold boot. # Warm Pool A warm pool is a set of machines kept booted and ready ahead of demand, so a queued GitHub Actions job attaches to one and skips the cold boot. A warm pool is a set of machines kept booted and ready ahead of demand, so that a queued job attaches to one of them and skips the wait for a cold boot. The startup work is paid in advance by the pool rather than at the moment someone pushes a commit. Every pool is the same trade written at a different size. Capacity sits idle so that arriving work finds a machine already there, and the standing cost of that idle capacity buys back the seconds a job would otherwise spend waiting for hardware to exist. ## [Definition](#definition)Copy Anchor Link Three properties make a pool warm. Machines are provisioned before any job asks for them. Each machine is held somewhere along the startup path rather than at the beginning of it. And the pool is drawn down by arriving work and refilled behind it, on a loop that runs continuously. The word warm describes how far along that path a machine already sits. Startup is a sequence, and a pool can stop at any point in it. Where the pool stops decides both what a job still waits for and what the idle capacity bills for. | Readiness stage | What has already happened | What an arriving job still waits for | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | Cold | Nothing. The capacity request reaches the provider when the job queues | Instance selection, allocation, kernel boot, network and disk attach, agent install, registration | | Prebuilt image | A disk image exists with the operating system and toolchain baked in | Allocation, boot, network and disk attach, registration | | Booted then stopped | A machine was created and booted once, so device setup and network configuration completed, then stopped without being destroyed | Power on, registration | | Booted and idle | The machine is running with its agent registered and waiting for work | Nothing. The job attaches | Reading the table downward, each row moves work out of the job's critical path and into a standing cost. Reading it upward, each row gives back some of that standing cost and hands the delay to whoever pushed the commit. ### [What the standing cost looks like](#what-the-standing-cost-looks-like)Copy Anchor Link The arithmetic of a pool is short: Copy Text ``` standing cost = pool depth x hourly rate of whatever is held x hours held ``` The rate in that expression depends entirely on the readiness stage. A pool of prebuilt images holds storage, so it bills for storage. A pool of machines booted and then stopped holds an attached disk while the compute is off, so it bills mostly for that disk. A pool of running idle machines holds compute, which is the most expensive row in the table and also the only one where a job waits for nothing at all. ### [Depth, drain, and refill](#depth-drain-and-refill)Copy Anchor Link Depth is sized against concurrent demand rather than daily volume. Ten thousand jobs a day that never overlap need a depth of one. A single pull request whose test job fans out to six shards on the same machine type needs a depth of six, because those six jobs enter the queue in the same second. Refill is the second number, and it is the one that usually gets missed. A pool that refills one machine a minute, drained by a burst of six, is back to full six minutes later. Whether that matters depends on how far apart the bursts arrive. Take a fleet with these assumptions, stated so they can be replaced with real ones: * Every pull request push queues six jobs on the same machine type. * The pool depth is six. * Refill produces about one prepared machine a minute and runs continuously. One push every ten minutes is comfortably served. The burst empties the pool, refill restores all six within six minutes, and the next burst finds a full pool. Two pushes five minutes apart are served differently: the second burst arrives with five machines restored, five of its six jobs attach immediately, and the sixth falls back to cold provisioning. Average demand of 0.6 machines a minute sits under a refill rate of 1.0 a minute, so the fleet keeps up over an hour while individual jobs still miss during the bursts. Depth absorbs the shape of a burst, and refill rate decides whether the pool survives a sequence of them. ### [A pool of machines and a warm cache are different things](#a-pool-of-machines-and-a-warm-cache-are-different-things)Copy Anchor Link Both are described as warm, and they hold different objects. A warm cache holds data, such as package archives, compiler output, or container layers, and it removes work from inside a job. A warm pool holds machines, and it removes waiting from before the job starts. A job can attach to a prepared machine in a second and then spend four minutes restoring dependencies, which is a full pool serving a cold cache. ### [What happens when the pool runs dry](#what-happens-when-the-pool-runs-dry)Copy Anchor Link A well built pool degrades rather than failing. When nothing is ready, the request falls through to cold provisioning, a fresh machine is created, and the job runs on it. The pool therefore changes when a job starts rather than whether it starts. That fallback is what makes an undersized pool hard to notice. Nothing errors, no step fails, and the only visible symptom is a `Set up job` step that takes far longer on some runs than on others. Sorting a week of `Set up job` durations for one workflow shows the two clusters plainly when a pool is undersized: a tight group at the warm attach time and a second group at the full provisioning time. Workflow authors have no depth control over pools operated by someone else. GitHub-hosted runners are served from pools GitHub runs, and the workflow file has no key that reaches them. A pool's depth and refill rate are set by whoever operates the fleet the label routes to, not by the workflow that queues a job onto it. ## [Example](#example)Copy Anchor Link This workflow fans a test suite across six shards. Six jobs enter the queue together every time a pull request is pushed: Copy Text ``` name: test on: pull_request: branches: [main] jobs: test: runs-on: [self-hosted, linux, x64, test-pool] strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npx jest --shard=${{ matrix.shard }}/6 ``` Nothing in the file mentions a pool. The job asks for a label, GitHub routes it to a runner advertising every label in the list, and the pool sits behind that label as an implementation detail of whoever supplies the machines. Adding, resizing, or removing the pool changes no line of this workflow. Running the pool at a depth of six with a refill rate of about one machine a minute, and pushing twice within five minutes, produces this sequence: | Time | Event | Prepared machines in the pool | Jobs on a cold machine | | ------------ | ------------------------------------------------------------ | ----------------------------- | ---------------------- | | 0:00 | First push queues six jobs | 6 | 0 | | 0:01 | All six attach to prepared machines | 0 | 0 | | 0:02 to 0:05 | Refill restores machines one at a time | 1 to 4 | 0 | | 5:00 | Second push queues six more jobs | 5 | 0 | | 5:01 | Five attach immediately, the sixth waits for a fresh machine | 0 | 1 | | 5:02 to 5:06 | Refill restores machines behind the second burst | 1 to 5 | 1 | Eleven of the twelve jobs started on prepared machines and one paid the full provisioning path. That single slow shard sets the wall clock for the whole matrix, because a fan-out finishes when its slowest leg finishes. Raising the depth to twelve covers back to back pushes, and raising the refill rate covers a longer sequence of them. Implementations differ in which readiness stage they hold and in how the pool is sized. One documented shape for runners in your own cloud account holds each machine booted and then shut down, with the count configured per runner type, described in the [standby disks documentation](/docs/ci/byoc/standby-disks). ## [Related Terms](#related-terms)Copy Anchor Link * [What a cold start is on a GitHub Actions job](/glossary/cold-start): the parts of the startup delay a pool removes, and the parts that stay behind inside the job. * [Standby disks for bring-your-own-cloud runners](/byoc/standby-disks): how a pool of prepared machines is configured for a runner type and what it costs on your own cloud account. * [Removing cold starts from GitHub Actions jobs](/guides/github-actions-cold-starts): how to measure each part of the setup stack from the job log before choosing a fix. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a warm pool? A warm pool is capacity prepared before anything asks for it. Machines are provisioned ahead of demand and held part way or all the way through the startup path, so an arriving job attaches to a machine that has already booted rather than waiting for one to be created, booted, and registered. ### How deep should a warm pool be? Depth is sized against concurrent demand rather than daily volume. Count the jobs that enter the queue together on the same machine type, usually one matrix fan-out, and set depth to that number. Then check the refill interval against how often those bursts arrive, because a pool that refills slower than bursts arrive runs dry whatever its depth. ### What happens when the warm pool is empty? The request falls back to cold provisioning. A fresh machine is created, booted, and registered, and the job runs on it as usual, so the pool changes when a job starts rather than whether it starts. The visible signature is a Set up job step that takes far longer on some runs than others. --- # Workflow Dispatch | WarpBuild URL: https://www.warpbuild.com/glossary/workflow-dispatch Description: workflow_dispatch is the GitHub Actions trigger that starts a workflow run on demand from the Actions tab, the GitHub CLI, or the REST API, with typed inputs. # Workflow Dispatch workflow\_dispatch is the GitHub Actions trigger that starts a workflow run on demand from the Actions tab, the GitHub CLI, or the REST API, with typed inputs. `workflow_dispatch` is the GitHub Actions trigger that lets a person or an API call start a workflow run on demand, instead of the run waiting for a push, a pull request, or a schedule. A workflow that declares the trigger can be started from the repository's Actions tab, from the GitHub CLI, or through the REST API, and it accepts typed inputs that the workflow file declares up front. The manual path and the programmatic path are the same event. However the run starts, GitHub sets `github.event_name` to `workflow_dispatch` and hands the job the input values that were supplied. ## [Definition](#definition)Copy Anchor Link The trigger is declared under the `on` key, and declaring it on its own is enough to make a workflow dispatchable: Copy Text ``` on: workflow_dispatch: ``` Inputs are optional and live under `on.workflow_dispatch.inputs`. Each input has an id, and under that id you set some combination of `description`, `required`, `default`, and `type`. The documented types include `string`, `boolean`, `choice` (which also takes an `options` list), and `environment`. GitHub caps the block at 25 top-level properties under `inputs`, with a maximum input payload of 65,535 characters ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13). Three rules decide whether a dispatch works at all. **The workflow file must be on the default branch.** GitHub states that "to trigger the `workflow_dispatch` event, your workflow must be in the default branch" ([manually run a workflow](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow), checked on 2026-08-13). A workflow that adds the trigger on a feature branch shows no Run workflow button until the branch merges, which is the usual reason a correct-looking file appears to do nothing. **Every dispatch names a git reference.** The REST endpoint takes a `ref` parameter, described in the API reference as "the git reference for the workflow. The reference can be a branch or tag name" ([REST API endpoints for workflows](https://docs.github.com/en/rest/actions/workflows), checked on 2026-08-13). The Actions tab exposes the same choice as a branch picker beside the Run workflow button. **Input values arrive in two contexts with different typing.** GitHub documents that "the information in the `inputs` context and `github.event.inputs` context is identical except that the `inputs` context preserves Boolean values as Booleans instead of converting them to strings" ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13). A condition written against `github.event.inputs` therefore compares strings, and the string `false` is a non-empty string, so it evaluates as true. The three entry points differ in what they need and in how they carry input values. | Entry point | What it needs | How inputs are supplied | | ---------------------------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------- | | Run workflow button in the Actions tab | Write access to the repository in the browser | Form fields generated from the inputs block | | gh workflow run | The GitHub CLI, authenticated | \-f key=value flags, or a JSON object on standard input | | POST /repos/{owner}/{repo}/actions/workflows/{workflow\_id}/dispatches | A token with the repo scope for classic tokens | An inputs object in the JSON body, 25 properties maximum | ## [Example](#example)Copy Anchor Link This workflow declares a boolean input and a choice input, then reads both from the `inputs` context inside the job: Copy Text ``` name: Deploy on: workflow_dispatch: inputs: run_migrations: description: Apply pending database migrations before deploying type: boolean default: false target: description: Environment to deploy to type: choice default: staging options: - staging - production jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Apply migrations if: ${{ inputs.run_migrations }} run: ./scripts/migrate.sh - name: Deploy run: ./scripts/deploy.sh --target "${{ inputs.target }}" ``` Once that file is on the default branch, the Actions tab lists the `Deploy` workflow in the left sidebar with a Run workflow button above the run list. The button opens a small form: a branch picker, a checkbox for `run_migrations`, and a dropdown holding the two `options` values for `target`. The same run starts from a script with one API call. Every input value crosses the wire as a string, whatever type the workflow declared: Copy Text ``` curl -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer $TOKEN" \ https://api.github.com/repos/octo-org/octo-repo/actions/workflows/deploy.yml/dispatches \ -d '{"ref":"main","inputs":{"run_migrations":"true","target":"production"}}' ``` The GitHub CLI wraps the same endpoint and resolves the workflow by file name: Copy Text ``` gh workflow run deploy.yml --ref main -f run_migrations=true -f target=production ``` The typing difference is where dispatched runs usually go wrong. `if: ${{ inputs.run_migrations }}` reads a real boolean and skips the step when the box is unticked. Writing `if: ${{ github.event.inputs.run_migrations }}` instead reads the string `false`, which is not empty and therefore evaluates as true, so the migration step runs on every dispatch. When a workflow has to read `github.event.inputs`, compare it explicitly with `== 'true'`. Two smaller details are worth pinning down before a dispatch goes into an on-call runbook. Inputs are declared per workflow rather than per job, so a matrix that fans a dispatched run across several jobs passes the same values to all of them. And the run is attributed to the account whose credentials made the call, which is what `github.actor` reports and what shows in the run header, so a dispatch made with a shared token is harder to trace back to a person than one made from the browser. ## [Related Terms](#related-terms)Copy Anchor Link * [Workflow event, the wider set of triggers a workflow can declare](/glossary/workflow-event): how `workflow_dispatch` sits alongside `push`, `pull_request`, `schedule`, and `repository_dispatch`. * [How to run a GitHub Actions workflow manually](/answers/how-do-i-run-a-workflow-manually): the click path in the Actions tab and the equivalent GitHub CLI command. * [Nightly builds in GitHub Actions](/guides/nightly-builds-github-actions): pairing a schedule with a manual trigger so a nightly job can also be started on demand. * [WarpBuild API automation reference](/docs/ci/api-keys/automation): creating an API key and driving runner operations from a script. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What does workflow\_dispatch do? workflow\_dispatch marks a workflow as startable on demand. Once the trigger is declared, a person can start a run from the Actions tab or the GitHub CLI, and a program can start one through the REST API, passing values for the inputs the workflow file declares. ### Why is the Run workflow button missing from my workflow? GitHub's documentation states that to trigger the workflow\_dispatch event, your workflow must be in the default branch. A workflow that declares the trigger on a feature branch shows no button until that branch merges, even though the file is syntactically correct. ### How many inputs can a workflow\_dispatch workflow take? GitHub documents a maximum of 25 top-level properties under inputs and a maximum input payload of 65,535 characters. The REST endpoint applies the same 25 property maximum to the inputs object in the request body. --- # Workflow Event | WarpBuild URL: https://www.warpbuild.com/glossary/workflow-event Description: A workflow event is the repository activity that starts a GitHub Actions run: push, pull_request, schedule, or workflow_dispatch. How each one is declared. # Workflow Event A workflow event is the repository activity that starts a GitHub Actions run: push, pull\_request, schedule, or workflow\_dispatch. How each one is declared. A workflow event is the repository activity that starts a GitHub Actions workflow run, such as a `push`, activity on a `pull_request`, a `schedule` tick, or a manual `workflow_dispatch`. Every run is started by exactly one event, declared in the workflow's `on` key and readable inside a job as `github.event_name`. The event decides three things at once: whether a run happens at all, which commit the run checks out, and what data the workflow can read without calling the API. Most confusion about workflows that never trigger, or trigger twice, traces back to one of those three. ## [Definition](#definition)Copy Anchor Link An event is a signal GitHub emits when something happens in or around a repository. GitHub Actions listens for those signals and starts a workflow run when the signal matches the workflow's `on` key. The full list of events is published in [GitHub's events reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13, and it covers more than thirty names ranging from `push` and `issues` to `merge_group` and `registry_package`. Events fall into four groups by where the signal comes from. **Repository activity.** Someone pushes commits, opens a pull request, publishes a release, or comments on an issue. These are the same signals GitHub sends to webhooks, and the payload shape is identical. **Time.** The `schedule` event fires from a POSIX cron expression evaluated in UTC. The shortest interval GitHub accepts is once every five minutes, scheduled runs always take the latest commit on the default branch, and the event can be delayed during periods of high load, with the start of every hour named as a busy window. **Manual and external triggers.** `workflow_dispatch` starts a run from the GitHub UI, the REST API, or the GitHub CLI, and it can declare typed inputs with defaults. `repository_dispatch` starts a run from a POST to the repository dispatch endpoint, which is how a system outside GitHub asks for a build. **Other workflows.** `workflow_call` makes a workflow reusable by another workflow, and `workflow_run` starts a workflow when a named workflow finishes. ### [Filters narrow an event](#filters-narrow-an-event)Copy Anchor Link Declaring an event subscribes a workflow to every occurrence of it. Filters cut that down, and the filters available differ by event. | Event | What starts it | Filters it accepts | | -------------------- | -------------------------------------------- | ----------------------------------------------------------------- | | push | commits pushed to a branch or a tag | branches, branches-ignore, tags, tags-ignore, paths, paths-ignore | | pull\_request | activity on a pull request in the repository | types, branches, branches-ignore, paths, paths-ignore | | schedule | a POSIX cron expression, evaluated in UTC | none | | workflow\_dispatch | a manual run from the UI, API, or CLI | none; it takes inputs instead | | workflow\_call | another workflow calling this one | none; it takes inputs and secrets | | workflow\_run | a named workflow completing | workflows, types, branches, branches-ignore | | release | release activity such as published | types | | repository\_dispatch | a POST to the repository dispatch endpoint | types | Activity types deserve their own note. A `pull_request` event has many activity types, and a workflow that lists the event with no `types` key runs only on `opened`, `synchronize`, and `reopened`. A workflow that needs to react when a pull request is labeled or closed has to say so explicitly. ### [Two rules that surprise people](#two-rules-that-surprise-people)Copy Anchor Link A `schedule` or `workflow_dispatch` trigger has to be declared on the default branch before GitHub recognizes it at all. A block added only on a topic branch stays invisible in the Actions tab until that branch merges, with no error to explain why. Once the trigger exists, a scheduled run always executes the default branch's version of the file. A manual run can target any branch or tag instead, and it executes that chosen ref's version of the file. Runs started with the built-in `GITHUB_TOKEN` do not start further runs. A step that pushes a commit or opens a pull request using that token emits the event, and Actions ignores it, which prevents a workflow from triggering itself in a loop. ## [Example](#example)Copy Anchor Link This workflow subscribes to two events with different filters. The `push` trigger is limited to `main` and ignores documentation-only changes, and the `pull_request` trigger covers pull requests targeting `main`. Copy Text ``` name: build on: push: branches: [main] paths-ignore: - 'docs/**' - '**.md' pull_request: branches: [main] types: [opened, synchronize, reopened] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: echo "event=${{ github.event_name }} ref=${{ github.ref }}" - run: make build - name: Publish if: github.event_name == 'push' run: make publish ``` Both events run the same three build steps. The `Publish` step carries an `if` condition on `github.event_name`, so it runs on the merge to `main` and is skipped on every pull request run. That is the usual reason a workflow reads the event name at all. The two events also hand the job different values. The table below lists what the [github context](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts) exposes for four common events, checked on 2026-08-13. | Context value | push to main | pull\_request opened from feature/login | schedule | workflow\_dispatch | | ------------------ | ----------------- | --------------------------------------- | ----------------------------------- | -------------------------------- | | github.event\_name | push | pull\_request | schedule | workflow\_dispatch | | github.ref | refs/heads/main | refs/pull/42/merge | default branch ref | the ref chosen for the run | | github.sha | the pushed commit | the last merge commit on the merge ref | latest commit on the default branch | head commit of the chosen ref | | github.head\_ref | empty | feature/login | empty | empty | | github.base\_ref | empty | main | empty | empty | | github.event | the push payload | the pull request payload | the schedule payload | the dispatch payload with inputs | The `github.sha` row is the one that changes behavior. On a pull request, `actions/checkout` lands on a merge commit of the source branch and the target branch, so the build tests the merged result rather than the branch tip. A step that stamps a version from `github.sha` therefore records a commit that exists only for that run. Adding a schedule and a manual trigger to the same file needs no change to the jobs: Copy Text ``` on: push: branches: [main] schedule: - cron: '0 3 * * *' workflow_dispatch: inputs: suite: description: Test suite to run type: choice options: [smoke, full] default: smoke ``` The cron field is UTC, so `0 3 * * *` is 03:00 UTC every day and shifts against local time when daylight saving changes. The `workflow_dispatch` inputs appear as a form in the Actions tab and are readable as `github.event.inputs.suite` or through the `inputs` context. A scheduled run leaves those inputs unset, so any step that reads them needs a default. ## [Related Terms](#related-terms)Copy Anchor Link * [workflow\_dispatch, the manual trigger and its inputs](/glossary/workflow-dispatch): the input types, where a dispatch run can be started from, and the default branch rule. * [How to run GitHub Actions jobs on a schedule](/answers/how-do-i-run-jobs-on-a-schedule): cron syntax, UTC handling, and the delay behavior of scheduled runs. * [Setting up nightly builds in GitHub Actions](/guides/nightly-builds-github-actions): a schedule-driven workflow end to end, including how to keep it from running on forks. * [GitHub events reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows): the full event list with payloads and activity types. * [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): the runner labels a `runs-on` key can name once an event has started a run. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### What is a workflow event in GitHub Actions? A workflow event is the repository activity that starts a workflow run. Common events are a push to a branch or tag, activity on a pull request, a schedule tick from a cron expression, and a manual workflow\_dispatch. The events a workflow accepts are declared in its on key, and the event that started a given run is readable at run time as github.event\_name. ### Can one workflow respond to more than one event? Yes. The on key accepts a list or a mapping of several events, and each one can carry its own filters. A workflow can declare push, pull\_request, schedule, and workflow\_dispatch together. Every run still has exactly one triggering event, so steps that need to behave differently branch on github.event\_name. ### Where do I see which event started a run? The run summary page names the event, and inside a job the value is available as github.event\_name. The full webhook payload for that event sits in the github.event object, so a step can read github.event.pull\_request.number or github.event.head\_commit.message without calling the API. --- # Workflow Permissions | WarpBuild URL: https://www.warpbuild.com/glossary/workflow-permissions Description: Workflow permissions are the per scope grants on the token a GitHub Actions run uses, declared with a permissions key at workflow level or job level. # Workflow Permissions Workflow permissions are the per scope grants on the token a GitHub Actions run uses, declared with a permissions key at workflow level or job level. Workflow permissions in GitHub Actions are the per scope grants attached to the token a workflow run uses, declared with a `permissions` key at workflow level or at job level. Each scope, such as `contents` or `packages`, takes one of three values, `read`, `write`, or `none`, and every step in the run receives a token carrying exactly the grants that key resolved to. When the key is absent, the run inherits a default set outside the workflow file, at the repository or organization level. That gap between what the file says and what the run receives is where most permission surprises begin. ## [Definition](#definition)Copy Anchor Link A `permissions` block is a map from scope name to access level. The syntax and the full scope list are documented in [GitHub's workflow syntax reference](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#permissions), checked on 2026-08-13. Copy Text ``` permissions: contents: read packages: write ``` Three shorthand forms exist alongside the map. `permissions: read-all` grants read on every scope, `permissions: write-all` grants write on every scope, and `permissions: {}` grants none on every scope, which fits a job whose steps stay off the GitHub API. ### [The rule that surprises people](#the-rule-that-surprises-people)Copy Anchor Link Naming any scope in a block sets every scope left out of that block to `none`. A block written to add `packages: write` for a publish step also removes the `pull-requests: write` a later step relied on, and the failure lands in a step nobody edited. A block replaces the default set outright rather than merging with it. The same replacement applies between levels. A job level block replaces the workflow level set for that job outright rather than adding to it, so a job that needs one extra scope has to restate the scopes it already had. ### [What sits above the workflow file](#what-sits-above-the-workflow-file)Copy Anchor Link Two limits apply regardless of what the file asks for. Runs triggered by a pull request from a forked repository receive a read only token. That is the mechanism keeping a contributor's branch from pushing to the base repository or publishing under its name. An organization can pin the default and restrict who may change Actions settings, so the value a run starts from is an administrative decision made once for many repositories. GitHub documents the two settings in [configuring the default GITHUB\_TOKEN permissions](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#configuring-the-default-github%5Ftoken-permissions), checked on 2026-08-13. ### [The scopes a build usually touches](#the-scopes-a-build-usually-touches)Copy Anchor Link Write access on a scope includes the read access for that same scope. The table lists what write buys on the scopes that appear most often in build and release workflows. | Scope | What write access allows | | --------------- | ----------------------------------------------------------- | | contents | Push commits, create tags, create releases, update branches | | packages | Publish and update packages in the GitHub package registry | | pull-requests | Open, label, comment on, and close pull requests | | issues | Open, label, comment on, and close issues | | checks | Create and update check runs | | statuses | Set commit statuses on a ref | | actions | Cancel, re-run, and manage workflow runs | | deployments | Create deployments and deployment statuses | | security-events | Upload code scanning results | | id-token | Request an OpenID Connect token for cloud authentication | `id-token` is the one entry with no read level. It accepts `write` or `none`, because requesting an identity token is the only operation the scope covers. ### [The failure signature](#the-failure-signature)Copy Anchor Link A missing scope surfaces as HTTP 403 from the API with the message `Resource not accessible by integration`. The message names no scope, so diagnosis means matching the API call in the failing step against the block that produced the token. A step calling `gh pr comment` needs `pull-requests: write`, a step running `npm publish` against the GitHub registry needs `packages: write`, and `actions/checkout` needs `contents: read` on a private repository. ## [Example](#example)Copy Anchor Link This workflow publishes a package on a version tag. Two grants at the top of the file describe the whole run. Copy Text ``` name: publish on: push: tags: ['v*'] permissions: contents: read packages: write jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 registry-url: https://npm.pkg.github.com - run: npm ci - run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` `contents: read` lets `actions/checkout` fetch the tagged commit. `packages: write` lets `npm publish` push the tarball to the registry. Every other scope resolves to `none` for this run, because the block named two scopes and silence on the rest means denial. Adding a step later that comments on a pull request returns 403 until `pull-requests: write` joins the block. ### [What applies when the block is absent](#what-applies-when-the-block-is-absent)Copy Anchor Link Delete those three lines and the run still starts. What changes is the origin of the grants: the token now carries the repository default under Settings, Actions, General, Workflow permissions, or the organization value when an administrator pinned it. The permissive setting grants write access on most scopes, and the restricted setting grants read access on `contents` and `packages`. So the same file publishes successfully in a permissive repository and fails at `npm publish` in a restricted one, with no difference between the two workflow files. Writing the block down removes that dependency on a setting the file cannot see. ### [Read only default with one job widened](#read-only-default-with-one-job-widened)Copy Anchor Link The shape most release pipelines settle on is a narrow default at the top and a single job that restates what it needs. Copy Text ``` name: release on: push: branches: [main] permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test release: needs: test runs-on: ubuntu-latest permissions: contents: write packages: write id-token: write steps: - uses: actions/checkout@v4 - run: ./scripts/release.sh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` The `test` job inherits `contents: read` and nothing else. The `release` job replaces that set with three grants of its own, which is why `contents: write` has to be repeated there even though the top of the file already granted read. `id-token: write` is present so the release script can exchange an OpenID Connect token for cloud credentials instead of reading a stored key. ## [Related Terms](#related-terms)Copy Anchor Link * [GITHUB\_TOKEN, the credential these grants are attached to](/glossary/github-token): where the token comes from, how long it lives, and what a step sees when it reads `secrets.GITHUB_TOKEN`. * [How to set permissions for GITHUB\_TOKEN in a workflow](/answers/how-do-i-set-permissions-for-the-github-token): the workflow level default plus per job widening, with the scope table for checkout, package publishing, and status updates. * [Managing secrets in GitHub Actions workflows](/guides/github-actions-secrets-management): repository, environment, and organization secrets, and the OpenID Connect path that removes the stored credential. * [WarpBuild runner security documentation](/docs/ci/security): how runner isolation, storage, and secrets handling are documented. * [Running workflows from public repositories](/docs/ci/public-repos): the runner group setting a public repository workflow needs before a job is scheduled. * [WarpBuild pricing](/pricing): per minute rates by runner type. ## FAQ ### Where does a permissions block go in a workflow file? At the top level of the file, inside a single job, or both. A top level block sets the grants for every job in the run. A job level block replaces that set for one job. The common shape is a read only default at the top and one job widened to the scopes it actually calls. ### What happens when a workflow has no permissions block? The run uses the default configured for the repository, or the organization value when an administrator has pinned it. GitHub documents two choices for that default: permissive, which grants write access on most scopes, and restricted, which grants read access on contents and packages. The workflow file carries no signal about which one applies, so the same file behaves differently across repositories. ### Why does a step fail with Resource not accessible by integration? The token is missing the scope that API call requires. Naming any scope in a permissions block drops every scope left out to none, so adding one grant can remove another that a later step depended on. Match the API calls in the failing step against the scopes in the block and add the missing one. --- # GitHub Actions Guides | WarpBuild URL: https://www.warpbuild.com/guides Description: Guides for making GitHub Actions faster and cheaper: caching, queue times, cost models, runner sizing, and debugging. # GitHub Actions Guides Practical guides to build speed, cost, caching, queue times, and debugging on GitHub Actions. * [Alerting on GitHub Actions Build Time RegressionsGitHub Actions has no build time regression alert. Baseline duration P90 for a week in the WarpBuild Jobs report, then run a scheduled check on the drift.](/guides/alerting-on-ci-regressions) * [Moving GitHub Actions Builds to Native ARM64Build ARM64 artifacts natively in GitHub Actions by removing the QEMU emulation step and sending the job to a WarpBuild native ARM64 runner label.](/guides/arm64-github-actions-runners) * [An ARM64 Migration ChecklistMigrate GitHub Actions jobs to ARM64 with a four category checklist: downloaded binaries, native modules, container base images, and hardcoded runner paths.](/guides/arm64-migration-checklist-github-actions) * [Node Native Modules on ARM64 RunnersA .node addon compiled for x64 fails on ARM64 with invalid ELF header. Install on the aarch64 machine, key caches on runner.arch, rebuild what has no prebuild.](/guides/arm64-node-native-modules-github-actions) * [Building ARM64 Python WheelsBuilding aarch64 wheels on an x64 GitHub Actions runner puts every compile under QEMU. Move the aarch64 leg of the cibuildwheel matrix to a native runner.](/guides/arm64-python-wheels-github-actions) * [Automating Base Image Updates on GitHub ActionsA scheduled job resolves the new base image digest, opens a pull request, and lets your checks prove the update before the rebuild hits a feature branch.](/guides/base-image-update-workflow-github-actions) * [Bazel Disk Cache on GitHub Actions RunnersBazel's --disk\_cache is deleted with the GitHub Actions runner. Carry it forward with a keyed cache action and a size cap, or boot from a snapshot runner.](/guides/bazel-disk-cache-github-actions) * [Bazel Remote Execution from GitHub ActionsPoint Bazel at a remote execution endpoint from GitHub Actions, keep loading and analysis on the runner, and size the runner for the work that remains.](/guides/bazel-remote-execution-github-actions) * [Publishing Build Artifacts from GitHub ActionsUpload build output once with actions/upload-artifact, consume it by name in later jobs, and set retention-days to the cadence you actually roll back to.](/guides/build-artifacts-publishing-github-actions) * [Sharing a Build Cache Across RepositoriesA GitHub Actions cache entry is addressed inside one repository, so reuse across repositories runs through a registry. Patterns, permissions, and rates.](/guides/build-cache-sharing-across-repositories) * [Build Once, Deploy Many with GitHub ActionsOne build job publishes the image and emits its digest, and the test and deploy jobs pull that digest. The YAML, the wiring table, and the cost math.](/guides/build-once-deploy-many-github-actions) * [Choosing a Buildx Cache Backend on GitHub ActionsInline cache, registry cache, and a remote builder profile fail in different ways on GitHub Actions. How to pick a buildx cache backend, with rates.](/guides/buildx-cache-backends-github-actions) * [Cloud Quotas for BYOC GitHub Actions RunnersOn BYOC, cloud quotas cap concurrent GitHub Actions jobs. Size EC2, EBS, elastic IP, and NAT quotas in your stack region against your job concurrency.](/guides/byoc-capacity-and-quotas) * [Using ccache for C and C++ Builds on GitHub Actionsccache returns nothing on GitHub Actions until CCACHE\_DIR survives the job. Wire the compiler launcher, cap the size, restore and save it, price the result.](/guides/ccache-github-actions) * [Working Out CI Cost per Pull RequestCost per pull request is jobs triggered times billed minutes times the rate for each runner label, times attempts. Build it from billing rows, then cut it.](/guides/ci-cost-per-pull-request) * [Setting Build Time Budgets for GitHub ActionsA build time budget is a p50 and p95 target per workflow class. Set the targets from measured percentiles, enforce a ceiling with timeouts, price the fix.](/guides/ci-slo-and-build-time-budgets) * [Running Concurrent Docker Builds Against One CacheSeveral GitHub Actions jobs can build on one WarpBuild builder profile at once. What the shared layer cache does under load, how to size it, what it bills.](/guides/concurrent-docker-builds-github-actions) * [Cutting Container Image Size in GitHub ActionsMost of a container image is base layers and build tooling. Move the toolchain into a stage that never ships, land on a slim runtime base, and pull less.](/guides/container-image-size-reduction-github-actions) * [Running Jobs Inside a ContainerThe container key runs every step inside your image, so paths, permissions, and tooling change. Here is the setup that keeps caches and services working.](/guides/container-jobs-github-actions) * [Cross Compiling or Native ARM64 BuildsCross compile ARM64 artifacts on an x64 runner when the build links no native code and tests can stay on x64\. Build natively when cgo or ARM64 tests run.](/guides/cross-compiling-vs-native-arm64-builds) * [Triggering Workflows Across RepositoriesTwo ways to trigger a workflow in another repository: a repository dispatch event or a workflow dispatch API call. Token permissions, refs, and cost.](/guides/cross-repository-workflows-github-actions) * [Custom Runner Images for GitHub ActionsBake your toolchain into a cloud VM image, register it on a WarpBuild BYOC stack, and route GitHub Actions jobs to it with a warp-custom- runner label.](/guides/custom-runner-images-github-actions) * [How to Debug Failed GitHub Actions JobsDebug a failed GitHub Actions job by classifying the failure, reading per-instance runner metrics, then opening an SSH session on the runner itself.](/guides/debug-github-actions-failures) * [Dependency Update Jobs on GitHub ActionsDependabot and Renovate pull requests trigger the same workflows a human one does. Scope the update check, group the updates, and price the weekly batch.](/guides/dependabot-and-renovate-jobs-github-actions) * [Deployment Jobs on GitHub ActionsA deploy job needs one environment, a fixed concurrency group with cancel-in-progress off, an OIDC credential exchange, and an artifact pinned by digest.](/guides/deployment-jobs-github-actions) * [Passing Secrets into Docker Builds SafelyA --build-arg token lands in the image history. Mount it with RUN --mount=type=secret in GitHub Actions instead, then prove it is absent from every layer.](/guides/docker-build-secrets-github-actions) * [Configuring Remote Docker Builder ProfilesA builder profile pins a name, vCPU size, architecture, and disk to one Docker builder VM. Field settings, the API calls that manage it, and session costs.](/guides/docker-builder-profiles-github-actions) * [Why Your Docker Layer Cache Misses Every RunA Docker layer cache misses every run when an early layer changes: a whole-context COPY, a build arg consumed too early, lockfile churn, or a reset builder.](/guides/docker-cache-invalidation-github-actions) * [Docker Layer Caching on GitHub ActionsDocker layer caches miss on GitHub Actions because every job builds on a fresh buildx instance. Move the cache onto a builder that outlives the job.](/guides/docker-layer-caching-github-actions) * [Dockerfile Changes That Cut Build TimeMost Dockerfile build time on GitHub Actions is rebuilt work. Order instructions by change frequency, add BuildKit cache mounts, keep the cache on a builder.](/guides/dockerfile-optimization-github-actions) * [Parallel .NET Test Runs on Windows Runnersdotnet test runs assemblies one at a time by default and leaves in-assembly parallelism to the framework. Turn on both levels, then size the Windows label.](/guides/dotnet-test-parallelism-github-actions) * [What Pulling Images from ECR Costs Your PipelinePulls from ECR bill your AWS account rather than the GitHub invoice. Where registry charges land in a deploy pipeline, plus a worked monthly model.](/guides/ecr-pull-costs-github-actions) * [Speeding Up Git Checkout in GitHub ActionsGit checkout is slow in GitHub Actions when every job clones the whole repository from GitHub. WarpBuilds/checkout@v7 seeds the tree from a cached mirror.](/guides/faster-git-checkout-github-actions) * [Speeding Up Windows Builds on GitHub ActionsWindows GitHub Actions jobs stall on file-heavy checkouts, Defender scans, and NuGet restores. Diagnose each cause, then size the WarpBuild runner.](/guides/faster-windows-github-actions) * [Finding the Slow Step in a GitHub Actions WorkflowFind the slow step in a GitHub Actions workflow by splitting queue wait from execution, ranking step timestamps from the job log, then reading runner metrics.](/guides/find-the-slow-step-github-actions) * [Finding and Fixing Flaky GitHub Actions JobsA flaky GitHub Actions job passes and fails on one commit. Rank jobs by success rate, read CPU and memory percentiles, then confirm on the runner.](/guides/flaky-github-actions-jobs) * [Speeding Up Git LFS Checkouts in GitHub ActionsGit LFS is slow in GitHub Actions because lfs true pulls every object the ref points at. Fetch only the paths the job reads and price the difference.](/guides/git-lfs-checkout-github-actions) * [Tracking Build Duration Percentiles on GitHub ActionsGitHub Actions gives you one duration per run and no percentiles. The WarpBuild Jobs report shows duration P75 and P90 per repository, workflow, and job.](/guides/github-actions-build-duration-percentiles) * [The GitHub Actions Cache Size Limit, ExplainedGitHub Actions caps all caches in a repository at 10 GB by default and evicts least recently used entries first. How the ceiling works, how to remove it.](/guides/github-actions-cache-size-limit) * [Removing Cold Starts from GitHub Actions JobsA GitHub Actions cold start is the setup time before your first real step runs. Split it into five parts, then cut it with standby disks and snapshots.](/guides/github-actions-cold-starts) * [GitHub Actions Concurrency Limits, ExplainedGitHub Actions concurrency limits come from the workflow concurrency key, plan-level job ceilings, and runner pool capacity. Match the symptom to the limit.](/guides/github-actions-concurrency-limits) * [Attributing GitHub Actions Cost per Repository and TeamGitHub Actions bills minutes as account level SKU totals. Attribute cost per repository and team with per job billing rows, runner labels and BYOC tags.](/guides/github-actions-cost-attribution) * [GitHub Actions Egress Costs, ExplainedEgress charges from GitHub Actions land on your cloud bill instead of GitHub's invoice. Where they come from, how to cut them, and a worked AWS model.](/guides/github-actions-egress-costs) * [Running Managed Runners on Open Source ProjectsManaged runners reach public repositories once the Default runner group allows them. Enablement steps, fork pull request controls, and per-minute costs.](/guides/github-actions-for-open-source-projects) * [What GitHub Actions Larger Runners CostGitHub Actions larger runners bill $0.012 to $0.252 per minute on Linux x64\. See the full rate ladder, break-even math, and WarpBuild rates per size.](/guides/github-actions-larger-runners-cost) * [What macOS Runners Cost on GitHub ActionsGitHub-hosted macOS runners list at $0.062 to $0.102 per minute. WarpBuild macOS runners are $0.08 per minute for 6 vCPU and $0.16 for 12 vCPU.](/guides/github-actions-macos-runner-cost) * [Building a GitHub Actions Matrix That ScalesA GitHub Actions build matrix turns one job into one job per combination. Size the fan-out to runner capacity, shard the suite, and price every cell.](/guides/github-actions-matrix-builds) * [When a Matrix Grows Too WideA GitHub Actions matrix multiplies every dimension into jobs. Price the width, prune the pull request grid with exclude and a computed matrix, run it nightly.](/guides/github-actions-matrix-explosion) * [Querying GitHub Actions Data with an MCP ServerWarpBuild runs an MCP server at https://mcp.warpbuild.com/mcp so an agent can query your GitHub Actions runner fleet, create runners, and build images.](/guides/github-actions-mcp-server) * [Pulling GitHub Actions Metrics Through the APIPull GitHub Actions metrics from the WarpBuild API: CI billing, jobs, queue timings and daywise costs, with a wkey- key on the ci scope and CSV export.](/guides/github-actions-metrics-api) * [Setting a Minutes Budget for GitHub ActionsA GitHub Actions minutes budget is jobs per month times billed minutes per job times the rate per label, bounded by labels, timeouts, concurrency, and paths.](/guides/github-actions-minutes-budget) * [Network Timeouts in GitHub Actions JobsA GitHub Actions job that times out downloading dependencies is hitting a registry rate limit, a DNS failure, or a slow mirror. Read the log signature first.](/guides/github-actions-network-timeouts) * [When a GitHub Actions Job Runs Out of MemoryExit code 137 means the kernel out-of-memory killer stopped the job. Read the log signature, cap workers and heaps, then price the runner memory ladder.](/guides/github-actions-out-of-memory) * [GitHub Actions Queue Times and How to Fix ThemGitHub Actions queue time is the wait between a job being queued and a runner claiming it. Trace it to concurrency caps, labels, or pool capacity.](/guides/github-actions-queue-times) * [Hitting API Rate Limits Inside WorkflowsGitHub API rate limits inside GitHub Actions: which token owns the hourly budget, how to read the x-ratelimit headers, back off safely, and cut calls.](/guides/github-actions-rate-limits) * [Using Runner Groups with GitHub ActionsA runner group decides which repositories and workflows may use a set of runners. Jobs that never start are usually blocked by group access, rarely by capacity.](/guides/github-actions-runner-groups) * [Runner Level Metrics for GitHub Actions JobsGitHub Actions reports job duration and nothing about the machine. Read per instance CPU, memory, disk and queue metrics in WarpBuild, then right size.](/guides/github-actions-runner-metrics) * [No Space Left on Device in GitHub ActionsA no space left on device failure means the runner volume filled mid-job. Print usage at three points to name the consumer, then apply one of three fixes.](/guides/github-actions-runner-out-of-disk) * [Running GitHub Actions Runners in Your Own VPCRun GitHub Actions runners in your own VPC as ephemeral EC2 instances. The network shape AWS needs, the outbound allowlist, the IAM split, and the cost.](/guides/github-actions-runners-in-your-vpc) * [Managing Secrets in GitHub Actions WorkflowsStore every GitHub Actions secret at the narrowest scope that works, hand it to one step rather than one job, and delete the cloud keys OIDC replaces.](/guides/github-actions-secrets-management) * [Scaling Self-Hosted Runners Without Owning the ClusterScaling self-hosted GitHub Actions runners means owning registration, autoscaling, image rebuilds, and cleanup. The managed and BYOC paths, priced.](/guides/github-actions-self-hosted-runner-scaling) * [Gradle Configuration Cache on GitHub ActionsEnable the Gradle configuration cache and the build cache on GitHub Actions, persist both directories across jobs, and key entries by Gradle version.](/guides/gradle-configuration-cache-github-actions) * [Building for Graviton Targets on GitHub ActionsRun the GitHub Actions build and test jobs that produce your Graviton artifacts on native ARM64 runner labels, so architecture faults surface in the pipeline.](/guides/graviton-builds-github-actions) * [Choosing Between Hosted and BYOC RunnersHosted WarpBuild runners bill one per-minute rate. BYOC runners bill $0.002 per minute plus your own cloud spend. How to choose, with a worked cost model.](/guides/hosted-vs-byoc-runners) * [Idle Time Inside GitHub Actions JobsIdle minutes inside a GitHub Actions job bill like busy ones. Spot them from low CPU against a long duration, name the four sources, and price the fix.](/guides/idle-time-in-github-actions-jobs) * [Incremental Builds on GitHub ActionsIncremental builds fail on GitHub Actions because each job gets a clean VM. Boot from a WarpBuild snapshot so build outputs and compiler state survive.](/guides/incremental-builds-github-actions) * [Code Signing iOS Builds on GitHub ActionsCreate a temporary keychain in the job, import the certificate from repository secrets, install the profile, then archive on a warp-macos runner label.](/guides/ios-code-signing-github-actions) * [Running iOS Simulator Tests on GitHub ActionsRun iOS simulator tests on GitHub Actions with xcodebuild on a macOS runner. Pin the image that carries your runtime, then shard the suite at $0.08 per minute.](/guides/ios-simulator-tests-github-actions) * [Caching Jest Runs on GitHub ActionsJest re-transforms every module on a fresh GitHub Actions runner. Pin cacheDirectory, restore it with a key that survives dependency bumps, and size maxWorkers.](/guides/jest-cache-github-actions) * [Large Artifact Uploads on GitHub ActionsArtifact uploads run slow because upload-artifact zips the whole path at compression level 6 before any byte moves. Cut the file count, bytes, and minutes.](/guides/large-artifact-uploads-github-actions) * [GitHub Actions on Large RepositoriesLarge repositories run slow on GitHub Actions because every job rebuilds a cold working set. Measure the setup phases, then keep the tree warm.](/guides/large-repository-github-actions) * [Long Running Jobs on GitHub ActionsA GitHub Actions job that runs for hours meets three ceilings. Find the one that ended yours, split the work into checkpointed stages, and price both shapes.](/guides/long-running-jobs-github-actions) * [Notarizing macOS Apps in GitHub Actionsnotarytool submits a signed archive to Apple, waits for a verdict, then staples the ticket. Here is the split-job workflow and what the wait costs.](/guides/macos-notarization-github-actions) * [Freeing Disk Space on macOS RunnersA macOS Xcode job fills the runner volume with DerivedData, archives and simulator data. Measure the budget, clean up in order, then price the larger label.](/guides/macos-runner-disk-space-github-actions) * [Sharding Test Suites Across a MatrixPut a shard index in the GitHub Actions matrix, pass it to the test command, and merge the shard reports in one job. Workflow YAML and cost arithmetic.](/guides/matrix-sharding-github-actions) * [Caching Toolchains Installed with misemise reinstalls every pinned tool on a fresh GitHub Actions runner. Cache the mise data directory, key it on the config files, and verify versions first.](/guides/mise-toolchain-cache-github-actions) * [Building Several Images from One MonorepoA monorepo that rebuilds every image on every commit pays for images nobody touched. Detect the changed paths, bake only the affected targets, price both paths.](/guides/monorepo-docker-builds-github-actions) * [MSBuild Caching on GitHub Actions Windows RunnersMSBuild rebuilds every project on GitHub Actions because runners are ephemeral. Cache NuGet, keep incremental output inside one job, and skip idle analyzers.](/guides/msbuild-caching-github-actions) * [Multi Platform Docker Images on Native RunnersMulti arch Docker builds crawl on one x64 runner because the arm64 stage runs under QEMU. Build each architecture natively and join them with a manifest.](/guides/multi-platform-docker-builds) * [Publishing One Image to Several RegistriesBuild the image once, push it to a primary registry, then copy it by digest into every other target. The workflow YAML, the wiring, and the transfer math.](/guides/multi-registry-publishing-github-actions) * [Multi Stage Docker Builds and Layer ReuseMulti stage Docker builds reuse layers one stage at a time, and GitHub Actions throws that away every job. Keep the stages on a builder that outlives the job.](/guides/multi-stage-docker-builds-github-actions) * [Nightly Build Jobs on GitHub ActionsA nightly build runs the work that is too slow or too broad for a pull request check. Pick what belongs there, route failures to a person, and price the matrix.](/guides/nightly-builds-github-actions) * [Caching NuGet Packages on GitHub ActionsCache the NuGet global packages folder, which dies with the runner on every GitHub Actions run. Use WarpBuilds/setup-dotnet on Linux, actions/cache on Windows.](/guides/nuget-cache-github-actions) * [OpenTelemetry Metrics from GitHub Actions RunnersGitHub Actions reports run duration and nothing about the machine. The WarpBuild agent emits runner metrics over OpenTelemetry, grouped per job and instance.](/guides/opentelemetry-ci-metrics-github-actions) * [Running Many iOS Jobs in Parallel on GitHub ActionsFanning many iOS jobs out on GitHub Actions clears three limits: the workflow concurrency key, account job ceilings, and the macOS fleet quota.](/guides/parallel-ios-jobs-github-actions) * [Persistent Caches for GitHub Actions RunsA GitHub Actions runner is destroyed after every job. Persist state with a cache action for files, or with a snapshot runner for whole machine state.](/guides/persistent-caches-github-actions) * [Preview Environments from Pull RequestsPreview environments on GitHub Actions run on three transitions: create on open, update on push, destroy on close. Workflow YAML, concurrency, and cost.](/guides/preview-environments-github-actions) * [Sharding pytest Suites Across Runnerspytest runs parallel in GitHub Actions two ways: xdist worker processes inside one job and matrix shards across jobs. Size both levers and merge results.](/guides/pytest-parallel-test-sharding) * [Moving ARM64 Docker Builds Off QEMUA buildx arm64 build on an x64 GitHub Actions runner is slow because QEMU translates every instruction. Move it to a native ARM64 runner or builder.](/guides/qemu-vs-native-arm64-builds) * [Cutting GitHub Actions Costs Without RewritesCut GitHub Actions spend by right-sizing runners, ending re-run waste, and fixing cache misses. WarpBuild Linux runners start at $0.004 per minute.](/guides/reduce-github-actions-costs) * [Release Build Workflows on GitHub ActionsA release workflow fires on a tag, builds each target once, tests those exact files, and publishes them. Here is the YAML, the runner sizing, and the cost.](/guides/release-builds-github-actions) * [Remote Docker Builders for GitHub ActionsA remote Docker builder runs docker build on a dedicated VM with a persistent layer cache outside your GitHub Actions runner. Setup, YAML, and costs.](/guides/remote-docker-builders) * [Re-run Storms and What They CostA re-run storm bills the same commit twice or three times over. Price the re-run rate against monthly minutes, then cut it with cancellation and retries.](/guides/rerun-storms-github-actions) * [Running Reusable Workflows Across Many RepositoriesA shared workflow holds up across fifty repositories on three things: a tagged version contract, a runner label input per caller, and a staged rollout.](/guides/reusable-workflows-at-scale-github-actions) * [A Runner Label Strategy for a Growing OrgMap every workload class to one runner label, pass that label into workflows as an input, and migrate one class at a time. Catalog rows, YAML, and arithmetic.](/guides/runner-labels-strategy-github-actions) * [Right Sizing GitHub Actions RunnersGitHub Actions reports job duration and nothing about the machine. Right size runners from peak CPU, peak memory and the per job recommendation endpoint.](/guides/runner-right-sizing-github-actions) * [Rust Incremental Compilation in GitHub ActionsCargo incremental state lives in target and dies with the runner. Set CARGO\_INCREMENTAL=0 on stateless GitHub Actions jobs, or keep the target directory warm.](/guides/rust-incremental-compilation-github-actions) * [Modeling S3 Transfer Costs for GitHub ActionsS3 charges for GitHub Actions land on your AWS bill as artifact reads, fixture pulls, and deploy bundles. A worked monthly model with published AWS rates.](/guides/s3-transfer-costs-github-actions) * [Using sccache on GitHub Actions Runnerssccache turns repeat compilations into cache fetches, but only when the cache directory outlives the job. Wire it up, persist it, and read the hit counts.](/guides/sccache-github-actions) * [Security Scanning Jobs Without the WaitScan jobs run long because image pulls, database refreshes, and full-tree analysis repeat on every pull request. Split the work and cache the database.](/guides/security-scanning-jobs-github-actions) * [What Self-Hosted Runners Actually CostSelf-hosted GitHub Actions runners bill idle instance hours plus image, autoscaling, upgrade and on-call work. A worked monthly model with the arithmetic.](/guides/self-hosted-runner-total-cost) * [Service Containers for Integration TestsGitHub Actions service containers start and end with the job. Gate the first step on a real health check, then size the runner for services and test workers.](/guides/service-containers-github-actions) * [Sharing Data Between GitHub Actions JobsThree mechanisms move data between GitHub Actions jobs: job outputs for strings, artifacts for files, and cache or snapshot for runner state.](/guides/share-data-between-github-actions-jobs) * [Sharing a Docker Layer Cache Across JobsTwo GitHub Actions jobs share a Docker layer cache only when both build on one persistent builder. Point every job at the same WarpBuild builder profile.](/guides/share-docker-cache-between-jobs) * [Sizing Runners for Android BuildsSize an Android runner by subtracting the emulator first: it holds guest cores and RAM all job. Sizing table, warp- labels, the KVM label, and cost math.](/guides/sizing-runners-for-android-builds) * [Sizing Runners and Builders for Docker BuildsSize the job runner for checkout and waiting, then size the remote Docker builder for the layer cache. Two size ladders, a decision rule, and the arithmetic.](/guides/sizing-runners-for-docker-builds) * [Sizing Runners for Java and Gradle BuildsSize a Gradle runner from the memory budget first: daemon heap plus forked test heaps must fit under RAM. Sizing table, warp- labels, rates, and cost math.](/guides/sizing-runners-for-java-builds) * [Sizing Runners for Node.js Test SuitesNode test workers each hold their own heap, so memory per worker sets the runner size. Measure peak CPU and memory, then pick the warp- Linux label to match.](/guides/sizing-runners-for-node-test-suites) * [Sizing Runners for Python Test SuitesTwo limits decide the runner size for a Python suite: memory per pytest worker and the shared service ceiling. Size both against the Linux x64 ladder.](/guides/sizing-runners-for-python-test-suites) * [Sizing Runners for Rust BuildsSize Rust jobs by how wide the cargo dependency graph stays. 4 vCPU for check jobs, 8 to 16 vCPU for tests, and the point where extra cores stop paying.](/guides/sizing-runners-for-rust-builds) * [How to Speed Up GitHub Actions BuildsFaster GitHub Actions builds come from more vCPUs, warm caches, and shorter queues. Swap ubuntu-latest for a WarpBuild warp- label and see the cost math.](/guides/speed-up-github-actions) * [Spot Instances for GitHub Actions RunnersSpot instances for GitHub Actions runners come from WarpBuild BYOC in your own AWS, GCP, or Azure account, at a flat $0.002 per runner minute fee.](/guides/spot-instances-github-actions-runners) * [Static IPs for GitHub Actions RunnersStatic egress IPs for GitHub Actions runners are a BYOC capability on AWS, GCP, and Azure. Here is the setup, the address math, and the per-GB cost.](/guides/static-ip-github-actions-runners) * [Running Only the Tests a Change AffectsPick the tests a change actually affects with path filters, dependency graph selection, or coverage maps. Detection job YAML, matrix fan-out, and cost math.](/guides/test-impact-analysis-github-actions) * [TestFlight Uploads from GitHub ActionsArchive and export on a macOS runner, write an App Store Connect API key into the runner temp directory, then upload the IPA with xcrun altool on a tag.](/guides/testflight-uploads-github-actions) * [Caching vcpkg and Conan Packages on GitHub Actionsvcpkg and Conan rebuild from source on GitHub Actions until the binary cache outlives the job. Restore the archives, key them safely, and ban source builds.](/guides/vcpkg-and-conan-caching-github-actions) * [Visual Studio Build Tools on GitHub ActionsWindows runner images already ship Visual Studio Enterprise. Initialize the developer environment with vswhere and VsDevCmd, and pick images by component ID.](/guides/visual-studio-build-tools-github-actions) * [A Weekly CI Health Report for GitHub ActionsAssemble billed minutes by runner label, p95 duration, queue wait and cache hit rate into one scheduled GitHub Actions job that writes a job summary.](/guides/weekly-ci-health-report-github-actions) * [Code Signing Windows Binaries in GitHub Actionssigntool needs a certificate, a password, and a timestamp authority on a runner that starts with none of them. Here is the tag-only signing job and its cost.](/guides/windows-code-signing-github-actions) * [Cutting Xcode Build Times on GitHub ActionsXcode jobs run long on GitHub Actions because DerivedData and SwiftPM start cold. Measure by phase, restore both caches, then size the macOS runner.](/guides/xcode-build-times-github-actions) * [Caching Xcode DerivedData in GitHub ActionsWhich DerivedData subdirectories restore safely on GitHub Actions, how to key and prune the archive so it fits the cache allowance, and the minutes it returns.](/guides/xcode-derived-data-cache-github-actions) * [Pinning an Xcode Version in GitHub ActionsPin the macOS runner label, set DEVELOPER\_DIR, and assert the resolved toolchain in the job log so a moving alias or an image update cannot change Xcode.](/guides/xcode-version-pinning-github-actions) --- # Alerting on GitHub Actions Build Time Regressions | WarpBuild URL: https://www.warpbuild.com/guides/alerting-on-ci-regressions Description: GitHub Actions has no build time regression alert. Baseline duration P90 for a week in the WarpBuild Jobs report, then run a scheduled check on the drift. # Alerting on GitHub Actions Build Time Regressions GitHub Actions has no build time regression alert. Baseline duration P90 for a week in the WarpBuild Jobs report, then run a scheduled check on the drift. GitHub Actions has no alert for a build that got slower, because the run log carries one duration per run and never compares that run against the ones before it. The alert has to be assembled from aggregated percentiles: baseline duration P90 per job over a week in the WarpBuild Jobs report, store one threshold per job, then run a scheduled workflow that reads the same report and fails when a job crosses its line. This guide covers what counts as a regression worth waking someone for, the four resource threshold alerts that ship with the observability feature and what they actually cover, a threshold-setting method built on a baseline week of percentile data, the runbook that says which report to open first for each alert, and what the watcher costs to run. ## [Diagnosis](#diagnosis)Copy Anchor Link A regression is a shift in the distribution of a job's runs. A single 24 minute run of a job that normally finishes in 9 minutes proves that one machine had a bad afternoon. The same job holding a duration P90 of 12.5 minutes across a week is the thing that costs the team hours, and it is invisible to any alert that watches individual runs. That is why per-run alerting produces noise on both ends. It pages on the outlier and stays silent through the slow slide, which is the failure mode of every threshold set against a single run. GitHub gives you the raw material and no aggregation. The [workflow runs REST API](https://docs.github.com/en/rest/actions/workflow-runs) returns timestamps and conclusions per run, so building percentiles from it means paginating history, joining runs to jobs, and storing the result somewhere. The WarpBuild [Jobs report](/docs/ci/features/reports) aggregates per unique repository, workflow, and job name combination and hands back run count, success rate, duration P75 and P90, queue time P75 and P90, and CPU and memory percentiles when observability is enabled for the runner. Four resource threshold alerts ship separately from the duration numbers, in the Recommendations view of the [observability feature](/docs/ci/features/observability). | Metric | Threshold | Alert label | | -------------------------- | -------------------------------------------- | --------------------- | | Max sustained CPU | 80 percent or higher | High CPU Usage | | Max memory utilization | 80 percent or higher | High Memory Usage | | Max filesystem utilization | 80 percent or higher | High Filesystem Usage | | Max disk I/O | 80 percent or higher of supported throughput | High Disk IO | Read what those four cover before you rely on them. They are a filter over the fleet that highlights under-provisioned and over-provisioned instances, applied to the organization's last 7 days, and each flagged job carries a recommendation type of upgrade or downgrade, the current runner label, a recommended label, and the subset of resources that triggered it. They answer the question of whether the machine is the wrong size. They say nothing about a job that got slower while sitting at 40 percent CPU the whole time, which is the common shape of a build time regression caused by more tests, a bigger dependency graph, or a cache that stopped hitting. Duration alerting is your job to wire, and the report data for it already exists. Two collection limits shape what you will see. Observability collects metrics and logs only for jobs longer than about one minute, so short jobs show a dash in the CPU and memory columns, and collection can be paused, which stops system logs and GitHub Actions logs along with the metrics. ## [Fix](#fix)Copy Anchor Link **Step 1: take a baseline week.** Pull the Jobs report for the last seven days and record duration P75, duration P90, and run count per job. The threshold rule that survives contact with a real repository is the baseline P90 plus the larger of 2.0 minutes or 20 percent of that baseline. The floor stops short jobs from paging on a 30 second wobble, and the proportional part keeps long jobs from paging on normal variance. The table below is illustrative rather than measured, and it shows the four cases you will meet in a real baseline. | Job | Baseline P75 | Baseline P90 | Runs in the week | Alert threshold on P90 | Cadence | | --------- | ------------ | ------------ | ---------------- | ---------------------- | ------------- | | api-tests | 8.2 min | 9.4 min | 310 | 11.4 min | Daily | | web-build | 5.1 min | 6.0 min | 480 | 8.0 min | Daily | | e2e | 21.0 min | 33.5 min | 46 | 40.2 min | Weekly | | release | 12.4 min | 14.1 min | 9 | No alert | Manual review | `api-tests` takes 9.4 plus 2.0, because 20 percent of 9.4 is smaller than the floor. `e2e` takes 33.5 plus 6.7, because at that length the proportional term wins. `release` runs nine times in the week, so its P90 is effectively one run and any threshold set on it will fire on the next flaky afternoon. Jobs under roughly 30 runs a week belong in a weekly review. **Step 2: keep P75 in the alert payload.** The pair is the diagnosis. P75 and P90 climbing together means the job got heavier for every run. P75 flat with P90 climbing means a tail is forming on a minority of runs, which points at retries, cold caches, or contention rather than at the code. **Step 3: run the runbook.** Each alert type has one report that answers it fastest. | Alert that fired | Open first | Read | Then | | ------------------------------- | ----------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Duration P90 over threshold | Jobs report, filtered to the job | duration\_p75 next to duration\_p90 | Compare against the same window last week, then check success rate | | Queue time P90 over threshold | Queue Timings report | Queue time P75 and P90 per runner label and stack | Check whether one label carries the wait, and whether run count grew | | Success rate drop | Jobs report | success\_rate with run\_count | Retries inflate duration P90, so fix the flake before re-baselining | | High CPU or High Memory Usage | Observability Recommendations | Current label, recommended label, resources array | Confirm the shape in the Usage view before changing the label | | High Filesystem or High Disk IO | Observability Usage view for the instance | Disk throughput chart with the correlated logs | Identify the step that owns the plateau, usually checkout, cache restore, or image export | | Spend up with duration flat | Reports, CI billing tab | Execution time next to billed time per job | Reconcile against run count before assuming the rate changed | **Step 4: re-baseline after every accepted change.** A deliberate change that adds two minutes to a build has to move the threshold, otherwise the alert becomes a thing people mute. Re-read the week after the change lands and write the new number back. ## [Configuration](#configuration)Copy Anchor Link The watcher is one scheduled workflow. It reads the Jobs report through the API with a key carrying the `ci` scope, compares each job against a thresholds file checked into the repository, and fails when a job is over its line. Copy Text ``` name: build-time-regression-watch on: schedule: - cron: "0 14 * * *" workflow_dispatch: jobs: check-duration: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - name: Pull the last 7 days of job percentiles env: WARPBUILD_API_KEY: ${{ secrets.WARPBUILD_API_KEY }} run: | curl -sS -G 'https://api.warpbuild.com/api/v1/reports/jobs' \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ -H 'Accept: application/json' \ --data-urlencode "start_date=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)" \ --data-urlencode "end_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --data-urlencode 'chart_metric=duration' \ --data-urlencode 'chart_percentile=p90' \ --data-urlencode 'sort_by=duration_p90' \ --data-urlencode 'sort_order=desc' \ --data-urlencode 'per_page=100' \ > jobs.json - name: Compare against the baseline thresholds run: | jq -r --slurpfile t .github/ci-thresholds.json ' .table.items[] | . as $row | ($t[0][$row.job_name] // empty) as $limit | select($row.duration_p90 > $limit) | "\($row.repo) \($row.workflow_name) \($row.job_name): p90 \($row.duration_p90) over \($limit) (p75 \($row.duration_p75), runs \($row.run_count))" ' jobs.json | tee regressions.txt test ! -s regressions.txt ``` `.github/ci-thresholds.json` holds one number per job name and is reviewed like any other config. Keep the thresholds in the same unit the report returns for `duration_p90` so the comparison stays a plain numeric test. Copy Text ``` { "api-tests": 11.4, "web-build": 8.0, "e2e": 40.2 } ``` Queue time needs no second call, because `queue_time_p75` and `queue_time_p90` sit on the same row. Add a second `select` on `$row.queue_time_p90` with its own limits and the one workflow covers execution drift and wait drift together. Replace the final `test` with a Slack step when you want a message instead of a red check. The resource threshold alerts come from a different endpoint, `/api/v1/org_metrics/job_runner_recommendations`, which returns one entry per job that should move plus the resources that triggered it. Running that weekly next to the daily duration check gives you both halves: whether the machine is wrong, and whether the work got slower. Adding `--data-urlencode 'format=csv'` to the reports call returns the same rows as CSV, matching every row under the current filters instead of the visible page. Two configuration notes. CPU and memory columns need observability enabled for the runner, and on BYOC instances the agent needs egress on port `33931`, otherwise those columns stay empty while duration and queue time keep reporting normally. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The watcher runs on the smallest Linux size in the catalog. Rates below come from the WarpBuild [pricing page](/pricing), billed per minute, checked on 2026-08-13. | Label | vCPU | RAM | USD per minute | | ------------------------- | ---- | ----- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | A daily run that finishes inside one billed minute is 30 minutes a month at $0.004, or $0.12\. Adding the weekly recommendations check at one minute each brings the total to about $0.14 a month. The watcher costs less than a single run of the job it watches. The number that matters is on the other side. Take `web-build` from the baseline table above at 500 runs a month, sitting on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, with duration P90 moving from 9.0 to 12.5 minutes after a dependency change. Left alone for three weeks, that is roughly 345 runs carrying 3.5 extra minutes each: 1,207 minutes of added wall clock, which is 20 hours of people waiting on pull requests, and $9.66 of added spend. Caught on the next day's check, the same regression costs about 16 runs, 56 minutes, and $0.45 before someone looks at it. The spend difference is small and the wait difference is the reason to wire the alert. A second model for the queue side. If `queue_time_p90` for one runner label moves from 20 seconds to 4 minutes across 2,000 jobs a week, that is 7,600 minutes of added wait per week, absent from the duration percentiles and absent from the bill, because queue time is billed to nobody. The Queue Timings report is where that shows up, split per runner label and stack. Structural notes for anyone budgeting this. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the observability data behind these alerts adds no line item of its own beyond the minutes the watcher consumes. Signup includes $10 free credits, which is enough to baseline a week of real jobs before you commit to thresholds. The same reports cover every one of them, including BYOC instances in your own cloud account. The alerting described here reads from those observability reports. For the full report and API surface, start at [GitHub Actions observability with WarpBuild](/solutions/github-actions-observability). If the thresholds themselves are the open question, [build time budgets](/guides/ci-slo-and-build-time-budgets) covers turning them into a target the team agrees on. When the alert that fires is a memory one, [can I alert when a runner runs out of memory](/answers/can-i-alert-when-a-runner-runs-out-of-memory) covers that signal, and [fix GitHub Actions jobs that run out of memory](/guides/github-actions-out-of-memory) covers the repair. ## [FAQ](#faq)Copy Anchor Link **How do I alert when a GitHub Actions build gets slower?** Baseline duration P90 per job over a week in the Jobs report, store one threshold per job, then run a scheduled workflow that reads the same report through the API and fails when a job crosses its threshold. GitHub Actions reports one duration per run and never compares it against earlier runs, so the comparison has to happen outside the run log. **What threshold should I set for a build time alert?** Set it from a baseline week rather than from a guess. Take the job's duration P90 over seven days and add the larger of 2.0 minutes or 20 percent of that baseline. Jobs with fewer than about 30 runs in the week get a weekly review instead, because their P90 is one or two runs and moves on noise. **Which report do I open first when a build time alert fires?** The Jobs report, filtered to the job that fired, with duration P75 read next to duration P90\. If queue time P90 moved instead, the Queue Timings report breaks the wait down per runner label and stack. Resource threshold alerts point at the Recommendations view instead, where the flagged instance carries its current label and a recommended label. --- # Moving GitHub Actions Builds to Native ARM64 | WarpBuild URL: https://www.warpbuild.com/guides/arm64-github-actions-runners Description: Build ARM64 artifacts natively in GitHub Actions by removing the QEMU emulation step and sending the job to a WarpBuild native ARM64 runner label. # Moving GitHub Actions Builds to Native ARM64 Build ARM64 artifacts natively in GitHub Actions by removing the QEMU emulation step and sending the job to a WarpBuild native ARM64 runner label. Last verified: 2026-08-13 GitHub Actions builds ARM64 artifacts natively when the job runs on an ARM64 machine, which removes the QEMU translation layer that `docker/setup-qemu-action` installs on an x64 runner. WarpBuild registers native Linux ARM64 runners under labels such as `warp-ubuntu-latest-arm64-8x`, so the migration is two edits: delete the QEMU setup step and change `runs-on`. This guide covers the migration itself. The full label and price list lives on the [Linux ARM64 runner catalog](/runners/linux-arm64), and the joining of per-architecture builds into one image tag lives in the [multi-platform Docker build guide](/guides/multi-platform-docker-builds). ## [Diagnosis](#diagnosis)Copy Anchor Link Emulated ARM64 builds are easy to miss because they succeed. The workflow is green, the image manifest says `linux/arm64`, and the only visible cost is time. Start by proving that a job is emulating, then decide whether it is worth moving. ### [Symptoms that point at emulation](#symptoms-that-point-at-emulation)Copy Anchor Link A job is a candidate when several of these hold at once. * The job includes a `docker/setup-qemu-action` step, and a later step passes `platforms: linux/arm64` while `runs-on` is an x64 label such as `ubuntu-latest`. * One stage dominates the wall clock. Checkout, dependency downloads, and shell glue take about as long as they do on an x64-only job, while `cargo build`, `go build` with cgo enabled, `npm rebuild`, `pip install` of packages with no ARM64 wheel, and Gradle or Maven steps that compile native code stretch out. * Jobs trip `timeout-minutes`, or brush the GitHub Actions job limit of 6 hours, on branches where the same steps used to finish comfortably. * Native dependency behaviour drifts from a real ARM64 machine. Binaries that use less common instructions abort with `SIGILL`, test suites that exercise atomics or thread scheduling produce different flake patterns, and build scripts that read `/proc/cpuinfo` report the x64 host while the userspace they run in is ARM64. * `exec format error` appears at the top of a step. That message means the kernel had no binfmt handler for the ARM64 binary at the moment it ran, which happens when the QEMU step was skipped, ordered after the build, or scoped to a different job. ### [Confirm it in one step](#confirm-it-in-one-step)Copy Anchor Link Add a temporary step to the suspect job and read three lines of output. Copy Text ``` - name: report architecture run: | uname -m ls /proc/sys/fs/binfmt_misc/ | grep -i qemu || echo "no qemu handlers" docker buildx inspect --bootstrap | grep -i platforms || true ``` On an emulated job, `uname -m` prints `x86_64` while the container build targets `linux/arm64`, and `/proc/sys/fs/binfmt_misc/` lists a `qemu-aarch64` handler that the setup action registered. On a native ARM64 runner, `uname -m` prints `aarch64` and no QEMU handler is needed for the build to run. ### [Why the minutes land where they do](#why-the-minutes-land-where-they-do)Copy Anchor Link QEMU user-mode emulation translates guest instructions to host instructions while syscalls pass through to the host kernel. That split explains the symptom pattern. Steps bound by network or disk look close to normal, because the host does that work at host speed. Steps bound by CPU carry the translation cost on every instruction, so compilers, linkers, and test binaries absorb almost all of it. The same split explains the correctness surprises. Emulation reproduces the instruction set, and the surrounding environment stays x64: CPU feature detection, core counts, memory reporting, and timing all come from the host. Code that branches on those signals takes a different path than it will on the ARM64 machine you ship to, which is a weak place to be when the artifact is a production image. ### [Decide what actually moves](#decide-what-actually-moves)Copy Anchor Link Not every emulated job is worth migrating on day one. Rank candidates by emulated minutes per month, which is job frequency multiplied by the length of the emulated stage. A nightly ARM64 image build is a small prize. A per pull request build that compiles a Rust or Go binary for ARM64 on every push is the one to move first. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so a repository can keep its x64 jobs where they are and move only the ARM64 work. ## [Fix](#fix)Copy Anchor Link The change is small enough to review in one pull request per repository. ### [The workflow diff](#the-workflow-diff)Copy Anchor Link Here is a typical emulated image build. Copy Text ``` name: image on: push: branches: [main] jobs: build-arm64: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-qemu-action@v3 with: platforms: arm64 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . platforms: linux/arm64 push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }}-arm64 ``` The migration removes four lines and changes one. Copy Text ``` jobs: build-arm64: - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 - uses: docker/setup-buildx-action@v3 ``` After the edit the job reads like an ordinary Linux build. The `platforms: linux/arm64` line stays, and it now names the host platform, so buildx builds directly instead of routing through a binfmt handler. Copy Text ``` jobs: build-arm64: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . platforms: linux/arm64 push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }}-arm64 ``` ### [Both architectures, each on its own machine](#both-architectures-each-on-its-own-machine)Copy Anchor Link Most teams that emulate ARM64 publish a multi-architecture image. A matrix sends each architecture to a runner of that architecture, and both legs run in parallel. Copy Text ``` jobs: build: strategy: fail-fast: false matrix: include: - platform: linux/amd64 runner: warp-ubuntu-latest-x64-8x suffix: amd64 - platform: linux/arm64 runner: warp-ubuntu-latest-arm64-8x suffix: arm64 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . platforms: ${{ matrix.platform }} push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }}-${{ matrix.suffix }} ``` A final job joins the two tags into one manifest. The digest handling and the manifest job are covered in the [multi-platform Docker build guide](/guides/multi-platform-docker-builds). ### [Compiled languages without containers](#compiled-languages-without-containers)Copy Anchor Link Container builds are the loudest case, and the same move applies to plain compile and test jobs. A Go service that enables cgo, or that runs its test suite against ARM64 behaviour, gets a native runner and a normal toolchain. Copy Text ``` go-build: runs-on: warp-ubuntu-latest-arm64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.24" - run: go build ./... - run: go test -race ./... ``` When cgo is off and the test suite does not need ARM64 behaviour, cross-compiling with `GOARCH=arm64` on an x64 runner is still a reasonable answer for the build artifact, and it leaves the tests running on the architecture you ship. The [Go on GitHub Actions solution page](/solutions/go-github-actions) covers that split. ### [Roll it out](#roll-it-out)Copy Anchor Link Move one job in one repository first and let it run for a week of normal traffic. Compare the job duration against the emulated baseline in the Actions run history, then widen. The number that matters for your rollout is the one from your own pipeline, not a general figure from someone else's. When a migrated job fails in a way the logs do not explain, the Action Debugger pauses the workflow and opens an SSH session on the runner so you can inspect the ARM64 machine directly, and CI observability correlates runner system metrics with the job logs. ## [Configuration](#configuration)Copy Anchor Link ### [ARM64 labels, sizes, and rates](#arm64-labels-sizes-and-rates)Copy Anchor Link Five sizes carry the Ubuntu 24.04 ARM64 image under the `latest` alias, and the same sizes exist for Ubuntu 26.04 under `warp-ubuntu-2604-arm64-`. GitHub publishes its own per-minute rates for GitHub-hosted Linux ARM64 runners on the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Runner label | Alias | vCPU | RAM | Storage | WarpBuild per minute | GitHub-hosted Linux arm64 same core count | | ---------------------------- | -------------------------- | ---- | ----- | --------- | -------------------- | ----------------------------------------- | | warp-ubuntu-latest-arm64-2x | warp-ubuntu-2404-arm64-2x | 2 | 8GB | 150GB SSD | $0.003 | $0.005 | | warp-ubuntu-latest-arm64-4x | warp-ubuntu-2404-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 | $0.008 | | warp-ubuntu-latest-arm64-8x | warp-ubuntu-2404-arm64-8x | 8 | 32GB | 150GB SSD | $0.012 | $0.014 | | warp-ubuntu-latest-arm64-16x | warp-ubuntu-2404-arm64-16x | 16 | 64GB | 150GB SSD | $0.024 | $0.026 | | warp-ubuntu-latest-arm64-32x | warp-ubuntu-2404-arm64-32x | 32 | 128GB | 150GB SSD | $0.048 | $0.050 | The ARM64 images for Ubuntu 22.04 were deprecated on March 31, 2025, so pin to a 24.04 or 26.04 label rather than carrying an old tag forward. Full platform coverage and the size list for every platform sit in the [cloud runners documentation](/docs/ci/cloud-runners). ### [The work dir difference](#the-work-dir-difference)Copy Anchor Link The Ubuntu 24.04 ARM64 image sets the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/` on the equivalent instance. Anything that hardcodes the GitHub path breaks on the first ARM64 run: shell scripts, container volume mounts, absolute paths in tool configuration, and coverage or lint tools that record file paths. The fix is to read the path from the environment. Copy Text ``` - name: use the workspace path run: | test -d "$GITHUB_WORKSPACE" docker run --rm -v "$GITHUB_WORKSPACE:/src" -w /src alpine:3 ls -1 ``` Inside `uses:` steps and expressions, the same value is available as the `github.workspace` context. Grep the repository for `/home/runner/work` before the migration and you will usually find every offender in one pass. ### [Tooling on the ARM64 image](#tooling-on-the-arm64-image)Copy Anchor Link The Ubuntu 24.04 ARM64 runners are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the tooling list is published in the [partner runner images reference for the ARM Ubuntu 24 image](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md). Ubuntu 26.04 ARM64 tracks the upstream [Ubuntu 26.04 ARM64 image readme](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2604-Arm64-Readme.md). The [preinstalled software documentation](/docs/ci/preinstalled-software) maps every image to its upstream list, which is the fastest way to check whether a tool your workflow assumes is present before you flip a label. Two checks are worth doing up front. Third-party actions that ship prebuilt binaries sometimes publish x64 assets only, so read the action release page before you assume it runs. Container jobs and service containers need an `arm64` variant of the image they name, which most official images publish and some internal base images do not. ### [Cache keys do not cross architectures](#cache-keys-do-not-cross-architectures)Copy Anchor Link Cache entries are identified by a version hash covering the compression tool used on that runner OS and the cached paths, so an entry written on `warp-ubuntu-latest-x64-8x` does not restore on `warp-ubuntu-latest-arm64-8x`. Plan for one cold run per key after the switch, and put the architecture into the key so the two fleets stop invalidating each other. Copy Text ``` - uses: actions/cache@v4 with: path: ~/.cargo/registry key: cargo-${{ runner.arch }}-${{ hashFiles('**/Cargo.lock') }} ``` Cache for build artifacts and dependencies is enabled by default on Linux runners, including the ARM64 sizes. ### [Work that stays on x64](#work-that-stays-on-x64)Copy Anchor Link Nested virtualization and `/dev/kvm` access are available on Linux x64 runners through the `nested-virtualization.enabled=true` label, and are unavailable on ARM64 runners today. Android emulator jobs and anything else that boots a VM inside the runner stay on an x64 label such as `warp-ubuntu-latest-x64-8x`, while the ARM64 compile jobs move. ### [Running ARM64 inside your own cloud](#running-arm64-inside-your-own-cloud)Copy Anchor Link Teams with data residency or network placement requirements can run the same migration on their own account. BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS, so the runner fleet is declared alongside the rest of your infrastructure. The workflow edit is identical, because the label is still what routes the job. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The migration changes two numbers at once: the per-minute rate and the number of minutes the job needs. Model them separately so the decision does not rest on a single blended figure. ### [Assumptions](#assumptions)Copy Anchor Link * 1,200 workflow runs per month, which is 60 runs per weekday across 20 weekdays. * One ARM64 build job per run. * Emulated path: `warp-ubuntu-latest-x64-8x` at $0.016 per minute, with `docker/setup-qemu-action` in the job. * Native path: `warp-ubuntu-latest-arm64-8x` at $0.012 per minute. * Billing is per minute of runner time. * `E` is the emulated job length in minutes and `N` is the native job length in minutes. Both are values you measure in your own pipeline. The values used below are placeholders that show the arithmetic. ### [The rate side](#the-rate-side)Copy Anchor Link At every size, the ARM64 label carries a lower per-minute rate than the x64 label with the same vCPU count. | Size | x64 label | x64 per minute | ARM64 label | ARM64 per minute | Difference per minute | | ---- | -------------------------- | -------------- | ---------------------------- | ---------------- | --------------------- | | 2x | warp-ubuntu-latest-x64-2x | $0.004 | warp-ubuntu-latest-arm64-2x | $0.003 | $0.001 | | 4x | warp-ubuntu-latest-x64-4x | $0.008 | warp-ubuntu-latest-arm64-4x | $0.006 | $0.002 | | 8x | warp-ubuntu-latest-x64-8x | $0.016 | warp-ubuntu-latest-arm64-8x | $0.012 | $0.004 | | 16x | warp-ubuntu-latest-x64-16x | $0.032 | warp-ubuntu-latest-arm64-16x | $0.024 | $0.008 | | 32x | warp-ubuntu-latest-x64-32x | $0.064 | warp-ubuntu-latest-arm64-32x | $0.048 | $0.016 | For a list-price anchor outside WarpBuild: `warp-ubuntu-latest-arm64-8x` is $0.012 per minute against $0.014 per minute for the GitHub-hosted Linux arm64 8-core runner, a 14 percent lower list price ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). At the 2 vCPU size the same comparison is $0.003 against $0.005, a 40 percent lower list price. ### [The minutes side](#the-minutes-side)Copy Anchor Link Monthly cost on the emulated path is `1200 * E * $0.016`, which is `$19.20 * E`. Monthly cost on the native path is `1200 * N * $0.012`, which is `$14.40 * N`. | Emulated minutes E | Emulated monthly cost | Native minutes N | Native monthly cost | Same N on GitHub-hosted arm64 8-core at $0.014 | | ------------------ | --------------------- | ---------------- | ------------------- | ---------------------------------------------- | | 10 | $192.00 | 5 | $72.00 | $84.00 | | 20 | $384.00 | 8 | $115.20 | $134.40 | | 30 | $576.00 | 12 | $172.80 | $201.60 | | 45 | $864.00 | 20 | $288.00 | $336.00 | Read the two halves independently. The left half is what the pipeline costs today at whatever `E` you measure. The right half is what it costs after the move at whatever `N` you measure. Fill both from your own run history rather than from the placeholder rows. The time model is the same arithmetic without the rate. At `E` of 30 the pipeline consumes 36,000 runner minutes per month; at `N` of 12 it consumes 14,400\. Queue time sits on top of both and is unaffected by the architecture change. ### [Break-even](#break-even)Copy Anchor Link Setting `0.016 * E` equal to `0.012 * N` gives `N = 1.33 * E`. The native path costs less on the invoice unless the native job needs more than four minutes for every three emulated minutes, which gives the rate difference room to absorb a migration that lands slower than expected on a specific workload. Anything better than that break-even shows up as a lower bill and a shorter feedback loop at the same time. One more input belongs in the model. The full rate sheet, including cache and snapshot line items, is on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link ### [Do I still need docker/setup-qemu-action after moving to native ARM64 runners?](#do-i-still-need-dockersetup-qemu-action-after-moving-to-native-arm64-runners)Copy Anchor Link No. The QEMU step registers binfmt handlers so an x64 machine can execute ARM64 binaries. On a native ARM64 runner the host already executes ARM64 instructions, so the step adds setup time and nothing else. Keep it only in jobs that still build a foreign architecture on the same machine. ### [Will my GitHub Actions cache carry over from x64 to ARM64?](#will-my-github-actions-cache-carry-over-from-x64-to-arm64)Copy Anchor Link No. Cache entries are keyed by a version hash that includes the runner OS compression tool and the cached paths, so an entry written on `warp-ubuntu-latest-x64-8x` does not restore on `warp-ubuntu-latest-arm64-8x`. Expect one cold run per cache key after the switch, and put the architecture in your cache key so the two fleets stop competing. ### [Which Ubuntu ARM64 images does WarpBuild offer, and do file paths differ from GitHub-hosted runners?](#which-ubuntu-arm64-images-does-warpbuild-offer-and-do-file-paths-differ-from-github-hosted-runners)Copy Anchor Link Ubuntu 24.04 and Ubuntu 26.04 ARM64 images, in 2, 4, 8, 16, and 32 vCPU sizes. The Ubuntu 24.04 ARM64 image sets the work dir to `/runner/_work` rather than GitHub's `/home/runner/work/`, so any script with a hardcoded runner path must read `$GITHUB_WORKSPACE` instead. ### [Can I keep publishing multi-platform images while building each architecture natively?](#can-i-keep-publishing-multi-platform-images-while-building-each-architecture-natively)Copy Anchor Link Yes. Run one build job per architecture on its own native runner, push per-architecture digests, then join them with a manifest in a final job. The [multi-platform guide](/guides/multi-platform-docker-builds) covers the digest and manifest wiring end to end. If you are still deciding whether the platform supports the move at all, [can GitHub Actions run on ARM64](/answers/can-github-actions-run-arm64) answers that in one page. --- # An ARM64 Migration Checklist | WarpBuild URL: https://www.warpbuild.com/guides/arm64-migration-checklist-github-actions Description: Migrate GitHub Actions jobs to ARM64 with a four category checklist: downloaded binaries, native modules, container base images, and hardcoded runner paths. # An ARM64 Migration Checklist Migrate GitHub Actions jobs to ARM64 with a four category checklist: downloaded binaries, native modules, container base images, and hardcoded runner paths. Last verified: 2026-08-13 Migrating a GitHub Actions workflow to ARM64 is one label edit plus four categories of audit: binaries downloaded with an architecture token in the URL, native modules resolved from a lockfile, container base images, and absolute paths copied from the GitHub-hosted layout. Work through the four categories before the first flip and the label change is the only edit that reaches a pull request. This checklist covers the audit, the fixes in order, and the arithmetic for the same pipeline at x64 and ARM64 rates. The label and size list sits on the [Linux ARM64 runner catalog](/runners/linux-arm64), the emulation case, where a job builds ARM64 artifacts on an x64 machine through QEMU, is covered in [moving builds to native ARM64](/guides/arm64-github-actions-runners), and the short version of the four categories is on [what changes when a workflow moves to ARM64](/answers/what-changes-when-i-move-a-workflow-from-x64-to-arm64). ## [Diagnosis](#diagnosis)Copy Anchor Link Run the audit against the whole repository first. Each category has a grep that finds it and an error string that identifies it once the job is already running, so a hit list built in ten minutes replaces a week of red runs. | Category | Find it before the flip | What it looks like after the flip | | --------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | | Downloaded binaries | grep -rn "amd64\|x86\_64|linux-x64" .github/workflows Makefile scripts/ | cannot execute binary file: Exec format error | | Native modules | grep -n "linux-x64\|linux\_x86\_64|manylinux.\*x86\_64" package-lock.json requirements.txt poetry.lock | An install or import failure naming a platform package such as @esbuild/linux-x64 | | Container base images | grep -rhn "^FROM\|image:" Dockerfile\* .github/workflows then docker manifest inspect | no matching manifest for linux/arm64/v8 in the manifest list entries | | Hardcoded paths | grep -rn "/home/runner/work" | A tool reads an empty directory, or a mount lands on a path that does not exist | The manifest check is worth scripting, because a base image is the category that cannot be fixed inside the workflow file. Copy Text ``` for image in $(grep -rh "^FROM" Dockerfile | awk '{print $2}'); do archs=$(docker manifest inspect "$image" | grep -o '"architecture": "[^"]*"' | sort -u | tr '\n' ' ') echo "$image -> $archs" done ``` An image that prints only `amd64` blocks the job until the base image publishes an `arm64` manifest or you build one. Official language images and the common distro images publish both; internal base images built once on an x64 machine usually do not, and that build is its own piece of work. Rank the remaining jobs by billed minutes per month, which is runs per month multiplied by job duration. A nightly job with three audit hits is a poor first candidate. A pull request job that runs on every push with no audit hits is the one that pays for the migration while the rest of the fixes land. ## [Fix](#fix)Copy Anchor Link Work the categories in this order. Each one is independent, so a category can be fixed and merged on x64 before any job changes architecture. **1\. Downloaded binaries.** Replace every literal architecture token with a value derived from the machine. One matrix entry then drives both the label and the URL. Copy Text ``` jobs: build: strategy: fail-fast: false matrix: include: - runner: warp-ubuntu-latest-x64-8x arch: x86_64 - runner: warp-ubuntu-latest-arm64-8x arch: aarch64 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - name: install the release binary for this machine run: | test "$(uname -m)" = "${{ matrix.arch }}" curl -fsSL "https://example.com/tool/v1.9.0/tool-linux-${{ matrix.arch }}.tar.gz" | tar xz ``` The `test` line fails loudly when a matrix entry and a label drift apart, which is the failure mode that otherwise shows up as a confusing runtime error three steps later. **2\. Native modules.** Lockfiles record platform-specific packages, so a lockfile generated on x86-64 alone leaves the aarch64 variant absent at install time. Regenerate the lockfile on a machine that sees both, keep optional dependencies enabled in the install step, and check the aarch64 package is present before the job runs. For Python, a package with no `manylinux` aarch64 wheel falls back to a source build, so the job needs a compiler and headers or a pinned version that publishes the wheel. **3\. Container base images.** Fix the images the audit flagged. Where the base image is yours, build it for both platforms and publish one manifest, so the workflow keeps naming a single tag. **4\. Hardcoded paths.** The Ubuntu 24.04 ARM64 image sets the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/` for the same instance ([WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). Replace each grep hit with the variable that already carries the right value: `$GITHUB_WORKSPACE` or `${{ github.workspace }}` for the checkout, `$RUNNER_TEMP` or `${{ runner.temp }}` for scratch files ([GitHub variables reference](https://docs.github.com/en/actions/reference/workflows-and-actions/variables)). Config files checked into the repository carry these paths as often as workflow files do, so the grep covers `Makefile`, coverage configuration, and scanner properties as well. The path layout under the new prefix is unchanged, which [the work directory answer](/answers/do-arm64-runners-use-a-different-work-directory) shows row by row. With the four categories closed, the flip is the label. Copy Text ``` test: - runs-on: warp-ubuntu-latest-x64-8x + runs-on: warp-ubuntu-latest-arm64-8x ``` Keep `fail-fast: false` while a matrix runs both architectures so one leg failing still reports the other. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so the jobs that stay on x86-64 keep their labels and nothing else in the workflow moves. ## [Configuration](#configuration)Copy Anchor Link Five sizes carry the Ubuntu 24.04 ARM64 image under the `latest` alias, and the same five exist for Ubuntu 26.04 under `warp-ubuntu-2604-arm64-`. The ARM64 images for Ubuntu 22.04 were deprecated on March 31, 2025, so a migration that starts from a pinned 22.04 label picks a 24.04 or 26.04 target. The Ubuntu 24.04 ARM64 runners are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the tooling list is published in [the ARM Ubuntu 24 image reference](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md). Read it for any language runtime the workflow assumes rather than assuming parity with the x86-64 image. Two configuration items belong in the same pull request as the label change. Cache entries are keyed by a hash covering the runner OS and the cached paths, so an entry written on `warp-ubuntu-latest-x64-8x` does not restore on `warp-ubuntu-latest-arm64-8x`. Put `${{ runner.arch }}` in the cache key so the two fleets stop invalidating each other, and expect one cold run per key. Second, jobs that need nested virtualization, such as Android emulator tests, stay on an x86-64 label ([common issues](/docs/ci/common-issues)). When a migrated job fails in a way the logs do not explain, the Action Debugger pauses the workflow and opens an SSH session on the ARM64 machine, and CI observability correlates runner system metrics with the job logs, which separates a missing package from a memory ceiling. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Price the pipeline at both rates before the migration, holding minutes constant, so the rate effect and the duration effect stay separate. Assumptions: 900 workflow runs per month, three jobs per run, 8 vCPU sizes throughout, `warp-ubuntu-latest-x64-8x` at $0.016 per minute and `warp-ubuntu-latest-arm64-8x` at $0.012 per minute, billed per minute. | Job | Minutes per run | Monthly minutes | x64 at $0.016 | ARM64 at $0.012 | | ------------------- | --------------- | --------------- | ------------- | --------------- | | Unit tests | 7 | 6,300 | $100.80 | $75.60 | | Integration tests | 11 | 9,900 | $158.40 | $118.80 | | Package and publish | 6 | 5,400 | $86.40 | $64.80 | | Total | 24 | 21,600 | $345.60 | $259.20 | The difference is $86.40 per month at identical minutes. Duration is the second input and it comes from your own run history, so keep the x86-64 leg in the matrix for a week after the flip and read both from the Actions run list. The rate gap absorbs a longer ARM64 run up to a known point: 900 runs at 32 minutes is 28,800 minutes, and 28,800 x $0.012 is $345.60, which matches the x86-64 total at 24 minutes. Two list-price anchors put those rates against GitHub-hosted runners at the same shape, both checked on 2026-08-13 in the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). `warp-ubuntu-latest-arm64-8x` (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB): 14 percent lower list price. On the architecture you are leaving, `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. The full rate sheet, including cache line items, is on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link ### [In what order should jobs move to ARM64?](#in-what-order-should-jobs-move-to-arm64)Copy Anchor Link By billed minutes, highest first, with one exception. Audit the four categories across the whole repository before the first flip, then move the single job that consumes the most runner minutes and has the fewest audit hits. That job proves the rate arithmetic on real traffic while the harder jobs, usually the ones with a container base image or a native module, get their fixes prepared in parallel. ### [What breaks first on an ARM64 runner?](#what-breaks-first-on-an-arm64-runner)Copy Anchor Link A step that downloads a release binary with an architecture token in the URL. The download succeeds because the file exists, and the first execution fails with `cannot execute binary file: Exec format error`. Drive that token from the same matrix variable that drives `runs-on`, or read it from `uname -m` inside the step, and the same workflow file works on both architectures. ### [Does the ARM64 work directory difference require a workflow change?](#does-the-arm64-work-directory-difference-require-a-workflow-change)Copy Anchor Link Only where a path is hardcoded. The Ubuntu 24.04 ARM64 image sets the work dir to `/runner/_work` rather than GitHub's `/home/runner/work/`, and every path variable still resolves, so a step that reads `$GITHUB_WORKSPACE` or `${{ github.workspace }}` needs no edit. Grep the repository for `/home/runner/work` before the first ARM64 run and replace each hit with the variable. --- # Node Native Modules on ARM64 Runners | WarpBuild URL: https://www.warpbuild.com/guides/arm64-node-native-modules-github-actions Description: A .node addon compiled for x64 fails on ARM64 with invalid ELF header. Install on the aarch64 machine, key caches on runner.arch, rebuild what has no prebuild. # Node Native Modules on ARM64 Runners A .node addon compiled for x64 fails on ARM64 with invalid ELF header. Install on the aarch64 machine, key caches on runner.arch, rebuild what has no prebuild. Last verified: 2026-08-13 A native Node module is an ELF shared object holding machine code for one CPU architecture, so a `.node` file produced on an x64 runner fails on an ARM64 runner with `invalid ELF header` the first time something requires it. The fix is to run the install on the aarch64 machine itself, carry `${{ runner.arch }}` through every cache key, and rebuild the small set of packages that publish no arm64 prebuilt binary. This guide covers the error signatures that identify an architecture mismatch, the three ways an x64 addon reaches an ARM64 job, a workflow with an architecture-aware cache key and a rebuild step, and a time model of prebuilt downloads against source compilation at each ARM64 size. ## [Diagnosis](#diagnosis)Copy Anchor Link Read the message before changing anything, because three different failures produce similar-looking logs and only one of them is about architecture. | Message in the log | What produced it | | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | .../build/Release/better\_sqlite3.node: invalid ELF header | An x86-64 addon loaded by an aarch64 Node process. The header says the wrong machine type and the loader stops there. | | Cannot find module '@rollup/rollup-linux-arm64-gnu' | node\_modules arrived from an install that ran somewhere else, so only the optional package for that other platform is present. | | Could not load the "sharp" module using the linux-arm64 runtime | Same shape as the row above, reported by a package that resolves its own platform binary at runtime. | | prebuild-install warn install No prebuilt binaries found (target=22.x runtime=node arch=arm64 libc= platform=linux) followed by gyp info spawn make | Working as intended. No arm64 asset was published, so the package is compiling from source. This is a duration problem rather than a correctness problem. | | was compiled against a different Node.js version using NODE\_MODULE\_VERSION 115\. This version requires 127 | A Node major version change. The architecture is fine and the same addon rebuilt on the current Node version will load. | | Error relocating ...: symbol not found inside an Alpine container | A glibc build of the addon on a musl system, which needs the linux-arm64-musl variant instead. | Confirm the machine and the artifacts in one step. `uname -m` prints `aarch64` and `node -p "process.arch"` prints `arm64` on a real ARM64 runner, and `file` reports the machine type baked into each addon: Copy Text ``` - name: Report architecture and addons run: | uname -m node -p "process.arch" find node_modules -name '*.node' -exec file {} + | sort -u ``` Every line of that `file` output should read `ELF 64-bit LSB shared object, ARM aarch64`. Any `x86-64` line is the module that will throw. Three routes put an x64 addon on an aarch64 machine. The most common is a cache entry whose key omits the architecture, because `${{ runner.os }}` resolves to `Linux` on both legs of a matrix and a lockfile hash is identical on both, so the two legs address the same entry ([GitHub contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts)). The second is `node_modules` moved between jobs as a workflow artifact, which carries the compiled tree along with it. The third is an install that never ran on aarch64 at all, such as a container image built for `linux/amd64` and started on the ARM64 runner. The reason a fresh install fixes all three is that npm resolves `optionalDependencies` against the `os` and `cpu` of the machine performing the install, and `prebuild-install` and `node-pre-gyp` request an asset named for the running platform, architecture, and ABI. Both decisions are made at install time on the install machine, so an install that runs on aarch64 produces an aarch64 tree. ## [Fix](#fix)Copy Anchor Link Give each architecture its own install and never move the resulting tree. 1. Run the ARM64 leg on a native ARM64 runner. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the ARM64 sizes attach through the `runs-on` label listed in the [cloud runners documentation](/docs/ci/cloud-runners): `warp-ubuntu-latest-arm64-2x` through `warp-ubuntu-latest-arm64-32x`. The full label list with preinstalled tooling is on the [Linux ARM64 runner page](/runners/linux-arm64). 2. Cache the package manager download directory rather than `node_modules`. Registry tarballs are architecture independent and safe to reuse; the installed tree is not. 3. Put `${{ runner.arch }}` in the primary key and in every `restore-keys` prefix. A prefix that drops the segment reopens the hole on the first run where the exact key misses. 4. Add a rebuild step for the packages with no arm64 prebuild, and pin it to the packages you have identified rather than rebuilding the whole tree. 5. Fail the job when a foreign addon appears, so the mismatch shows up as a build failure instead of a runtime crash in a later step. For step 4, `npm rebuild ` reruns node-gyp against the current Node version and architecture, and node-gyp reads `JOBS` to set the `make` parallelism, so match it to the vCPU count of the label ([node-gyp](https://github.com/nodejs/node-gyp)). node-gyp needs `python3`, `make`, and a C++ compiler on the PATH; confirm they are present when the job runs inside a slim container image. One porting detail applies to the Ubuntu 24.04 ARM64 image: its work directory is `/runner/_work`, so any script with a hardcoded runner path should read `$GITHUB_WORKSPACE` instead. ## [Configuration](#configuration)Copy Anchor Link The matrix below runs both architectures, keys the download cache on `runner.arch`, rebuilds the two packages that ship no arm64 asset, and verifies the tree before the tests run. The cache action is the drop-in replacement documented in the [caching reference](/docs/ci/features/caching). Copy Text ``` name: test on: pull_request: jobs: test: strategy: fail-fast: false matrix: include: - runner: warp-ubuntu-latest-x64-4x jobs: 4 - runner: warp-ubuntu-latest-arm64-4x jobs: 4 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - uses: WarpBuilds/cache@v1 with: path: ~/.npm key: npm-${{ runner.os }}-${{ runner.arch }}-node22-${{ hashFiles('**/package-lock.json') }} restore-keys: | npm-${{ runner.os }}-${{ runner.arch }}-node22- npm-${{ runner.os }}-${{ runner.arch }}- - run: npm ci - name: Rebuild addons without arm64 prebuilds if: runner.arch == 'ARM64' env: npm_config_build_from_source: "true" JOBS: ${{ matrix.jobs }} run: npm rebuild better-sqlite3 node-re2 - name: Fail on foreign addons run: | expected=$([ "$(uname -m)" = "aarch64" ] && echo aarch64 || echo x86-64) bad=$(find node_modules -name '*.node' -exec file {} + | grep -v "$expected" || true) if [ -n "$bad" ]; then echo "$bad" exit 1 fi - run: npm test ``` Three details in that file do the work. `path: ~/.npm` keeps the compiled tree out of the cache entirely, so the worst case of a bad restore is a slower install rather than a broken one. The `runner.arch` segment appears in the primary key and in both fallback prefixes. And the verification step compares against `uname -m`, so the same step guards both legs without a per-leg condition. `npm ci` deletes `node_modules` before installing, which is what makes the per-leg install trustworthy. `npm install` on top of a restored tree leaves whatever was already there. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Compiling from source moves work from the network onto the CPU, so the two paths respond to runner size in opposite directions. Substitute your own numbers; the assumptions below describe one dependency tree. * Six packages with no arm64 prebuild, totalling 24 CPU-minutes of compilation. * Parallel efficiency of 80 percent across the available vCPU, with `JOBS` set to the vCPU count. * 1.2 minutes for checkout, Node setup, and an `npm ci` that finds every prebuilt binary, on every size. * 800 installs per month. * Linux ARM64 rates from the [pricing page](/pricing), billed per minute, checked on 2026-08-13. | Label | vCPU | Rate | Prebuilt path | Source path | Monthly, prebuilt | Monthly, source | | ---------------------------- | ---- | ------ | ------------- | ----------- | ----------------- | --------------- | | warp-ubuntu-latest-arm64-2x | 2 | $0.003 | 1.2 min | 16.2 min | $2.88 | $38.88 | | warp-ubuntu-latest-arm64-4x | 4 | $0.006 | 1.2 min | 8.7 min | $5.76 | $41.76 | | warp-ubuntu-latest-arm64-8x | 8 | $0.012 | 1.2 min | 4.95 min | $11.52 | $47.52 | | warp-ubuntu-latest-arm64-16x | 16 | $0.024 | 1.2 min | 3.08 min | $23.04 | $59.04 | | warp-ubuntu-latest-arm64-32x | 32 | $0.048 | 1.2 min | 2.14 min | $46.08 | $82.08 | Read the last two columns together. On the prebuilt path the install is network bound, so a larger label buys nothing and costs more per minute. On the source path a larger label returns wall clock time and raises the monthly bill anyway, because the rate climbs faster than the compile time falls: the 32 vCPU row finishes 14 minutes sooner per run than the 2 vCPU row and costs $43.20 more per month. The cheapest move is removing packages from the source column, either by upgrading to a version that publishes an arm64 asset or by caching the compiled output under an architecture-aware key. For a list-price baseline at the middle size, `warp-ubuntu-latest-arm64-4x` (4 vCPU, 16 GB) costs $0.006 per minute against $0.008 per minute for the 4-core Linux ARM64 larger runner (4 vCPU, 16 GB): 25 percent lower list price. GitHub list prices checked on 2026-08-13 in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The 6,960 monthly minutes of the source path in that row therefore cost $41.76 here against $55.68 on the GitHub-hosted equivalent. Cache storage is $0.20 per GB-month and each cache write or restore is $0.0001, from the [caching reference](/docs/ci/features/caching) and the [pricing page](/pricing), checked on 2026-08-13\. Holding a 1 GB `~/.npm` entry for each architecture is $0.40 a month, against the $41.76 the 4 vCPU source path costs at 800 installs. The utilization metrics behind the `JOBS` setting come from CI observability. ## [FAQ](#faq)Copy Anchor Link ### [Why does a native Node module built on an x64 runner fail on an ARM64 runner?](#why-does-a-native-node-module-built-on-an-x64-runner-fail-on-an-arm64-runner)Copy Anchor Link A `.node` file is an ELF shared object holding machine code for one architecture. An aarch64 Node process that dlopens an x86-64 addon rejects it at the header, which surfaces as `invalid ELF header` on the first `require`. The module has to be fetched or compiled for aarch64 by an install that runs on the aarch64 machine. ### [Do I have to rebuild every dependency when I add an ARM64 leg?](#do-i-have-to-rebuild-every-dependency-when-i-add-an-arm64-leg)Copy Anchor Link No. Only packages that ship a compiled addon are involved, and most of the widely used ones publish `linux-arm64` prebuilt binaries that `prebuild-install` or `node-pre-gyp` downloads during a normal install. Source compilation applies to the remainder, which is usually a handful of packages, and node-gyp needs `python3`, `make`, and a C++ compiler on the PATH for those. ### [Can the x64 and ARM64 legs of a matrix share one cache entry?](#can-the-x64-and-arm64-legs-of-a-matrix-share-one-cache-entry)Copy Anchor Link They can share the package manager download directory and nothing else. Registry tarballs are architecture independent, while `node_modules` holds resolved optional packages and compiled `.node` files that belong to the architecture that installed them. Cache `~/.npm` with `${{ runner.arch }}` in the key and let each leg run its own install, as covered in [do GitHub Actions caches work across architectures](/answers/do-github-actions-caches-work-across-architectures). --- # Building ARM64 Python Wheels | WarpBuild URL: https://www.warpbuild.com/guides/arm64-python-wheels-github-actions Description: Building aarch64 wheels on an x64 GitHub Actions runner puts every compile under QEMU. Move the aarch64 leg of the cibuildwheel matrix to a native runner. # Building ARM64 Python Wheels Building aarch64 wheels on an x64 GitHub Actions runner puts every compile under QEMU. Move the aarch64 leg of the cibuildwheel matrix to a native runner. Last verified: 2026-08-13 To build ARM64 Python wheels in GitHub Actions, run the aarch64 leg of your cibuildwheel matrix on a native ARM64 runner such as `warp-ubuntu-latest-arm64-8x` at $0.012 per minute and delete the `docker/setup-qemu-action` step that the x64 route depends on. Every compile, link, and test inside the `manylinux_2_28_aarch64` container then executes on aarch64 hardware instead of being translated instruction by instruction. This guide covers where an emulated wheel build spends its time, the build matrix that maps Python versions and platform tags to runner labels, the workflow diff and the full release workflow with artifact upload, and a time and cost model for a package with a stated number of C extension modules. The rest of a Python pipeline, including dependency caching and test sharding, is covered on the [Python on GitHub Actions page](/solutions/python-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link ### [Where the aarch64 leg spends its time](#where-the-aarch64-leg-spends-its-time)Copy Anchor Link On Linux, [cibuildwheel](https://cibuildwheel.pypa.io/en/stable/) does not build wheels on the runner itself. It pulls a [manylinux](https://github.com/pypa/manylinux) or [musllinux](https://peps.python.org/pep-0656/) container image, one per platform tag, and runs the build inside it so the resulting wheel links against an old enough glibc or musl to install on the range of distributions the tag promises. The architecture of that container follows the host. On an x86\_64 runner, an `aarch64` container image runs only because `docker/setup-qemu-action` wrote entries into `/proc/sys/fs/binfmt_misc` telling the kernel to hand any aarch64 binary to `qemu-aarch64-static`. From that point the container is full of aarch64 binaries, and every one of them runs as translated code with each syscall marshalled between the two ABIs. That is the whole source of the gap between the two legs of the matrix, and it is the same mechanism described in more depth for image builds in [moving ARM64 Docker builds off QEMU](/guides/qemu-vs-native-arm64-builds). The exposure is uneven across the stages of a wheel build, which is why two packages on the same runner size see very different emulated durations. | Stage of the wheel build | Runs where | Emulation exposure | | ----------------------------------------------------------- | ---------------------------------- | -------------------------------------------------- | | Pull and unpack manylinux\_2\_28\_aarch64 | Host Docker daemon | None. Bytes move at host speed. | | pip download of build requirements | Network bound inside the container | Low. The wait dominates. | | setuptools or maturin driver, pure Python bookkeeping | Interpreted inside the container | Moderate. The interpreter is itself translated. | | C, C++, Cython, and Rust compile steps | Inside the container | Highest. CPU bound from start to finish. | | Link and strip steps | Inside the container | High, and syscall heavy on top of the translation. | | auditwheel repair, which rewrites ELF headers with patchelf | Inside the container | Moderate, and syscall heavy. | | CIBW\_TEST\_COMMAND test run | Inside the container | High for anything CPU bound. | | actions/upload-artifact of the wheelhouse | Host | None. | A package that is pure Python with a thin `ctypes` shim barely notices the difference. A package that compiles three extension modules against NumPy headers for five interpreter versions spends nearly all of its billed minutes inside the interpreter. ### [Confirming it in one run](#confirming-it-in-one-run)Copy Anchor Link Add a temporary step ahead of the cibuildwheel step and read three lines. Copy Text ``` - name: report architecture run: | uname -m python -c "import sysconfig; print(sysconfig.get_platform())" ls /proc/sys/fs/binfmt_misc/ | grep -i qemu || echo "no qemu handlers" ``` On the emulated job `uname -m` prints `x86_64`, `sysconfig.get_platform()` prints `linux-x86_64`, and the binfmt listing shows a `qemu-aarch64` entry. On a native ARM64 runner `uname -m` prints `aarch64`, the platform string is `linux-aarch64`, and no handler is involved. Then measure. cibuildwheel closes each run by printing the wheel count and the elapsed time, so both numbers you need for the model below come out of the log without extra instrumentation. Record the emulated aarch64 leg and the x86\_64 leg from the same commit, then repeat the aarch64 leg on a native runner. ### [Why a bigger x64 runner does not close the gap](#why-a-bigger-x64-runner-does-not-close-the-gap)Copy Anchor Link Resizing the x64 runner leaves the translation in place, so the per-instruction cost is unchanged. Extra vCPU also does less than it looks on a wheel build, because cibuildwheel builds one wheel at a time within a job. The only parallelism a larger machine can use is whatever the package's own build system exposes, such as `make -j` in a C extension or `cargo` compiling crates in parallel, and both of those are running under the interpreter too. Two more constraints worth checking before you plan the fix. Wheels are tagged, so a `manylinux_2_28_aarch64` artifact and a `musllinux_1_2_aarch64` artifact are separate builds with separate durations rather than one build with two outputs. And a GitHub Actions job runs for at most 6 hours, which an emulated matrix covering five interpreter versions can approach once a build requirement such as NumPy or PyArrow starts compiling from source. ## [Fix](#fix)Copy Anchor Link Move the aarch64 rows of the matrix onto a native ARM64 runner label and drop the QEMU step. The Dockerfile-free parts of the release workflow, including artifact upload and publishing, stay exactly as they are. ### [The wheel build matrix](#the-wheel-build-matrix)Copy Anchor Link This is the mapping to hold in one place: interpreter version, cibuildwheel build identifier, the tag that ends up in the wheel filename, and the runner label the row runs on. | Python | cibuildwheel build identifier | Wheel platform tag | Runner label | | ------------------ | ------------------------------------- | ------------------------------------- | --------------------------- | | 3.9 | cp39-manylinux\_aarch64 | cp39-cp39-manylinux\_2\_28\_aarch64 | warp-ubuntu-latest-arm64-8x | | 3.10 | cp310-manylinux\_aarch64 | cp310-cp310-manylinux\_2\_28\_aarch64 | warp-ubuntu-latest-arm64-8x | | 3.11 | cp311-manylinux\_aarch64 | cp311-cp311-manylinux\_2\_28\_aarch64 | warp-ubuntu-latest-arm64-8x | | 3.12 | cp312-manylinux\_aarch64 | cp312-cp312-manylinux\_2\_28\_aarch64 | warp-ubuntu-latest-arm64-8x | | 3.13 | cp313-manylinux\_aarch64 | cp313-cp313-manylinux\_2\_28\_aarch64 | warp-ubuntu-latest-arm64-8x | | 3.12 on musl | cp312-musllinux\_aarch64 | cp312-cp312-musllinux\_1\_2\_aarch64 | warp-ubuntu-latest-arm64-8x | | 3.13 on musl | cp313-musllinux\_aarch64 | cp313-cp313-musllinux\_1\_2\_aarch64 | warp-ubuntu-latest-arm64-8x | | 3.9 to 3.13 on x64 | cp3{9,10,11,12,13}-manylinux\_x86\_64 | manylinux\_2\_28\_x86\_64 | warp-ubuntu-latest-x64-8x | The `manylinux_2_28` part of the tag is the glibc floor set by [PEP 600](https://peps.python.org/pep-0600/) and is chosen by the image you point cibuildwheel at, so it is independent of the runner. The `aarch64` part is what the native runner change is about. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so the x86\_64 rows and the aarch64 rows of one matrix both run on native hardware under the same workflow. ### [Runner labels and rates](#runner-labels-and-rates)Copy Anchor Link Linux ARM64 labels, shapes, and per-minute rates come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing). | Label | vCPU | RAM | Storage | Rate per minute | Pinned alias | | ---------------------------- | ---- | ----- | --------- | --------------- | -------------------------- | | warp-ubuntu-latest-arm64-2x | 2 | 8GB | 150GB SSD | $0.003 | warp-ubuntu-2404-arm64-2x | | warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 | warp-ubuntu-2404-arm64-4x | | warp-ubuntu-latest-arm64-8x | 8 | 32GB | 150GB SSD | $0.012 | warp-ubuntu-2404-arm64-8x | | warp-ubuntu-latest-arm64-16x | 16 | 64GB | 150GB SSD | $0.024 | warp-ubuntu-2404-arm64-16x | | warp-ubuntu-latest-arm64-32x | 32 | 128GB | 150GB SSD | $0.048 | warp-ubuntu-2404-arm64-32x | Ubuntu 26.04 ships under `warp-ubuntu-2604-arm64-8x` and the rest of that series at the same rates. Pin the alias in `runs-on` when you want the image version to move on your schedule rather than when `latest` moves. The full label list is on the [Linux ARM64 runner page](/runners/linux-arm64). ### [Two porting details](#two-porting-details)Copy Anchor Link The Ubuntu 24.04 ARM64 image is compatible with the GitHub ARM64 image and carries the same preinstalled tooling, per the [preinstalled software reference](/docs/ci/preinstalled-software). Its work dir is `/runner/_work`, which differs from GitHub's `/home/runner/work/`, so a release script with a hardcoded runner path should read `$GITHUB_WORKSPACE` instead. The older Ubuntu 22.04 ARM64 image is the exception: it is based on the upstream `ubuntu:22.04` image, defaults to the `root` user, and carries minimal tooling, so a job pinned there installs its own build dependencies with `apt` first. If a wheel builds on the emulated leg and fails on the native one, the cause is usually a build requirement resolved from a source distribution with an architecture assumption baked in, or a step downloading a fixed `x86_64` URL. ## [Configuration](#configuration)Copy Anchor Link Here is the workflow most projects start from. One job, an x64 runner, QEMU registering handlers, and cibuildwheel asked for both architectures. Copy Text ``` name: wheels on: push: tags: ["v*"] jobs: wheels: runs-on: ubuntu-latest timeout-minutes: 360 steps: - uses: actions/checkout@v4 - uses: docker/setup-qemu-action@v3 with: platforms: arm64 - uses: pypa/cibuildwheel@v2.21.3 env: CIBW_ARCHS_LINUX: x86_64 aarch64 - uses: actions/upload-artifact@v4 with: name: wheels path: ./wheelhouse/*.whl ``` ### [The native matrix](#the-native-matrix)Copy Anchor Link Split the single job into one row per architecture and libc family, give each row its own runner label, and remove the QEMU step. Each row uploads its own artifact so the rows stay independent. Copy Text ``` name: wheels on: push: tags: ["v*"] workflow_dispatch: jobs: wheels: name: ${{ matrix.tag }} runs-on: ${{ matrix.runner }} timeout-minutes: 60 strategy: fail-fast: false matrix: include: - tag: manylinux-x86_64 runner: warp-ubuntu-latest-x64-8x archs: x86_64 build: "cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64" - tag: manylinux-aarch64 runner: warp-ubuntu-latest-arm64-8x archs: aarch64 build: "cp39-manylinux_aarch64 cp310-manylinux_aarch64 cp311-manylinux_aarch64 cp312-manylinux_aarch64 cp313-manylinux_aarch64" - tag: musllinux-aarch64 runner: warp-ubuntu-latest-arm64-8x archs: aarch64 build: "cp312-musllinux_aarch64 cp313-musllinux_aarch64" steps: - uses: actions/checkout@v4 - uses: pypa/cibuildwheel@v2.21.3 env: CIBW_ARCHS_LINUX: ${{ matrix.archs }} CIBW_BUILD: ${{ matrix.build }} CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_28 CIBW_BUILD_VERBOSITY: 1 CIBW_TEST_COMMAND: python -c "import mypkg; mypkg.selftest()" - uses: actions/upload-artifact@v4 with: name: wheels-${{ matrix.tag }} path: ./wheelhouse/*.whl if-no-files-found: error ``` The edit against the starting workflow is three lines of removal and one of addition per row. Copy Text ``` - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 - - uses: pypa/cibuildwheel@v2.21.3 ``` `fail-fast: false` matters here: a broken `cp39` wheel should not cancel the four interpreter versions that were about to succeed on the same tag. ### [Collecting and publishing](#collecting-and-publishing)Copy Anchor Link `actions/upload-artifact@v4` requires unique artifact names, which is why each row uploads `wheels-`. The publish job pulls them back into one directory with `merge-multiple`. Copy Text ``` publish: needs: wheels runs-on: warp-ubuntu-latest-x64-2x environment: pypi permissions: id-token: write steps: - uses: actions/download-artifact@v4 with: pattern: wheels-* path: dist merge-multiple: true - run: ls -l dist - uses: pypa/gh-action-pypi-publish@release/v1 ``` The `ls -l dist` step is worth keeping. It is the cheapest check that the aarch64 wheels actually arrived, since a matrix row that silently produced nothing is otherwise invisible until an ARM64 user reports that `pip install` fell back to the source distribution. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Assumptions, stated so you can substitute the two durations you measured in the diagnosis section: * One package with 3 C extension modules, built for 5 CPython versions, producing 5 `manylinux_2_28_aarch64` wheels in one matrix row. * Example durations: 9 minutes per wheel emulated, 2 minutes per wheel native, plus 4 minutes of checkout, container pull, and `auditwheel repair` in both cases. These are placeholders that make the arithmetic visible. Your ratio depends on how much of the build is CPU bound inside the container. * That gives 49 billed minutes emulated and 14 billed minutes native for the row. * 40 release and pre-release builds per month. * WarpBuild rates from the [pricing page](/pricing), billed per minute. | Path | Arithmetic | Cost per build | Monthly at 40 builds | | --------------------------------------------- | ----------- | -------------- | -------------------- | | Emulated aarch64 on warp-ubuntu-latest-x64-8x | 49 x $0.016 | $0.784 | $31.36 | | Native aarch64 on warp-ubuntu-latest-arm64-8x | 14 x $0.012 | $0.168 | $6.72 | Two levers move independently in that table. Removing the translation takes 35 billed minutes out of the row. The rate difference is worth $0.004 per minute at the 8 vCPU size, because the ARM64 rate sits below the x64 rate at every size in the catalog above. Wall clock moves further than the bill does. The matrix rows run in parallel, so the release finishes when the slowest row finishes. With the aarch64 row at 49 minutes and the x86\_64 row at roughly 14, the aarch64 row sets the tag-to-PyPI time on its own; native builds put the two rows within a few minutes of each other. ### [Against GitHub-hosted list prices](#against-github-hosted-list-prices)Copy Anchor Link GitHub publishes its per-minute rates in the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), with shapes in the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). At the same shape, `warp-ubuntu-latest-arm64-8x` (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB): 14 percent lower list price. GitHub list price checked on 2026-08-13. Applying that to the same 14 minute native row: $0.168 per build here against 14 x $0.014 = $0.196 per build, or $7.84 per month at 40 builds. Removing the emulation is the larger lever on either provider, and the per-minute rate stacks on top of it. Every cost and performance number on this page carries a source link and a checked-on date, and the same number appears on every WarpBuild page that uses it, so you can verify it before planning around it. ## [FAQ](#faq)Copy Anchor Link ### [Why is the aarch64 leg of my wheel matrix so much slower than the x86\_64 leg?](#why-is-the-aarch64-leg-of-my-wheel-matrix-so-much-slower-than-the-x86%5F64-leg)Copy Anchor Link On Linux, cibuildwheel builds inside a manylinux or musllinux container whose architecture follows the host. On an x86\_64 runner the aarch64 container only runs because `docker/setup-qemu-action` registered binfmt\_misc handlers, so every compiler, linker, and test process inside it runs as translated aarch64 code. The x86\_64 leg runs natively and pays none of that. ### [Do I still need docker/setup-qemu-action on a native ARM64 runner?](#do-i-still-need-dockersetup-qemu-action-on-a-native-arm64-runner)Copy Anchor Link No. The runner is aarch64, so the `manylinux_2_28_aarch64` container is a native container and needs no binfmt handler. Delete the step and set `CIBW_ARCHS_LINUX` to `aarch64`. Keep the QEMU step only in a job that still builds a foreign architecture on purpose. ### [Which platform tags does the aarch64 leg have to produce?](#which-platform-tags-does-the-aarch64-leg-have-to-produce)Copy Anchor Link manylinux wheels carry a tag such as `manylinux_2_28_aarch64` under PEP 600, and musl-based wheels carry `musllinux_1_2_aarch64` under PEP 656\. A glibc aarch64 wheel does not install on Alpine, so a project that supports both libc families needs both build identifiers in the matrix. The mapping from interpreter version to identifier to tag is in the matrix table above. --- # Automating Base Image Updates on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/base-image-update-workflow-github-actions Description: A scheduled job resolves the new base image digest, opens a pull request, and lets your checks prove the update before the rebuild hits a feature branch. # Automating Base Image Updates on GitHub Actions A scheduled job resolves the new base image digest, opens a pull request, and lets your checks prove the update before the rebuild hits a feature branch. Automate base image updates with a scheduled GitHub Actions job that resolves the digest a base tag currently points at, rewrites the pinned `FROM` line, and opens a pull request that your existing checks run against. The bump then arrives on a reviewable branch on a schedule you chose, and the cold rebuild it causes is paid once, in a window you expected. This guide covers the failure modes of leaving base images to drift, the shape of the update job, the workflow YAML for a repository with several pinned bases, and a time model for the first build after a base change. Why the rebuild happens at all is covered in [why a base image update invalidates a Docker build cache](/answers/why-do-my-docker-builds-lose-cache-after-a-base-image-update), and the term itself in [base image](/glossary/base-image). ## [Diagnosis](#diagnosis)Copy Anchor Link Five things go wrong, and only the first one is about caching. **An unpinned tag repoints on someone else's branch.** `FROM node:22-bookworm-slim` resolves through a mutable tag, so the publisher can push new content behind it at any time. BuildKit keys each layer on its parent, and invalidating one layer invalidates every layer downstream of it, per the [Docker cache invalidation reference](https://docs.docker.com/build/cache/invalidation/), checked on 2026-08-13\. The rebuild lands on whichever pull request happens to build first after the repoint, and the person who pays it changed one line of application code. **A pinned digest with no refresh job goes stale.** Pinning fixes the first problem and creates a second one: the base image freezes, including its unpatched packages, until a commit moves the digest. A pin without a schedule is a decision to stop taking upstream updates. **The bump pull request arrives with no checks.** This is the failure that quietly defeats the whole exercise. GitHub does not create workflow runs from events raised with the default `GITHUB_TOKEN`, documented in [automatic token authentication](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication), checked on 2026-08-13\. A job that opens a pull request with that token produces a branch with an empty checks list, and the update merges with no proof that the image still builds. **Pins live in more than one file.** A multi-stage Dockerfile carries one `FROM` per stage. Beyond it sit compose files, container jobs in workflow YAML, devcontainer definitions, and test fixtures. A refresh job that only reads `Dockerfile` leaves the rest of them frozen. **The schedule turns itself off.** GitHub disables scheduled workflows in a repository after 60 days of no repository activity, per the [events that trigger workflows reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13\. A quiet service repository is exactly the one whose base image nobody is watching. ## [Fix](#fix)Copy Anchor Link The job that solves all five is small, and its four steps map to the four failures above. 1. **Resolve the digest the tag points at today.** One registry query per pinned image, run on a cron rather than on a push. 2. **Rewrite the pin in place.** One pull request per base image, so a bump that breaks a build names its own culprit. 3. **Open it with a token that triggers workflows.** A GitHub App installation token or a personal access token, so the image build runs on the bump branch and the cold rebuild is paid there. 4. **Merge on green.** Auto-merge on the bump label turns a passing image build into a merged commit without a person in the loop, while a failing one stays open for review. Two rules make the result predictable. Pin every base image by digest with the tag left in the line as a readable label, so the digest is what resolves and the tag is what a reader recognizes. And run the update job for every file that carries a pin, using the matrix in the next section, because the Dockerfile is rarely the only one. ## [Configuration](#configuration)Copy Anchor Link ### [Where the pins live](#where-the-pins-live)Copy Anchor Link | Pin location | Reference format | Rewrite tool in the job | | --------------------------------- | -------------------------------- | -------------------------- | | FROM in a Dockerfile | node:22-bookworm-slim@sha256:... | sed on the FROM line | | A second or third stage FROM | its own tag and digest | one matrix entry per stage | | image: in a compose file | tag, digest optional | yq | | container.image in a workflow job | tag | yq on the workflow file | | image in devcontainer.json | tag | jq | ### [Resolving the digest](#resolving-the-digest)Copy Anchor Link | Command | Requires | Returns | | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | docker buildx imagetools inspect node:22 --format '{{.Manifest.Digest}}' | buildx | index digest for a multi-architecture tag, which is the reference to pin | | crane digest node:22 | the [crane CLI](https://github.com/google/go-containerregistry/blob/main/cmd/crane/README.md) | same digest with no Docker daemon | | docker manifest inspect -v node:22 | Docker CLI | full manifest list, per-platform digests included | Pin the index digest rather than a per-platform manifest digest. The index resolves to the right manifest on both `linux/amd64` and `linux/arm64`, which matters on multi-architecture builds, and it is the value the first two commands return by default. ### [The scheduled update workflow](#the-scheduled-update-workflow)Copy Anchor Link .github/workflows/base-image-update.yml Copy Text ``` name: base-image-update on: schedule: - cron: "0 6 * * 1" workflow_dispatch: permissions: contents: read jobs: bump: runs-on: warp-ubuntu-latest-x64-2x permissions: contents: write pull-requests: write strategy: fail-fast: false matrix: include: - image: node:22-bookworm-slim file: Dockerfile branch: chore/base-node-22 - image: ubuntu:24.04 file: tools/Dockerfile.ci branch: chore/base-ubuntu-2404 steps: - uses: actions/checkout@v4 - id: token uses: actions/create-github-app-token@v1 with: app-id: ${{ vars.BUMP_APP_ID }} private-key: ${{ secrets.BUMP_APP_PRIVATE_KEY }} - id: resolve env: IMAGE: ${{ matrix.image }} run: | digest=$(docker buildx imagetools inspect "$IMAGE" \ --format '{{.Manifest.Digest}}') echo "digest=$digest" >> "$GITHUB_OUTPUT" - id: rewrite env: IMAGE: ${{ matrix.image }} FILE: ${{ matrix.file }} DIGEST: ${{ steps.resolve.outputs.digest }} run: | sed -i -E \ "s|^FROM ${IMAGE}(@sha256:[0-9a-f]{64})?|FROM ${IMAGE}@${DIGEST}|" \ "$FILE" if git diff --quiet -- "$FILE"; then echo "changed=false" >> "$GITHUB_OUTPUT" else echo "changed=true" >> "$GITHUB_OUTPUT" fi - if: steps.rewrite.outputs.changed == 'true' uses: peter-evans/create-pull-request@v7 with: token: ${{ steps.token.outputs.token }} branch: ${{ matrix.branch }} labels: base-image-update commit-message: "chore: pin ${{ matrix.image }} to ${{ steps.resolve.outputs.digest }}" title: "Bump ${{ matrix.image }}" body: | Scheduled refresh of `${{ matrix.image }}` in `${{ matrix.file }}`. Merging rebuilds every layer above FROM. The rebuild is paid on this branch by the image build check below. ``` `fail-fast: false` keeps one unreachable registry from cancelling the other bumps. The digest step is a registry read, so the 2 vCPU size is the right host for it, and a week where nothing moved produces a run of a few seconds per matrix leg with no pull request, since [create-pull-request](https://github.com/peter-evans/create-pull-request) opens nothing when the tree is unchanged. ### [Let the checks prove it, then merge](#let-the-checks-prove-it-then-merge)Copy Anchor Link The bump branch has to run the same image build a human branch runs. With the App token above, `pull_request` fires normally, so no extra wiring is needed beyond your existing workflow. Add auto-merge on the label so a green run does not wait for a reviewer: .github/workflows/base-image-automerge.yml Copy Text ``` name: base-image-automerge on: pull_request_target: types: [labeled] jobs: enable: if: github.event.label.name == 'base-image-update' runs-on: warp-ubuntu-latest-x64-2x permissions: pull-requests: write contents: write steps: - run: gh pr merge --squash --auto "$PR_URL" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_URL: ${{ github.event.pull_request.html_url }} ``` Auto-merge waits for the required checks to pass before it merges, per [automatically merging a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request), checked on 2026-08-13, so a base image that breaks the build leaves the pull request open instead. If your pins all sit in Dockerfiles, the `docker` package ecosystem in [Dependabot's options reference](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference), checked on 2026-08-13, does the resolve and rewrite for you, and [dependency update jobs on GitHub Actions](/guides/dependabot-and-renovate-jobs-github-actions) covers how to keep those pull requests off the full matrix. The bespoke job earns its place when pins live outside those files or when you want every bump to land in one window. ### [Where the rebuild lands after the merge](#where-the-rebuild-lands-after-the-merge)Copy Anchor Link The rebuild is fixed work, and the variable is how many separate caches pay for it. A WarpBuild builder profile maps to one dedicated build virtual machine that keeps its layer cache on local disk and shares it across every job pointed at that profile, per the [remote Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13\. One build after the merge repopulates the chain and the jobs behind it read warm layers, so the cold rebuild count tracks digest changes rather than branch count. Point a small job at the profile on pushes to the default branch to make that first rebuild deterministic: .github/workflows/warm-builder.yml Copy Text ``` name: warm-builder on: push: branches: [main] paths: [Dockerfile, tools/Dockerfile.ci] concurrency: group: warm-builder cancel-in-progress: true jobs: warm: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: Warpbuilds/build-push-action@v6 with: context: . push: false tags: acme/api:warm profile-name: api-amd64 ``` Dependency caches outside the Docker layer chain are a separate lever, and the drop-in replacement for `actions/cache` is in the [WarpBuild caching documentation](/docs/ci/features/caching). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The model is arithmetic on stated assumptions rather than a measurement. Replace the rows with the step durations in your own build log. ### [Assumptions](#assumptions)Copy Anchor Link * A Node service image, two stages, both on `node:22-bookworm-slim`, 1,400 npm packages. * Six pinned base images across the repository, refreshed by the weekly cron above. * On average 2.5 of those six digests move in a given week, which is 10 base changes a month. * Job runner `warp-ubuntu-latest-x64-4x` at $0.008 per minute and a 16 vCPU, 32 GB builder profile at $0.06 per minute, both from the [pricing page](/pricing), checked on 2026-08-13. ### [The first build after a base change](#the-first-build-after-a-base-change)Copy Anchor Link | Step | Warm | First build after the bump | | -------------------------------------- | ---- | -------------------------- | | Checkout, registry login, action setup | 0:50 | 0:50 | | Pull the new base layers, both stages | 0:00 | 0:35 | | apt-get install build dependencies | 0:00 | 1:20 | | npm ci, 1,400 packages | 0:00 | 3:05 | | Compile the application bundle | 1:30 | 1:45 | | COPY application source | 0:05 | 0:05 | | Export and push changed layers | 0:35 | 2:40 | | Total | 3:00 | 10:20 | One base change costs 7 minutes 20 seconds of extra wall clock and 7.33 extra minutes on both the runner and the builder, which is 7.33 at $0.068 combined, or $0.50. ### [A month of staying current](#a-month-of-staying-current)Copy Anchor Link Each base change is paid twice on this setup: once on the bump pull request, where the check proves the image still builds, and once by the warm job on the default branch after the merge. Every feature branch that builds afterwards reads the warm cache. | Line | Value | | ------------------------------- | ----- | | Base changes per month | 10 | | Cold builds per change | 2 | | Extra minutes over a warm build | 146.7 | | Extra runner cost at $0.008 | $1.17 | | Extra builder cost at $0.06 | $8.80 | | Extra cost per month | $9.97 | Ten dollars a month, on these assumptions, is what it costs to know the age of every base image in the repository and to have every rebuild land on a branch created for it. The comparison that matters is time rather than dollars. The same 10 minute 20 second wait otherwise lands, without warning, on the next person to open a pull request after upstream repoints a tag. Two levers move the total. A daily cron finds moving digests sooner and pays more cold rebuilds, since a base that moves three times in a week is billed three times rather than once. And a shared builder profile keeps the second number at one rebuild per change; per-branch cache scopes multiply it by the number of active branches. ### [Rates used](#rates-used)Copy Anchor Link | Resource | Shape | Price per minute | | ------------------------- | -------------------------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | | Builder profile | 16 vCPU, 32 GB, 100GB disk | $0.06 | | Builder profile | 32 vCPU, 64 GB, 200GB disk | $0.12 | The runner and the builder are billed separately, so a job that offloads the build to a builder profile pays a small runner rate for the orchestrating job and the builder rate for the build itself. Linux runners are the usual host for a Docker build job. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the product surface. The rest of the pipeline is covered in the [Docker builds on GitHub Actions hub](/solutions/docker-builds-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [Why does my base image bump pull request show no checks?](#why-does-my-base-image-bump-pull-request-show-no-checks)Copy Anchor Link GitHub does not start workflow runs from events created with the default `GITHUB_TOKEN`, so a pull request opened by a job holding that token arrives with an empty checks list. Give the job a GitHub App installation token or a personal access token instead, and the `pull_request` event fires normally and your image build runs on the bump branch. ### [How often should the base image update job run?](#how-often-should-the-base-image-update-job-run)Copy Anchor Link Weekly is the usual setting because it bounds the age of the base image at seven days while producing at most one bump pull request per base per week. A daily cron finds new digests sooner and pays a full cold rebuild each time one moves. Whichever cadence you pick, keep the repository active, since GitHub disables scheduled workflows after 60 days without repository activity. ### [Do I still need this if Dependabot already updates my Dockerfile?](#do-i-still-need-this-if-dependabot-already-updates-my-dockerfile)Copy Anchor Link Dependabot's `docker` ecosystem bumps tags and digests in Dockerfiles, and for most repositories that covers the `FROM` lines. A bespoke job is worth writing when pins live outside the files Dependabot reads, such as a compose file, a container job in a workflow, or a devcontainer definition, or when you want the rebuild scheduled into one window rather than arriving whenever the bot opens a pull request. --- # Bazel Disk Cache on GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/guides/bazel-disk-cache-github-actions Description: Bazel's --disk_cache is deleted with the GitHub Actions runner. Carry it forward with a keyed cache action and a size cap, or boot from a snapshot runner. # Bazel Disk Cache on GitHub Actions Runners Bazel's --disk\_cache is deleted with the GitHub Actions runner. Carry it forward with a keyed cache action and a size cap, or boot from a snapshot runner. Bazel's `--disk_cache` writes action results and output blobs into a directory on the machine that runs the build, and a GitHub Actions runner is deleted when its job finishes, so that directory is gone before the next run starts. Two mechanisms carry it forward: save and restore the directory with a cache action keyed on the files that decide your action keys, or run the job on a machine that already holds the directory, which is what a [snapshot runner](/runners/snapshot) does. This guide covers how the disk cache differs from a remote cache, the workflow that moves it with a size cap and a stable key, the prepared-machine option for directories too large to move, and the arithmetic that picks between them. ## [Diagnosis](#diagnosis)Copy Anchor Link Start by confirming the cache is missing rather than misconfigured. Bazel prints a process summary at the end of every build, and the disk cache shows up in it: Copy Text ``` INFO: 3184 processes: 2601 disk cache hit, 118 internal, 465 linux-sandbox. ``` On GitHub Actions that line usually reads zero disk cache hits on every run, including a rerun of the same commit. Three separate causes produce that symptom, and they take different fixes. ### [The directory lives on a machine that is thrown away](#the-directory-lives-on-a-machine-that-is-thrown-away)Copy Anchor Link The disk cache holds two things: action cache entries keyed by the action key, and the content-addressed blobs those entries point at. Both are plain files under the directory you pass to `--disk_cache`, with no network protocol and no server involved, as the [Bazel remote caching documentation](https://bazel.build/remote/caching) describes. A hosted GitHub Actions runner is provisioned per job and destroyed after it, so nothing under that directory outlives the job that wrote it. ### [The directory outgrows what a cache action can move](#the-directory-outgrows-what-a-cache-action-can-move)Copy Anchor Link Bazel writes an entry for every executed action and applies no ceiling by default. On a monorepo with a wide action graph the directory reaches tens of gigabytes within a week of builds. A cache action is a transfer wrapped in a workflow step: it compresses and uploads the paths at the end of a run, then downloads and decompresses them at the start of the next one, and the runner is billed for the wall clock time it spends waiting. That transfer scales with the size of the directory rather than the size of the change, so a pull request touching one file pays the full restore. Storage limits bite at the same time. GitHub caps the combined size of all caches in a repository at 10 GB by default, evicts entries in least recently used order once the total passes the cap, and deletes any entry unused for 7 days, per the [GitHub dependency caching documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching) checked on 2026-08-13\. A 12 GB disk cache does not fit under that ceiling at all, and cache entries are scoped to the branch that wrote them plus the base branch, so several active branches each hold their own copy. ### [The key churns on every commit](#the-key-churns-on-every-commit)Copy Anchor Link A key built from `hashFiles('**/*.go')` or any other source glob writes a brand new full-size entry on every commit, and the entry you restore is always one commit stale. The disk cache is content addressed internally, so it does not need a key that tracks source changes. It needs a key that changes when the action keys change, which happens on a Bazel version bump, a toolchain change, or an rc file edit. Measure before choosing a fix. Add the restore step seconds and the post-run save seconds from a recent run, then compare that total against the execution time the disk cache hits would remove. When the transfer is larger, moving the directory is the wrong mechanism no matter how good the hit rate gets. ## [Fix](#fix)Copy Anchor Link Three placements exist for Bazel cache state on GitHub Actions, and the choice is a size question. | Property | Disk cache (\--disk\_cache) | Remote cache (\--remote\_cache) | | ----------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------- | | What it stores | Action cache entries and the output blobs they point at | The same entries and blobs | | Where it lives | A directory on the runner filesystem | A gRPC or HTTP endpoint you operate or buy | | Shared across machines | No | Yes | | How an action reaches it | Local file reads | A network round trip per action | | Cost on GitHub Actions | The transfer that moves the directory in and out, or nothing when the machine keeps it | The service, its storage, and latency per action | | Write access on pull requests | The workflow's own cache scope | Needs a token and a read-only mode for forks | | Breaks down when | The directory grows past what you can move per run | The endpoint is far from the runner or unavailable | The disk cache is the cheaper option in three situations. First, when there is no cache service to run: the flag is a directory path, and the operational cost is zero. Second, when the compressed directory stays small enough that its restore costs less runner time than the actions it skips, which is the arithmetic in the cost model below. Third, when the machine keeps the directory between runs, because a local tier that never transfers has no per-run cost at all. A remote cache stays the right answer for sharing results across machines, branches, and laptops. The two combine cleanly: Bazel checks the disk cache first, falls through to the remote endpoint, and writes remote hits back to disk so the next action in the same build reads them locally. [Remote build cache](/glossary/remote-build-cache) covers that layering, [running Bazel on GitHub Actions with a remote cache](/solutions/bazel-github-actions) covers the endpoint side, and [Bazel remote execution on GitHub Actions](/guides/bazel-remote-execution-github-actions) covers the case where the actions themselves run off the runner. The general pattern behind all of this is written up in [persistent caches on GitHub Actions runners](/guides/persistent-caches-github-actions). ## [Configuration](#configuration)Copy Anchor Link Pin the directory in an rc file the workflow loads, so the path is identical on every run and no step has to guess it. ci/.bazelrc Copy Text ``` startup --output_user_root=/home/runner/.cache/bazel build:ci --disk_cache=/home/runner/.cache/bazel-disk build:ci --experimental_disk_cache_gc_max_size=20G build:ci --repository_cache=/home/runner/.cache/bazel-repo build:ci --incompatible_strict_action_env build:ci --announce_rc ``` `--incompatible_strict_action_env` is the flag that decides whether two runners agree on an action key at all, because without it Bazel passes the client `PATH` into action environments. `--experimental_disk_cache_gc_max_size` is the in-Bazel ceiling on recent releases; older releases need the prune step in the workflow below. ### [Moving the directory with a cache action](#moving-the-directory-with-a-cache-action)Copy Anchor Link .github/workflows/bazel.yml Copy Text ``` name: bazel on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - name: Restore Bazel disk cache id: disk-cache uses: WarpBuilds/cache/restore@v1 with: path: ~/.cache/bazel-disk key: bazel-disk-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('MODULE.bazel.lock', '.bazelversion', '.bazelrc', 'ci/.bazelrc', 'toolchains/**') }} restore-keys: | bazel-disk-${{ runner.os }}-${{ runner.arch }}- - name: Build and test run: | bazel --bazelrc=ci/.bazelrc test //... --config=ci --jobs=8 - name: Cap the disk cache before saving run: | bazel --bazelrc=ci/.bazelrc shutdown du -sh ~/.cache/bazel-disk find ~/.cache/bazel-disk -type f -atime +3 -delete - name: Save Bazel disk cache if: github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: ~/.cache/bazel-disk key: ${{ steps.disk-cache.outputs.cache-primary-key }} ``` Four details in that job are the whole technique. The key hashes workspace files rather than sources. `MODULE.bazel.lock`, `.bazelversion`, the rc files, and the toolchain definitions are the inputs that move action keys wholesale; a source edit invalidates individual actions inside the cache and leaves the rest usable. The `restore-keys` prefix means a Bazel bump falls back to the previous entry instead of starting empty. `runner.arch` belongs in the key because action results do not cross architectures. An ARM64 job and an x64 job maintain separate key spaces by design, so one shared entry would restore blobs the other can never hit. A workspace that builds on more than one of them keeps one entry per platform. The save runs on the default branch only. Pull requests read the shared entry through `restore-keys` and write nothing, which keeps one authoritative entry instead of one per branch and removes the upload from the critical path of every review cycle. `bazel shutdown` runs before the prune so the Bazel server is not writing into the directory while `find` walks it, and `du -sh` prints the size into the job log so the growth curve is visible without extra tooling. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` with no repository size ceiling, is enabled by default on Linux runners, and is documented in the [WarpBuild caching docs](/docs/ci/features/caching). ### [The prepared-machine option](#the-prepared-machine-option)Copy Anchor Link When the directory is too large to move on every run, stop moving it. A snapshot runner captures the whole runner VM at a chosen point and boots later jobs from that image, so the disk cache is already on the filesystem when the job starts and nothing transfers. .github/workflows/bazel-snapshot.yml Copy Text ``` jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=bazel-disk-main' }} steps: - uses: actions/checkout@v5 - name: Build and test run: | bazel --bazelrc=ci/.bazelrc test //... --config=ci --jobs=8 - name: Cap and clean before capture if: github.ref == 'refs/heads/main' run: | bazel --bazelrc=ci/.bazelrc shutdown find ~/.cache/bazel-disk -type f -atime +3 -delete rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "bazel-disk-main" fail-on-error: true wait-timeout-minutes: 60 ``` Five constraints from the [snapshot runner documentation](/docs/ci/features/snapshot-runners) shape that job. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, and snapshot labels on any other runner type are silently ignored while the job runs normally. The `/tmp` directory does not persist because it is cleaned on reboot and a snapshot boot is a reboot, which is why the rc file pins the disk cache under the runner home. Snapshots are deleted after 15 days, so the default-branch job doubles as the refresher. Boot from a snapshot takes 45 to 60 seconds. The prune still matters, because an alias that is rebuilt from itself accumulates entries across every run that boots from it, against a 150GB SSD. Snapshot runners sit alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface. CI observability is the one worth wiring up here, because it reports runner system metrics correlated with the job logs, which is how you tell a build that is waiting on a cache transfer from one that is CPU bound. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The fees that change with the mechanism, from the [pricing page](/pricing): | Line | Rate on hosted runners | | ---------------------- | ------------------------ | | Cache storage | $0.20 per GB-month | | Cache write or restore | $0.0001 per operation | | Snapshot restore | $0.04 per job | | Snapshot storage | $0.025 per snapshot-hour | Both cache lines are included at no charge on BYOC runners. Runner rates for the sizes a Bazel build usually lands on: | Label | vCPU | RAM | USD per minute | Minutes equal to one snapshot restore | | ---------------------------- | ---- | ----- | -------------- | ------------------------------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | 5.00 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | 2.50 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | 1.25 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | 3.33 | | warp-ubuntu-latest-arm64-16x | 16 | 64 GB | $0.024 | 1.67 | The last column is the fixed hurdle: a snapshot has to remove at least that much setup time per run before it pays for the restore fee alone. ### [Worked model](#worked-model)Copy Anchor Link Assumptions, stated so you can substitute your own step timings: * 600 workflow runs per month, one in six of them on the default branch. * A disk cache directory that compresses to 6 GB, restoring in 2.50 minutes and saving in 2.50 minutes. * Base image boot of 0.25 minutes, snapshot boot of 1.00 minute, snapshot capture of 4.00 minutes on each of 100 default-branch runs. | Stage | Cache action path | Snapshot path | | --------------------- | ----------------- | ------------- | | Runner boot | 0.25 min | 1.00 min | | Restore state | 2.50 min | 0.00 min | | Save state, amortized | 0.42 min | 0.67 min | | Total per run | 3.17 min | 1.67 min | The snapshot path removes 1.50 minutes per run, or 900 minutes per month. Priced out on `warp-ubuntu-latest-x64-8x` at $0.016 per minute: | Line | Monthly | | ---------------------------------------------------- | --------------------- | | Runner minutes removed, 900 at $0.016 | $14.40 | | Cache storage no longer held, 6 GB at $0.20 | $1.20 | | Cache operations no longer run, 700 at $0.0001 | $0.07 | | Snapshot restores added, 600 at $0.04 | $24.00 | | Snapshot storage added, 720 snapshot-hours at $0.025 | $18.00 | | Net | $26.33 more expensive | At this size the cache action wins, which is the point of running the arithmetic before the migration. Two variables move the result. Runner rate is the weaker one: the same workload on `warp-ubuntu-latest-x64-16x` at $0.032 per minute is worth $28.80 of removed minutes against the same $42.00 of snapshot fees, which narrows the gap without closing it. Directory size is the one that decides. Grow the disk cache to 13 GB compressed, where the restore takes 5.50 minutes and the save 5.50, and the cache path costs 6.67 minutes per run against 1.67 on the snapshot path. That is 3,000 minutes per month, worth $96.00 on the 16x label against $42.00 of snapshot fees. The rule that falls out: the transfer is the variable cost and the snapshot fees are fixed, so the crossover moves with directory size and runner rate together. Repositories under a few gigabytes of compressed disk cache stay on the cache action; monorepos above roughly 10 GB stop moving the directory. ## [FAQ](#faq)Copy Anchor Link ### [Can I cache the Bazel disk cache with a cache action?](#can-i-cache-the-bazel-disk-cache-with-a-cache-action)Copy Anchor Link Yes, with three adjustments. Key the entry on the files that decide action keys (`MODULE.bazel.lock`, `.bazelversion`, the rc files, toolchain definitions) rather than on source hashes, so the entry survives ordinary commits. Save from the default branch only, so pull requests read the shared entry and never write near-duplicates. Cap the directory before the save step, because the restore and save transfer grows with the directory and is billed as runner minutes. ### [How large should I let the Bazel disk cache grow on a runner?](#how-large-should-i-let-the-bazel-disk-cache-grow-on-a-runner)Copy Anchor Link Cap it at the size whose restore costs less runner time than the actions the hits remove. Recent Bazel releases enforce a ceiling with `--experimental_disk_cache_gc_max_size`; older releases need a prune step such as `find ~/.cache/bazel-disk -type f -atime +3 -delete` after `bazel shutdown`. WarpBuild Ubuntu runners carry a 150GB SSD, so the transfer cost binds long before the disk does. ### [Should I use the disk cache or a remote cache on GitHub Actions?](#should-i-use-the-disk-cache-or-a-remote-cache-on-github-actions)Copy Anchor Link Both, when you operate a cache service. Bazel checks the disk cache first and writes remote hits back to disk, so the disk cache acts as a local tier in front of the remote endpoint. When there is no service to operate, the disk cache is the whole mechanism, and the question becomes how to keep the directory on the machine between runs. --- # Bazel Remote Execution from GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/bazel-remote-execution-github-actions Description: Point Bazel at a remote execution endpoint from GitHub Actions, keep loading and analysis on the runner, and size the runner for the work that remains. # Bazel Remote Execution from GitHub Actions Point Bazel at a remote execution endpoint from GitHub Actions, keep loading and analysis on the runner, and size the runner for the work that remains. Last verified: 2026-08-13 Bazel remote execution from GitHub Actions means pointing the build at a Remote Execution API endpoint with `--remote_executor`, so a pool of workers runs the compile, link, and test actions while the runner keeps loading, analysis, action key computation, input upload, and output fetch. The runner size then follows that residual work rather than peak `--jobs` parallelism, which usually means a `warp-ubuntu-latest-x64-4x` label at $0.008 per minute instead of a 16 vCPU one. This guide covers how [remote execution](/glossary/remote-execution) differs from remote caching in terms of what leaves the runner, the flags and platform properties that make the switch work, the workflow and rc file, and a cost model comparing a cache-only job on a large runner against a remote execution job on a small one. ## [Diagnosis](#diagnosis)Copy Anchor Link Both mechanisms speak the same [Remote Execution API](https://github.com/bazelbuild/remote-apis) and both are configured with a `grpcs://` endpoint, so teams often assume that turning on [remote caching](https://bazel.build/remote/caching) already covers execution. It does not. The two move different phases of the build off the machine. | Phase of a Bazel build | Remote cache only | Remote execution | | ------------------------------------- | ------------------------------- | ------------------------------------- | | Loading and analysis | Runner | Runner | | Action key computation | Runner | Runner | | Input upload | On writes after a miss | For every input blob the workers lack | | Action execution: compile, link, test | Runner | Worker pool | | Output storage | Runner disk plus a cache upload | Worker CAS, fetched on demand | | Peak CPU demand | Runner, sized to \--jobs | Worker pool | A remote cache, [set up for Bazel with --remote\_cache](/solutions/bazel-github-actions), answers one question: has anyone produced this exact action key before. When the answer is yes the action runs nowhere, which is why cache hit rates dominate warm pull request runs. When the answer is no, a cache-only setup executes the action on the GitHub Actions VM, so the worst case on a cache-only build stays a full local build. That worst case arrives on schedule: a dependency bump, a toolchain change, a compiler flag change, or an edit to a widely included header invalidates most of the [task graph](/glossary/task-graph) at once. Bazel reports the split at the end of every invocation, and that line is the diagnosis: Copy Text ``` INFO: 9,041 processes: 7,900 remote cache hit, 1,105 linux-sandbox, 36 internal. ``` The second bucket is the one to read. A large `linux-sandbox`, `local`, or `worker` count on your slowest jobs means execution is happening on the runner, and no cache key tuning changes that number. For per-action detail, add `--execution_log_json_file` to the invocation and count actions by strategy and duration; recent Bazel releases also offer `--execution_log_compact_file` for the same data in a smaller format. The second symptom is a runner sized for a phase that occupies part of the job. A 16 vCPU label with `--jobs=16` is saturated during execution and close to idle during loading, analysis, and output fetch. WarpBuild [CI observability](/docs/ci/features/observability) shows that shape per step, which is the fastest way to confirm how many minutes of a 16 vCPU label are actually spent compiling. Two failure modes decide whether remote execution helps before you configure anything. The first is platform mismatch. An action key includes the execution platform properties, so if the properties the workflow sets differ from what the workers advertise, actions either fail to schedule or compute distinct keys and re-execute on every run. The second is input transfer. A job whose inputs are large and whose content addressable store is cold or far away spends its minutes uploading blobs, and a runner-local [disk cache](/guides/bazel-disk-cache-github-actions) is the better first move there. Measure the upload seconds from the execution log before switching a workflow over. ## [Fix](#fix)Copy Anchor Link Set the executor, then reshape three defaults that assume local execution. The [remote build execution documentation](https://bazel.build/remote/rbe) covers the endpoint side; the parts that matter on a GitHub Actions runner are below. `--jobs` changes meaning. Under remote execution it bounds in-flight remote actions rather than local processes, so a value tied to the runner core count starves the pool. Values between 50 and 200 are normal on a 4 vCPU runner. Output downloads have to be capped. Without a policy the runner pulls every intermediate output back over the network and the transfer replaces the compile time you removed. Set `--remote_download_outputs=toplevel` so the runner fetches the outputs of requested targets only, and keep it explicit so older Bazel versions and local runs agree with GitHub Actions. Execution platform properties need one owner. Either set them on a `platform` target with `exec_properties` or pass `--remote_default_exec_properties`, and pick one: the flag applies only when the execution platform carries no properties of its own, so a platform target silently wins and the flag becomes dead configuration. Actions that cannot leave the machine stay put. Targets tagged `no-remote`, `local`, or `requires-network` run on the runner under any configuration, and a `--strategy` override per mnemonic keeps a test runner local without disabling remote execution for compiles. Count those actions before sizing, because they are the residual CPU load. Reachability is the last piece. An endpoint on a private network needs the runner inside that network: the WarpBuild networking addon joins the runner to your Tailscale tailnet through the `network.name=` dynamic label, and the addon is listed at $0 per minute on the [pricing page](/pricing). A public endpoint needs only an authorization header from a repository secret. One platform boundary is worth stating early. A Linux worker pool cannot execute Apple toolchain actions, so the iOS half of a Bazel workspace stays on a macOS runner with a remote cache while the server half dispatches to the pool. ### [Size the runner for the residual work](#size-the-runner-for-the-residual-work)Copy Anchor Link After the switch, the runner does checkout, Bazel startup, loading and analysis of the target pattern, action key computation with its cache lookups, input upload for missing blobs, output fetch for top level results, and any locally tagged actions. That profile is network bound and moderately RAM bound, with one or two cores busy during analysis. | Label | Shape | Per minute | Fits | | -------------------------- | -------------- | ---------- | --------------------------------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | Small workspaces where every action is remote | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | Default under remote execution | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | Large analysis phase, or tagged local tests | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | Keep only while execution stays on the runner | Rates are from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The same sizing logic applies to every tool in the [monorepo guide](/solutions/monorepo-github-actions) that supports a worker pool. ## [Configuration](#configuration)Copy Anchor Link Keep the endpoint configuration in a checked-in rc file under a `ci` config so a developer can reproduce an action key locally. .bazelrc Copy Text ``` common --enable_bzlmod build --incompatible_strict_action_env build --remote_local_fallback=false build:ci --remote_executor=grpcs://rbe.internal.example.com:443 build:ci --remote_instance_name=projects/build/instances/default build:ci --remote_timeout=3600 build:ci --jobs=120 build:ci --remote_download_outputs=toplevel build:ci --experimental_remote_cache_compression build:ci --extra_execution_platforms=//platforms:rbe_linux_x64 build:ci --host_platform=//platforms:rbe_linux_x64 build:ci --execution_log_json_file=/home/runner/bazel-exec.json ``` The platform target carries the properties the scheduler matches against, pinned by digest so a worker image rebuild does not change action keys by surprise: platforms/BUILD.bazel Copy Text ``` platform( name = "rbe_linux_x64", constraint_values = [ "@platforms//os:linux", "@platforms//cpu:x86_64", ], exec_properties = { "OSFamily": "Linux", "container-image": "docker://ghcr.io/example/bazel-worker@sha256:3f9c1d2b", }, ) ``` The workflow itself gets smaller as the work leaves the runner: Copy Text ``` name: bazel on: pull_request: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x;network.name=rbe-tailnet steps: - name: Checkout code uses: actions/checkout@v5 - name: Build and test env: RBE_TOKEN: ${{ secrets.RBE_TOKEN }} run: | bazel test --config=ci \ --remote_header=Authorization="Bearer ${RBE_TOKEN}" \ //... - name: Upload execution log if: always() uses: actions/upload-artifact@v4 with: name: bazel-execution-log path: /home/runner/bazel-exec.json retention-days: 7 ``` Three details in that file carry the behavior. The `runs-on` label is a 4 vCPU size with the networking addon attached, so the runner reaches a private endpoint without one being exposed publicly. `--jobs=120` comes from the rc file rather than the core count. And the execution log is uploaded on every run, including failures, so the strategy split is available when a job gets slower next month. Lockfile keyed downloads still belong in a cache action rather than in the worker pool. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` and is documented on the [caching page](/docs/ci/features/caching); use it for the repository cache directory so an `http_archive` fetch does not run on every job. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Assumptions, stated so you can substitute your own step timings: * A Bazel monorepo with 9,000 actions in a full build of `//...`, of which 8,600 are compile or test actions eligible for the worker pool. * 700 pull request runs per month, 15 percent of them on a cold graph after a dependency or toolchain bump. * Runner rates from the [pricing page](/pricing): `warp-ubuntu-latest-x64-16x` at $0.032 per minute, `warp-ubuntu-latest-x64-4x` at $0.008 per minute. * Worker pool compute is billed by whoever operates the pool and sits outside this model, which prices the GitHub Actions runner side only. | Stage | Cache only, 16 vCPU | Remote execution, 4 vCPU | | ------------------------------------ | ------------------- | ------------------------ | | Boot and checkout | 0.70 min | 0.70 min | | Bazel startup, loading, analysis | 1.60 min | 1.90 min | | Action key lookups and input upload | 0.50 min | 1.30 min | | Execution, warm run at 600 actions | 3.20 min | 1.10 min | | Output fetch and artifact upload | 0.60 min | 0.80 min | | Total, warm run | 6.60 min | 5.80 min | | Execution, cold run at 8,600 actions | 31.00 min | 4.60 min | | Total, cold run | 34.40 min | 9.30 min | Analysis takes longer on the smaller runner, and input upload roughly doubles, because both are real costs of the switch. The cold run is where the shape changes: 34.40 minutes becomes 9.30, and that is the run a developer waits on after a dependency bump. Monthly, at 595 warm runs and 105 cold runs: | Line | Cache only, 16 vCPU | Remote execution, 4 vCPU | | ------------------- | ------------------- | ------------------------ | | Warm run minutes | 3,927 | 3,451 | | Cold run minutes | 3,612 | 977 | | Total minutes | 7,539 | 4,428 | | Rate per minute | $0.032 | $0.008 | | Monthly runner cost | $241.25 | $35.42 | The cold run share drives that gap. Halve it to 5 percent and the cache-only path falls to 5,593 minutes at $178.98 while the remote execution path falls to 4,183 minutes at $33.46, so the ordering holds and the distance narrows. Repositories that rarely invalidate the graph should reach for a remote cache and a warm disk cache first. ### [Against GitHub-hosted list prices](#against-github-hosted-list-prices)Copy Anchor Link The cache-only column is the one that maps to a GitHub-hosted larger runner, since the comparison there is a runner size rather than a mechanism. `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13, from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The same 7,539 cache-only minutes cost $316.64 at the GitHub-hosted rate, against $241.25 on the WarpBuild label and $35.42 once the actions move to a worker pool and the label drops to 4 vCPU. Every number above carries a source and a checked-on date, and the step timings are assumptions you should replace with your own execution log before committing to a size. ## [FAQ](#faq)Copy Anchor Link ### [What is the difference between Bazel remote caching and remote execution?](#what-is-the-difference-between-bazel-remote-caching-and-remote-execution)Copy Anchor Link A remote cache moves stored results off the runner: on a hit the action never runs anywhere. Remote execution moves the work itself off the runner: on a miss the action runs on a worker in the pool instead of on the GitHub Actions VM. Loading, analysis, and action key computation stay on the runner under both. ### [Do I still need a remote cache when remote execution is on?](#do-i-still-need-a-remote-cache-when-remote-execution-is-on)Copy Anchor Link Yes, and with `--remote_executor` set you already have one, because the same endpoint serves the action cache unless `--remote_cache` points somewhere else. A cache hit costs one lookup and skips execution entirely, so the cache is the cheaper of the two paths and remote execution is the fallback for everything it misses. ### [What runner size fits a job that dispatches its actions to a worker pool?](#what-runner-size-fits-a-job-that-dispatches-its-actions-to-a-worker-pool)Copy Anchor Link Start at `warp-ubuntu-latest-x64-4x`, 4 vCPU and 16 GB at $0.008 per minute. The residual work is checkout, Bazel startup, loading and analysis, action key computation, input upload, and output fetch, which is network heavy rather than core heavy. Move to `warp-ubuntu-latest-x64-8x` when analysis of a large graph is the long pole or when tagged local actions still run on the runner. --- # Publishing Build Artifacts from GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/build-artifacts-publishing-github-actions Description: Upload build output once with actions/upload-artifact, consume it by name in later jobs, and set retention-days to the cadence you actually roll back to. # Publishing Build Artifacts from GitHub Actions Upload build output once with actions/upload-artifact, consume it by name in later jobs, and set retention-days to the cadence you actually roll back to. Publishing a build artifact takes two steps: `actions/upload-artifact@v4` on the job that produced the files, and `actions/download-artifact@v4` with the same `name` on every job that consumes them. The two settings that decide what this costs are `retention-days` on the upload and the runner sizes on both ends, because storage bills per GB-month for the whole retention window and every transfer minute bills as runner minutes. This guide covers the handoff pattern that stops downstream jobs from rebuilding, the workflow that implements it, and the storage arithmetic that decides between an artifact and a container registry. ## [Diagnosis](#diagnosis)Copy Anchor Link Every job in a workflow gets its own runner and that runner is deleted when the job ends, so a compiled bundle sitting in `dist/` does not survive into the next job. Teams hit that wall and pick one of two answers. The common one is to repeat the build step in each downstream job, which pays for the same compile three or four times and lets the deploy job ship bytes that no test job ever saw. The other is the artifact handoff: one job produces, later jobs consume by name, and nothing rebuilds. The handoff is worth stating precisely, because the guarantees are what make it safe to skip the rebuild. * One producer job runs the build and uploads the output under a name that identifies the commit. * Every consumer job declares a `needs` edge on the producer and downloads that exact name. * No consumer runs a compiler. A consumer that rebuilds is testing a different binary from the one you deploy. * The artifact outlives the run, so a human can download the exact bytes from the run summary page when a deploy goes wrong. Three GitHub behaviors shape the rest of the page. Artifacts are retained for 90 days by default, and the retention period is configurable [between 1 and 90 days on public repositories and between 1 and 400 days on private repositories](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository). Artifact storage counts against the shared storage allowance on your plan and bills at [$0.25 per GB-month beyond it](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13\. And in v4 an artifact name is unique within a run, so a four-way matrix uploading one name fails after the first leg. Retention is where the bill hides. A 1.2 GB bundle uploaded 40 times a weekday sits in storage for the entire window, and the default window is 90 days, so the account pays for roughly 2,640 live copies of a file whose useful life ended at the deploy. The [artifact retention definition](/glossary/artifact-retention) and [how long artifacts are kept](/answers/how-long-are-github-actions-artifacts-kept) cover the settings hierarchy; the arithmetic is in the cost model below. ## [Fix](#fix)Copy Anchor Link Match the destination to how long the bytes have to stay reachable, then set the retention window to your release cadence. | Payload | Who consumes it | Where it belongs | Retention setting | | ------------------------------------------------- | ------------------------------- | --------------------------------------------- | -------------------------------- | | Compiled bundle, test fixtures, packaged binaries | Later jobs in the same run | Artifact, uploaded once | Days, matched to rollback window | | Test reports, coverage files, failure logs | A human reading the run summary | Artifact | 7 to 14 days | | Container image | Deploy jobs and clusters | Container registry, pushed from the build job | Registry lifecycle policy | | Signed installers and release binaries | End users, auditors | Release asset or registry | Outlives any artifact window | | Runner-local state such as warm daemons | The next run, not this one | Cache or snapshot runner | Cache key expiry | Then set `retention-days` on every upload. The default of 90 days is a value nobody chose. | Release cadence | retention-days | What the window has to cover | | ------------------------------- | --------------- | ---------------------------------------------------- | | Deploy on every merge to main | 3 | Rollback to any of the last few merges | | Weekly release train | 14 | The shipped release plus a week of hotfix candidates | | Monthly release | 45 | Two releases in flight at once | | Regulated hold on signed builds | Not an artifact | Push to a registry or attach as a release asset | Two more rules keep the pattern from breaking under load. Give each matrix leg its own artifact name and reassemble with `pattern` plus `merge-multiple` on the consumer, because names are unique per run. And when the payload is a container image, push it to a registry from the build job instead of moving a tarball through an artifact; [remote Docker builders](/docs/ci/docker-builders) push layers straight from the builder, so the image never becomes a file the workflow has to carry. Payloads above a few gigabytes have their own failure modes, covered in [large artifact uploads](/guides/large-artifact-uploads-github-actions), and the deploy side of the pattern is in [build once, deploy many](/guides/build-once-deploy-many-github-actions). ## [Configuration](#configuration)Copy Anchor Link One producer, five consumers, nothing rebuilt: Copy Text ``` name: build-and-ship on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Build release bundle run: ./scripts/build.sh --out dist - name: Publish the bundle for downstream jobs uses: actions/upload-artifact@v4 with: name: bundle-${{ github.sha }} path: | dist/** !dist/**/*.map if-no-files-found: error retention-days: 3 compression-level: 1 e2e: needs: build runs-on: warp-ubuntu-latest-x64-8x strategy: matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v5 - uses: actions/download-artifact@v4 with: name: bundle-${{ github.sha }} path: dist - run: ./scripts/e2e.sh --shard ${{ matrix.shard }} - if: always() uses: actions/upload-artifact@v4 with: name: e2e-report-${{ matrix.shard }} path: reports/ retention-days: 7 deploy: needs: [build, e2e] runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/download-artifact@v4 with: name: bundle-${{ github.sha }} path: dist - run: ./scripts/deploy.sh dist ``` Four inputs on that upload step are doing real work. `if-no-files-found: error` turns an empty glob into a failed build job rather than a deploy job that downloads nothing and ships an empty directory, since the default is `warn`. `retention-days: 3` overrides the repository default and cannot exceed the limit set at the repository, organization, or enterprise level. `compression-level: 1` skips most of the zlib work on payloads that are already compressed, where the default of `6` spends producer runner minutes to remove very little; leave it at the default for text and unpacked binaries. The `!dist/**/*.map` exclusion keeps source maps out of a bundle that four shards each download. The report uploads use one name per shard because artifact names are unique within a run. A job that collects them downloads with `pattern: e2e-report-*` and `merge-multiple: true`. To read an artifact from a different workflow run, `download-artifact` takes `run-id` and a `github-token` with `actions: read`, which is the mechanism a deploy workflow uses to fetch a bundle a build workflow produced earlier. Runner sizing differs on each end. The producer compiles and compresses, so it gets `warp-ubuntu-latest-x64-4x`. The shards run tests, so they get `warp-ubuntu-latest-x64-8x`. The deploy job downloads and shells out, so `warp-ubuntu-latest-x64-2x` is enough. An artifact is the handoff that crosses all four, which is what a macOS build feeding a Linux packaging job depends on. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Transfer minutes bill on both ends at the runner rate. Assume a 1.2 GB bundle, 1.0 minute to compress and upload, 0.6 minutes to download and unpack, 40 runs per weekday across 22 weekdays, so 880 runs per month. GitHub-hosted rates come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13, at the same vCPU and RAM shape. | Step | Runner | WarpBuild per minute | GitHub-hosted per minute | Minutes per run | | -------------------------- | ----------------------------------------- | -------------------- | ------------------------ | --------------- | | Upload on the build job | warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) | $0.008 | $0.012 | 1.0 | | Download on 4 e2e shards | warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) | $0.016 | $0.022 | 2.4 | | Download on the deploy job | warp-ubuntu-latest-x64-2x (2 vCPU, 8 GB) | $0.004 | $0.006 | 0.6 | That is $0.0488 per run on WarpBuild against $0.0684 on the GitHub-hosted shapes, or $42.94 against $60.19 across 880 runs: a difference of $17.25 per month on artifact transfer alone, before any change to what you upload. Storage is the larger line, and it follows one formula: Copy Text ``` GB held at steady state = artifact size (GB) x uploads per day x retention days ``` At 1.2 GB, 40 uploads per weekday, and the 90-day default, the window covers about 66 weekdays, so 66 x 40 x 1.2 = 3,168 GB. Cutting `retention-days` to 3 leaves 3 weekdays live: 3 x 40 x 1.2 = 144 GB. | Storage option | Rate | 90-day default | retention-days: 3 | | ---------------------------------------------------------------- | ------------------ | ----------------- | ----------------- | | GitHub Actions artifact, past a 50 GB Enterprise Cloud allowance | $0.25 per GB-month | 3,168 GB, $779.50 | 144 GB, $23.50 | | Amazon ECR image tag, layers stored once | $0.10 per GB-month | 397 GB, $39.71 | 19 GB, $1.91 | The 50 GB figure is the [Actions artifact storage allowance on GitHub Enterprise Cloud](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13; Free, Pro, and Team carry smaller allowances. The registry row assumes a 1.05 GB base stored once plus a 0.15 GB application layer per tag, because a registry addresses layers by content while every artifact upload stores the full payload again. Rate from the [Amazon ECR pricing page](https://aws.amazon.com/ecr/pricing/), checked on 2026-08-13; an ECR lifecycle policy is the retention equivalent. Substitute your own layer split before trusting the second row. Two conclusions fall out. Setting `retention-days: 3` on this workload removes $756.00 per month from artifact storage, which is one line of YAML against the default nobody chose. And once a payload is a container image, the registry is cheaper to hold and skips the transfer minutes entirely. Measure your own numbers before substituting the assumptions. The [Reports page](/docs/ci/features/reports) gives per-job duration at P75 and P90 with CSV export, so the upload and download step timings above become your timings. For the wider set of levers on the same workflow, start at [speeding up GitHub Actions](/guides/speed-up-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [How do I publish a build artifact so later GitHub Actions jobs can use it?](#how-do-i-publish-a-build-artifact-so-later-github-actions-jobs-can-use-it)Copy Anchor Link Run `actions/upload-artifact@v4` on the job that produced the files with a `name`, a `path`, and `if-no-files-found: error`, then run `actions/download-artifact@v4` with the same `name` on every consumer job. Each consumer needs a `needs` edge on the producer. The artifact is available for download as soon as the upload step finishes in v4, so a consumer can start while other producer steps are still running. ### [What retention-days value should I set on a build artifact?](#what-retention-days-value-should-i-set-on-a-build-artifact)Copy Anchor Link Set it to the window you would actually roll back or re-download inside. GitHub retains artifacts for 90 days by default, and the setting accepts 1 to 90 days on public repositories and 1 to 400 days on private repositories. Artifact storage bills as shared storage at $0.25 per GB-month beyond the plan allowance, so retention is the single lever with the largest effect on the bill. ### [Why does upload-artifact fail with a name conflict in a matrix job?](#why-does-upload-artifact-fail-with-a-name-conflict-in-a-matrix-job)Copy Anchor Link Artifact names are unique within a run in v4, so four shards uploading the name `bundle` collide after the first one. Give each matrix leg its own name, such as `bundle-${{ matrix.shard }}`, and have the consumer download with `pattern: bundle-*` and `merge-multiple: true`. Use `overwrite: true` only when a re-run of the same job is meant to replace the earlier upload. --- # Sharing a Build Cache Across Repositories | WarpBuild URL: https://www.warpbuild.com/guides/build-cache-sharing-across-repositories Description: A GitHub Actions cache entry is addressed inside one repository, so reuse across repositories runs through a registry. Patterns, permissions, and rates. # Sharing a Build Cache Across Repositories A GitHub Actions cache entry is addressed inside one repository, so reuse across repositories runs through a registry. Patterns, permissions, and rates. A GitHub Actions cache entry cannot be restored from a second repository, because the entry is addressed inside the repository that wrote it and the token a run uses to read it carries that same repository scope. Reuse across a repository boundary runs through a registry that both sides authenticate to, or through a builder that holds the layers in one place and is addressed by name from either side. This guide states what cache scoping allows before any workaround, gives the registry-backed pattern for artifacts that genuinely need to cross the boundary, and works through the permission question that decides the design: which repositories may read what another repository built. ## [Diagnosis](#diagnosis)Copy Anchor Link Start with the address, because the failure is silent and reads as an ordinary cache miss in the log. A cache entry is addressed by three things. The `key` string you write in the workflow, the version hash computed over the compression tool and the list of cached paths, and the branch that saved it. The [WarpBuild caching documentation](/docs/ci/features/caching) states the same three-part scope for `WarpBuilds/cache@v1`, and GitHub's [dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching) documents the branch half as an access restriction: a run restores entries written on its own branch or on the default branch, and sibling branches stay isolated from each other. The [cache scope glossary entry](/glossary/cache-scope) unpacks the term, and the [answer on branches](/answers/do-caches-carry-across-branches-in-github-actions) covers the branch rule on its own. The repository is above all three. It is the namespace the address lives in rather than a field inside the address, which is why writing an identical key in two repositories produces two unrelated entries instead of one shared entry. There is no permission error to find, because the second repository never asks for the first repository's entry at all. Here is the full grid of what a cache reaches. | Boundary | Shared by default | Why | | ------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Two workflows, one repository, same branch | Yes | The workflow file name is absent from the address, covered in the [answer on sharing between workflows](/answers/can-i-share-a-cache-between-github-actions-workflows) | | Two jobs in one workflow | Yes, after the writer's save step finishes | The reader needs the writer in its needs list | | Feature branch reading the default branch | Yes | Branch access restrictions allow the parent direction only | | Two sibling feature branches | No | Access restrictions isolate them | | Two repositories in one organization | No | The entry lives in the writing repository's namespace | | Two repositories in different organizations | No | Same reason, plus no shared token | | A fork writing to the upstream cache | No | A fork run cannot write into the base repository | | Linux entry read by a macOS or Windows job | No | The version hash differs across platforms | Two consequences follow. A monorepo turns most of this table into the first row, which is one of the reasons teams consolidate. And a reusable workflow does not change anything here, because it executes in the caller's repository, so the caller's namespace is the one that gets read and written. ## [Fix](#fix)Copy Anchor Link Decide what actually has to cross the boundary before picking a mechanism, because the three candidates carry different content and different permission models. **Publish a package or an image, and consume it by digest.** This is the registry-backed pattern, and it is the right default for anything a second repository consumes as an input rather than rebuilds: a base image, a compiled shared library, a generated client, a toolchain bundle. One repository builds the artifact, tags it immutably, and pushes it to a registry. Every consumer pulls it by digest. The boundary becomes a published version instead of a cache key, which means the consumer gets reproducibility and an audit trail that a cache entry never offered. **Export a registry-backed layer cache when you stay on runner-local buildx.** BuildKit can write its layer cache to a separate registry ref with `type=registry`, documented in the [Docker registry cache backend reference](https://docs.docker.com/build/cache/backends/registry/), and any repository with read access to that ref can import it. The cost is that every layer is serialized, pushed, and pulled again on each build. **Point both repositories at one remote Docker builder profile.** A builder profile is an account-level resource addressed by `profile-name`, and it works from WarpBuild runners and from non-WarpBuild runners through an API key, so workflows in different repositories can target the same profile. The layers stay on the builder's own disk, so there is no export and no import step. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface. | Mechanism | What crosses | Who can read it | Immutable | Best fit | | -------------------------- | ---------------------------- | ------------------------------------------------- | -------------------------- | -------------------------------------------- | | Package or image by digest | A published artifact | Whoever has registry read access | Yes, by digest | Shared inputs consumed rather than rebuilt | | Registry layer cache ref | Serialized BuildKit layers | Whoever has read access to the cache ref | No, the ref is overwritten | Runner-local buildx with a heavy build stage | | Shared builder profile | Nothing, the layers stay put | Any workflow holding the profile name and API key | No | Many repositories building similar images | | Cache action | Nothing across repositories | Runs in the writing repository only | No | Dependency trees inside one repository | ### [The permission question that decides it](#the-permission-question-that-decides-it)Copy Anchor Link Every cross-repository pattern answers one question, and the answer belongs in the design rather than in a follow-up. Which repositories may read what another repository built? A cache action never had to answer it, because the repository boundary answered it by default. A registry makes the answer explicit and therefore your responsibility. Grant read access per repository rather than to the whole organization, keep the publishing repository as the only writer, and give consumers `packages: read` with no write scope. Consuming by digest closes the remaining gap, since a mutable tag lets whoever holds write access change what a consumer pulls tomorrow. WarpBuild's side of the boundary is documented in the [security documentation](/docs/ci/security): each runner gets its own ephemeral virtual machine that is destroyed after the build, and cache storage is encrypted and reachable only by your runner. Compliance evidence for that surface is SOC 2 Type 2, with the report available through [trust.warpbuild.com](https://trust.warpbuild.com/). ## [Configuration](#configuration)Copy Anchor Link The publishing repository builds once and pushes an immutable tag. Copy Text ``` name: publish-base-image on: push: branches: [main] permissions: contents: read packages: write jobs: publish: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - name: Log in to the container registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push the base image uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/base-toolchain:${{ github.sha }} profile-name: platform-base ``` Each consuming repository pins the digest and asks for read scope only. Copy Text ``` name: build on: [push] permissions: contents: read packages: read jobs: test: runs-on: warp-ubuntu-latest-x64-4x container: image: ghcr.io/acme/base-toolchain@sha256:2f9c... credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} env: WARPBUILD_RUNNER_VERIFICATION_TOKEN: ${{ env.WARPBUILD_RUNNER_VERIFICATION_TOKEN }} steps: - uses: actions/checkout@v5 - run: make test ``` The `WARPBUILD_RUNNER_VERIFICATION_TOKEN` line matters only when steps inside the container also use the cache action. That variable is always present on WarpBuild runners and has to be passed into the container explicitly, along with `wget` and `zstd` being available in the image. To share a builder instead of an artifact, give every repository the same `profile-name`. The API key is required only when the job runs on a runner that is not a WarpBuild runner. Copy Text ``` - name: Build with the shared builder profile uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/service-a:${{ github.sha }} profile-name: platform-base api-key: ${{ secrets.WARPBUILD_API_KEY }} ``` Two properties of a shared profile are worth writing down before a rollout. The layer cache on a profile is shared but eventually consistent, so layers written by one concurrent build may be invisible to another build running at the same moment and visible to later builds after synchronization. And the builder cache has a TTL of 10 days, so a profile left unused for longer is reset and the next build on it runs cold. Keep the cache action for what it is good at, which is dependency trees inside one repository. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` with the same `key`, `path`, and `restore-keys` inputs. The cache is enabled by default on Linux runners and is not supported on WarpBuild Windows runners. Entries expire 7 days after last use. The [guide to persistent caches](/guides/persistent-caches-github-actions) covers the intra-repository choices in full, and the [remote build cache glossary entry](/glossary/remote-build-cache) covers the tool-level caches that Bazel and Gradle address by input hash rather than by branch. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Rates first, from the [pricing page](/pricing). | Metric | Hosted | BYOC | | ---------------------------------------------- | --------------------- | ----------------- | | Cache storage | $0.20 per GB-month | Free | | Cache write, restore, or list | $0.0001 per operation | Free | | Docker builder, 16 vCPU and 32 GB, 100 GB disk | $0.06 per minute | n/a | | Docker builder, 32 vCPU and 64 GB, 200 GB disk | $0.12 per minute | n/a | | warp-ubuntu-latest-x64-4x, 4 vCPU and 16 GB | $0.008 per minute | $0.002 per minute | | warp-ubuntu-latest-x64-8x, 8 vCPU and 32 GB | $0.016 per minute | $0.002 per minute | For the baseline on the runner line, `warp-ubuntu-latest-x64-4x` costs $0.008 per minute against $0.012 per minute for the 4-core GitHub-hosted Linux larger runner at the same 4 vCPU and 16 GB, which is 33 percent lower list price ([GitHub billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). ### [Worked model](#worked-model)Copy Anchor Link Assumptions, stated so you can substitute your own step timings: * Six application repositories, 120 workflow runs each per month, so 720 runs. * Every job in the model runs on `warp-ubuntu-latest-x64-8x` at $0.016 per minute. * A shared base toolchain that takes 6.0 minutes to build. * The base changes 30 times per month. * Pulling the published image adds 0.5 minutes to a consumer run. * Each repository holding its own 4 GB cache entry for the same base. | Line | Rebuild in every repository | Publish once, consume by digest | | ---------------------------------- | --------------------------- | ------------------------------- | | Base build minutes | 720 runs x 6.0 = 4,320 | 30 builds x 6.0 = 180 | | Base build cost | $69.12 | $2.88 | | Consumer pull minutes | 0 | 720 x 0.5 = 360 | | Consumer pull cost | $0.00 | $5.76 | | Cache storage, 4 GB per repository | 24 GB at $0.20 = $4.80 | 4 GB at $0.20 = $0.80 | | Monthly total | $73.92 | $9.44 | The money line moves by $64.48 per month. The wall clock line moves further: 5.5 minutes leave the front of each of 720 consumer runs, which is 3,960 minutes, or 66 hours per month that no developer spends watching a check. Two sensitivities decide whether the pattern is worth the plumbing. Base change frequency sets the publish cost, so a base rebuilt on every commit collapses the gap. Consumer run count sets the saving, so the pattern earns more as repositories are added, since the publish side is fixed. ## [FAQ](#faq)Copy Anchor Link ### [Can two repositories share a GitHub Actions cache?](#can-two-repositories-share-a-github-actions-cache)Copy Anchor Link No. A cache entry is addressed by key, version, and branch inside the repository that wrote it, and the run token that authenticates a restore is scoped to the repository running the workflow. A second repository asking for the same key computes a valid address in its own namespace, misses, and logs a normal cache miss with no permission error. Reuse across repositories has to travel through a registry or a shared builder. ### [What is the safest way to reuse a build output in another repository?](#what-is-the-safest-way-to-reuse-a-build-output-in-another-repository)Copy Anchor Link Publish it once as a versioned package or an OCI image, consume it by digest, and grant read access per repository rather than to the whole organization. The publishing repository stays the only writer, consumers pin an immutable digest, and the permission model answers the question of which repositories may read what another repository built. ### [What does the cross-repository pattern cost on WarpBuild?](#what-does-the-cross-repository-pattern-cost-on-warpbuild)Copy Anchor Link Cache storage is $0.20 per GB-month and each cache write, restore, or list is $0.0001 per operation on hosted runners, and both are free on BYOC. A remote Docker builder profile at 16 vCPU and 32 GB is $0.06 per minute and keeps its layer cache on the builder, so repositories that target the same profile name reuse those layers with no export step. --- # Build Once, Deploy Many with GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/build-once-deploy-many-github-actions Description: One build job publishes the image and emits its digest, and the test and deploy jobs pull that digest. The YAML, the wiring table, and the cost math. # Build Once, Deploy Many with GitHub Actions One build job publishes the image and emits its digest, and the test and deploy jobs pull that digest. The YAML, the wiring table, and the cost math. Last verified: 2026-08-13 Build once, deploy many on GitHub Actions means one build job publishes a single artifact and emits its digest as a job output, and every job after it reads that digest through the `needs` context instead of running the build again. The pipeline then pays for one build per commit rather than one per environment, and staging and production run the exact bytes the test job exercised. This guide covers the shapes that quietly rebuild per environment, the digest handoff that removes them, a workflow with a build job, a test job, and two deploy jobs all pinned to one digest, and a per-release cost and wall clock model with rates applied. It sits under [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link A pipeline drawn as build once often rebuilds at the YAML level. Three shapes account for most of it, and each one puts the cost somewhere different. | Shape | What you see in the workflow | What it costs | | -------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | | The deploy job builds | A docker build or build-push-action step inside deploy-staging and deploy-production | Build minutes billed once per environment, and each environment ships bytes nothing tested | | The deploy references a moving tag | ghcr.io/acme/app:main in a manifest, a Helm value, or a deploy script | The tag can resolve to a newer manifest between the staging apply and the production apply | | Build arguments differ per environment | \--build-arg API\_URL=... supplied separately in each deploy job | One image per environment by construction, so there is nothing to promote | ### [Confirm it from the registry](#confirm-it-from-the-registry)Copy Anchor Link Ask the registry what each environment tag currently points at: Copy Text ``` docker buildx imagetools inspect ghcr.io/acme/app:staging --format '{{.Manifest.Digest}}' docker buildx imagetools inspect ghcr.io/acme/app:production --format '{{.Manifest.Digest}}' ``` Two different `sha256:` values for one release means two builds happened. The [image digest](/glossary/image-digest) is computed over the manifest, so any difference in a layer, in the config, or in the platform set produces a different value. ### [Confirm it from the bill](#confirm-it-from-the-bill)Copy Anchor Link The [Reports page](/docs/ci/features/reports) settles the same question from the billing side. The Docker Builder billing table lists one row per session with profile, architecture, duration, and cost, so a single merge to main that produced three sessions tells you the pipeline built three times. The CI billing table lists per-job billed time by job name, which is where a `deploy-production` job carrying a hidden build step shows a duration close to the build job's rather than close to an apply. A rebuild costs more than the minutes. A base image tag that moved overnight, a package index that resolved a new patch release, and a timestamp baked into a layer all change the output from identical source, so the second build produces a different image and the test result from the first build no longer describes it. ## [Fix](#fix)Copy Anchor Link Publish the image once, then pass the digest forward. `Warpbuilds/build-push-action@v6` is a drop-in replacement for `docker/build-push-action` that runs the build on a WarpBuild builder profile ([remote Docker builders documentation](/docs/ci/docker-builders)). It sets a `digest` output when the build pushes, and that output is the handoff. Promote it to a job output, and every downstream job addresses the image by content. Copy Text ``` name: release on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-8x outputs: digest: ${{ steps.build.outputs.digest }} steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: build uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/app:sha-${{ github.sha }} profile-name: platform-amd64 timeout: 600000 test: needs: [build] runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Integration suite against the published image run: | docker run --rm -d --name app -p 8080:8080 \ "ghcr.io/acme/app@${{ needs.build.outputs.digest }}" ./scripts/integration-test.sh http://localhost:8080 deploy-staging: needs: [build, test] runs-on: warp-ubuntu-latest-x64-2x environment: staging concurrency: group: deploy-staging cancel-in-progress: false steps: - name: Apply run: | kubectl set image deployment/app \ app="ghcr.io/acme/app@${{ needs.build.outputs.digest }}" kubectl rollout status deployment/app --timeout=5m deploy-production: needs: [build, deploy-staging] runs-on: warp-ubuntu-latest-x64-2x environment: production concurrency: group: deploy-production cancel-in-progress: false steps: - name: Apply run: | kubectl set image deployment/app \ app="ghcr.io/acme/app@${{ needs.build.outputs.digest }}" kubectl rollout status deployment/app --timeout=5m ``` Four things in that file carry the pattern. The build job is the only job with a build step. Everything downstream reads `needs.build.outputs.digest` and pulls. `deploy-production` lists `build` in `needs` even though its real ordering dependency is `deploy-staging`. The `needs` context exposes outputs only for the jobs named in that list, so dropping `build` leaves the expression empty and the deploy pulls `ghcr.io/acme/app@`. The deploy jobs sit on `warp-ubuntu-latest-x64-2x`. A job that pulls a manifest and calls an apply spends its minutes on network and on waiting for a rollout, so cores buy nothing there. The build job takes the 8x label because that is where the compile happens. The rest of the deploy job shape, including environments, protection rules, and the OIDC credential exchange, is covered in [deployment jobs on GitHub Actions](/guides/deployment-jobs-github-actions). The tag `sha-${{ github.sha }}` exists for retention rather than for addressing. Registry cleanup policies commonly delete untagged manifests, and a manifest that only a deploy record references is untagged the moment the next build takes the branch tag. ## [Configuration](#configuration)Copy Anchor Link ### [The wiring, line by line](#the-wiring-line-by-line)Copy Anchor Link | Where | Line | Why it is required | | --------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Push step | id: build | steps.build.outputs.digest needs a step id to reference | | Build job | outputs.digest: ${{ steps.build.outputs.digest }} | Job outputs are the only channel between jobs | | Push step | push: true | The digest comes back from the registry push, so a build that stays local reports nothing | | Downstream job | needs: \[build, ...\] | The needs context covers listed jobs only, and a transitive edge does not expose outputs | | Every image reference | image@sha256:... | Content addressing, so the pull either serves those bytes or fails | | Push step | A tag alongside the digest | Keeps the manifest tagged so untagged-manifest cleanup leaves it in place | ### [Guard against an empty digest](#guard-against-an-empty-digest)Copy Anchor Link An empty output produces a reference ending in `@`, and the resulting registry error names a parse problem rather than the missing handoff. One step at the top of each deploy job makes the failure legible: Copy Text ``` - name: Require a digest from the build job env: DIGEST: ${{ needs.build.outputs.digest }} run: | case "$DIGEST" in sha256:*) echo "deploying $DIGEST" ;; *) echo "build job produced no digest: '$DIGEST'" >&2; exit 1 ;; esac ``` ### [Multi-platform and multi-target deploys](#multi-platform-and-multi-target-deploys)Copy Anchor Link A build that publishes several architectures produces an image index, and the `digest` output is the index digest. A client pulling it selects the manifest matching its own architecture, so one output serves an amd64 cluster and an arm64 cluster from the same string. The jobs consuming that digest can run wherever the deploy tooling for each target lives. ### [What a digest cannot absorb](#what-a-digest-cannot-absorb)Copy Anchor Link Configuration baked at build time. If the image compiles an API hostname into a bundle, the staging image and the production image are different artifacts and no handoff makes them one. Move those values to environment variables or a mounted config read at container start, then the build arguments hold only what is identical everywhere. The build itself can also move off the job runner entirely. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger, so the build job can shrink to a checkout while a builder profile executes the build and keeps the layer cache between runs. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Everything below is arithmetic on stated assumptions rather than a measurement. Substitute the durations printed in your own job logs before deciding anything on it. Assumptions: * 60 merges to main per month, each promoted through staging and then production. * A cold image build occupies a runner for 6.0 minutes. * The integration suite runs 4.0 minutes. * A deploy job that pulls by digest and applies takes 2.0 minutes. * Rates from the [pricing page](/pricing), checked on 2026-08-13: `warp-ubuntu-latest-x64-8x` at $0.016 per minute, `warp-ubuntu-latest-x64-4x` at $0.008, `warp-ubuntu-latest-x64-2x` at $0.004. * Durations are held constant across both options, so the only variable is how many times the build runs. Rebuilding in each deploy job: | Job | Runner | Minutes | Rate | Cost | | ----------------------------------- | ------------------------- | ------- | ------ | ------ | | build | warp-ubuntu-latest-x64-8x | 6.0 | $0.016 | $0.096 | | test | warp-ubuntu-latest-x64-4x | 4.0 | $0.008 | $0.032 | | deploy-staging, build plus apply | warp-ubuntu-latest-x64-8x | 8.0 | $0.016 | $0.128 | | deploy-production, build plus apply | warp-ubuntu-latest-x64-8x | 8.0 | $0.016 | $0.128 | | Total per release | 26.0 | $0.384 | | | Building once and deploying the digest: | Job | Runner | Minutes | Rate | Cost | | ----------------- | ------------------------- | ------- | ------ | ------ | | build | warp-ubuntu-latest-x64-8x | 6.0 | $0.016 | $0.096 | | test | warp-ubuntu-latest-x64-4x | 4.0 | $0.008 | $0.032 | | deploy-staging | warp-ubuntu-latest-x64-2x | 2.0 | $0.004 | $0.008 | | deploy-production | warp-ubuntu-latest-x64-2x | 2.0 | $0.004 | $0.008 | | Total per release | 14.0 | $0.144 | | | Per release that is $0.24 and 12.0 minutes of runner time removed, which comes to $14.40 and 720 runner minutes across 60 releases. The dollars stay small for one service, and the wall clock is the number to argue from: the four jobs run in sequence, so those 12.0 minutes come off the gap between a merge and a production rollout every single time, roughly 12 hours per month of pipeline latency for one service. A fleet of ten services multiplies both columns. For a baseline on the build job, `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13 against the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Running the build on a remote Docker builder changes which line grows. The build job drops to a small runner that checks out and waits while a builder profile does the work, and the profile bills separately at $0.06 per minute for the 16 vCPU, 32 GB size ([remote Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13). Build-once matters more there: a rebuild inside a deploy job opens another builder session at that rate rather than reusing what the build job already paid for. A pipeline that ships binaries rather than images follows the same rule with artifacts standing in for digests, which is the subject of [release build workflows on GitHub Actions](/guides/release-builds-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [How do I pass an image digest from the build job to the deploy jobs?](#how-do-i-pass-an-image-digest-from-the-build-job-to-the-deploy-jobs)Copy Anchor Link Give the push step an id, promote its digest output to a job output with `outputs.digest` set to `steps.build.outputs.digest`, and read `needs.build.outputs.digest` in every downstream job. Job outputs are the only channel between jobs on GitHub Actions, and the value arrives as a plain string such as `sha256:` followed by 64 hexadecimal characters. ### [Why is the digest empty in my production deploy job?](#why-is-the-digest-empty-in-my-production-deploy-job)Copy Anchor Link Two causes account for most of it. The `needs` list of the deploy job has to name the build job directly, because the `needs` context exposes only the jobs listed there and a transitive dependency through the staging job gives you nothing. The other cause is a build step that never pushed, since the digest output comes back from the registry push and a build with `push: false` has no manifest to report. ### [What if each environment needs different build arguments?](#what-if-each-environment-needs-different-build-arguments)Copy Anchor Link Then the pipeline builds one image per environment by construction, and the digest handoff cannot help until the configuration moves out of the image. Read values such as API hostnames from environment variables or a mounted config at container start, keep the build arguments limited to things that are identical everywhere, and the same digest becomes promotable across every target. --- # Choosing a Buildx Cache Backend on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/buildx-cache-backends-github-actions Description: Inline cache, registry cache, and a remote builder profile fail in different ways on GitHub Actions. How to pick a buildx cache backend, with rates. # Choosing a Buildx Cache Backend on GitHub Actions Inline cache, registry cache, and a remote builder profile fail in different ways on GitHub Actions. How to pick a buildx cache backend, with rates. Last verified: 2026-08-13 A buildx cache backend decides where Docker layers live between GitHub Actions runs, and three shapes cover almost every workflow: inline cache written into the pushed image, a registry cache written to a separate cache ref, and a remote builder that keeps layers on its own disk. The first two serialize layers and move them across the network on every build, while a builder profile reuses the layers in place because they never left the machine that wrote them. This guide covers what each backend actually stores, the failure mode each one produces in a job log, the configuration to move from a registry cache to a builder profile, and the arithmetic that tells you which one is cheaper at your build volume. ## [Diagnosis](#diagnosis)Copy Anchor Link BuildKit holds its layer cache in the local state of the builder that ran the build. A runner-local builder is created when the job starts and destroyed when the runner terminates, so that state dies with the machine unless a backend copies it somewhere that outlives the job. Each backend picks a different somewhere, and each one has a signature in the log. ### [Inline cache](#inline-cache)Copy Anchor Link Inline cache embeds cache metadata in the image manifest that you push, so the image doubles as the cache. Copy Text ``` cache-from: type=registry,ref=ghcr.io/acme/api:latest cache-to: type=inline ``` Two failure modes. First, inline records metadata only for the layers that end up in the final image, so the intermediate stages of a multi-stage build are invisible to the next run. A Dockerfile that compiles in a builder stage and copies one binary into a slim runtime gets cache hits on the runtime layers while the compile stage runs from scratch every time. Second, cache reads point at a floating tag such as `:latest`, which is whatever the default branch pushed most recently, so a pull request that touched an early layer misses everything downstream of it. The log signature is a run where the last few steps say `CACHED` and the dependency install or compile step runs full length. ### [Registry cache](#registry-cache)Copy Anchor Link Registry cache writes a separate cache manifest to a registry ref, and `mode=max` records every stage rather than the final image. Copy Text ``` cache-from: type=registry,ref=ghcr.io/acme/api:buildcache cache-to: type=registry,ref=ghcr.io/acme/api:buildcache,mode=max ``` This fixes the multi-stage gap and introduces four operational problems. 1. The export runs at the end of the build. A build that fails or is cancelled writes nothing, so the next run is still cold. A flaky test step placed before the push can keep a cache permanently empty. 2. The import is a download that has to finish before the first cache hit can be evaluated, and `mode=max` makes it larger. Both the import and the export are runner minutes you pay for on every build. 3. Concurrent builds that write to the same ref race each other. A matrix of five variants exporting to one ref ends with a cache describing whichever build finished last. 4. Nothing expires by default. The cache ref accumulates blobs until somebody garbage collects them, and the registry bills that storage plus the pull bandwidth on every import. The GitHub Actions cache backend, `type=gha`, is the same shape with a different store. Scopes are keyed by branch, so a pull request reads its own empty scope before falling back, and the store has its own size and eviction limits. ### [Remote builder with a persistent local layer cache](#remote-builder-with-a-persistent-local-layer-cache)Copy Anchor Link A builder profile corresponds to one dedicated builder VM with caching attached to it. The build runs on that VM, and the layers stay on its local disk between jobs, so there is no export step and no import step anywhere in the workflow. The costs move rather than disappear. The builder is billed per session, separately from the runner. The shared profile cache is eventually consistent across concurrent builds. A profile that goes unused for more than 10 days is reset. And if a later step needs the built image on the runner filesystem, that image has to travel back over the network before the step can start. | Backend | Where layers live | Per-build overhead | Main failure mode | | ---------------------------- | ------------------------------------ | ---------------------------------------- | ------------------------------------------------------------ | | Inline cache | Inside the pushed image manifest | Slightly larger push | Final-stage layers only, multi-stage builds miss | | Registry cache, mode=max | A separate cache ref in a registry | Import before the build, export after it | Cancelled builds write nothing, concurrent writers race | | GitHub Actions cache backend | The workflow cache store | Import before the build, export after it | Branch-scoped reads, store size and eviction limits | | Remote builder profile | Local disk of a dedicated builder VM | A builder session billed per minute | Eventually consistent across concurrent builds, 10-day reset | ## [Fix](#fix)Copy Anchor Link Pick the backend from the shape of your job log rather than from the Dockerfile. **Keep inline cache** when the image is a single stage, the push is short, and the build step is already a small share of wall clock. It needs no extra registry repository and no cleanup job. **Keep a registry cache** when you want the build to stay on the runner and the Dockerfile has a heavy build stage. Use `mode=max`, a dedicated cache ref that is separate from your image tags, and a per-branch ref with a fallback to the default branch ref so pull requests read a warm cache. **Move to a builder profile** when any of three things is true: the import and export lines are a top-three cost in the job, several jobs build the same image and each one pays its own import, or you build for `linux/amd64` and `linux/arm64` and the emulated leg dominates. The move is a drop-in replacement for the action. `Warpbuilds/build-push-action@v6` sets up the remote builder for you, and the cache flags come out. Copy Text ``` - - name: Set up Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push - uses: docker/build-push-action@v6 + - name: Build and push + uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} - cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache - cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max + profile-name: "api-images" ``` With Docker Builders the `cache-to` and `cache-from` options are not required, because a cached builder keeps the layers on its own disk and reuses them for later builds. `Warpbuilds/bake-action@v6` is the equivalent replacement for bake files and takes the same `profile-name` input. The [remote Docker builders setup walkthrough](/guides/remote-docker-builders) covers profile creation and authentication end to end, and the [WarpBuild Docker Builders documentation](/docs/ci/docker-builders) is the reference for every action input. ## [Configuration](#configuration)Copy Anchor Link ### [Registry cache, tuned](#registry-cache-tuned)Copy Anchor Link If you stay runner-local, this is the version worth running: a per-branch cache ref with a fallback to the default branch ref, so pull requests start warm. Copy Text ``` name: build-image on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} cache-from: | type=registry,ref=ghcr.io/${{ github.repository }}:cache-${{ github.ref_name }} type=registry,ref=ghcr.io/${{ github.repository }}:cache-main cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache-${{ github.ref_name }},mode=max ``` ### [Builder profile](#builder-profile)Copy Anchor Link The same workflow on a builder profile drops the buildx setup step and both cache flags. The runner label shrinks too, because the build no longer needs the runner's cores. Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} profile-name: "api-images" ``` For a bake file, swap the action and keep the targets. Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Bake uses: Warpbuilds/bake-action@v6 with: push: true set: | *.tags=ghcr.io/${{ github.repository }}:${{ github.sha }} profile-name: "api-images" ``` On a runner that WarpBuild does not operate, add an API key so the action can request a builder assignment, plus an explicit readiness timeout in milliseconds. Copy Text ``` with: profile-name: "api-images" api-key: ${{ secrets.WARPBUILD_API_KEY }} timeout: 600000 ``` ### [The operational limits that decide the choice](#the-operational-limits-that-decide-the-choice)Copy Anchor Link Four limits do most of the deciding, and all four are properties of the profile rather than of the workflow. **The cache has a 10 day TTL.** A builder profile that goes unused for more than 10 days is reset automatically. A weekly release pipeline stays warm. A monthly one rebuilds cold every time, which is an argument for a registry cache on that workflow. **Concurrent builds share an eventually consistent cache.** Multiple jobs can run on the same profile in parallel on the same VM. Layers written by one of those builds may not be visible to another build running at the same time, and become visible to later builds after synchronization. A matrix that expects build N to reuse a layer produced by build N-1 in the same wave should not rely on it. **The builder is billed per session, separately from the runner.** A session runs from the moment the builder action starts until the job completes, and concurrent jobs on the same profile share one session billed from the first job's start to the last job's completion. The runner and the builder are two separate, independent resources and both are charged. Generally available Linux and Windows runners do not have plan-level concurrency caps; size the builder profile for the fan-out it will serve. **Architecture caps the profile size.** arm64 and multi-arch profiles support a maximum size of 64 vCPU, and the 96 vCPU and 192 vCPU sizes are amd64 only. A multi-arch build runs one session per architecture on the same profile, and those sessions are billed independently. When a bad layer set gets pinned on a profile, reset it through the API instead of deleting and recreating the profile. Copy Text ``` curl -s -X POST \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ https://api.warpbuild.com/api/v1/builder-profiles/$BUILDER_PROFILE_ID/cache/reset ``` Three platform notes. Docker builds are driven from the Linux labels in that catalog. The [Docker builder profile sizes and per-minute rates](/runners/docker-builders) page lists every profile size, and [sharing a Docker layer cache between GitHub Actions jobs](/guides/share-docker-cache-between-jobs) covers the fan-out case where one profile serves many jobs. The Docker layer cache described in this guide is separate from WarpBuild's own dependency and build cache, which the [caching documentation](/docs/ci/features/caching) covers on its own terms. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Rates](#rates)Copy Anchor Link Builder profile rates, taken from the WarpBuild Docker Builder price list. | Profile size | Price per minute | Architectures | | ------------------------------- | ---------------- | ------------------- | | 16 vCPU, 32GB RAM, 100GB disk | $0.06 | amd64, arm64, multi | | 32 vCPU, 64GB RAM, 200GB disk | $0.12 | amd64, arm64, multi | | 64 vCPU, 128GB RAM, 200GB disk | $0.24 | amd64, arm64, multi | | 96 vCPU, 192GB RAM, 600GB disk | $0.36 | amd64 only | | 96 vCPU, 192GB RAM, 2TB disk | $0.52 | amd64 only | | 192 vCPU, 384GB RAM, 600GB disk | $0.72 | amd64 only | | 192 vCPU, 384GB RAM, 2TB disk | $0.88 | amd64 only | Runner rates used below: `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) at $0.008 per minute, `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) at $0.016 per minute, and `warp-ubuntu-latest-x64-32x` (32 vCPU, 128 GB) at $0.064 per minute. ### [Model 1: one image, 500 builds per month](#model-1-one-image-500-builds-per-month)Copy Anchor Link Assumptions, which you should replace with the timings in your own job log. The image is a multi-stage Node service. Option A imports a 1:10 cache and exports it in 1:40\. Option B has no import and rebuilds the compile stage. Option C runs the build on a 16 vCPU profile with warm layers on disk. | Option | Runner | Job length | Builder session | Cost per build | 500 builds | | --------------------------- | ----------------------------------- | ---------- | --------------- | -------------- | ---------- | | A. Registry cache, mode=max | warp-ubuntu-latest-x64-8x at $0.016 | 9:00 | none | $0.144 | $72.00 | | B. Inline cache | warp-ubuntu-latest-x64-8x at $0.016 | 10:00 | none | $0.160 | $80.00 | | C. Builder profile, 16 vCPU | warp-ubuntu-latest-x64-4x at $0.008 | 5:30 | 3:00 at $0.06 | $0.224 | $112.00 | Read the last column before the middle ones. At this shape the builder profile costs $40.00 more per month than the registry cache and returns 3:30 of wall clock on every build, which is 1,750 minutes or about 29 hours of pipeline time across 500 builds. The registry cache option also carries a registry storage and bandwidth line on your registry bill that the table does not include. ### [Model 2: a build-bound job on a large runner](#model-2-a-build-bound-job-on-a-large-runner)Copy Anchor Link The picture inverts when the runner was sized for `docker build` rather than for the rest of the job. Take a 20 minute job on `warp-ubuntu-latest-x64-32x`, of which 8 minutes is the image build and the other 12 minutes leave 32 vCPU mostly idle. | Line item | Rate | Quantity | Cost | | ----------------------------------------- | ----------------- | ---------- | ------ | | Before: runner warp-ubuntu-latest-x64-32x | $0.064 per minute | 20 minutes | $1.280 | | After: runner warp-ubuntu-latest-x64-4x | $0.008 per minute | 14 minutes | $0.112 | | After: builder session, 32 vCPU profile | $0.12 per minute | 5 minutes | $0.600 | | After: total per build | $0.712 | | | At 500 builds per month that is $356.00 against $640.00, a difference of $284.00, and the job also finishes sooner. The rule that falls out of both models: a builder profile pays for itself in dollars when it lets you shrink the runner label, and it pays for itself in time when the import and export lines are large. None of this needs a plan change. ## [FAQ](#faq)Copy Anchor Link ### [Which buildx cache backend should I use on GitHub Actions?](#which-buildx-cache-backend-should-i-use-on-github-actions)Copy Anchor Link Use inline cache for a single-stage image where the push is cheap. Use a registry cache with `mode=max` when you stay on runner-local buildx and have a heavy build stage. Move to a remote builder profile when the cache import and export lines are a top-three cost in the job log, when many jobs build the same image, or when you build multi-arch. ### [Do I still need cache-from and cache-to with a remote builder?](#do-i-still-need-cache-from-and-cache-to-with-a-remote-builder)Copy Anchor Link No. With WarpBuild Docker Builders the `cache-to` and `cache-from` options are not required. The builder keeps the layers on its own disk and reuses them for later builds, so there is no export step and no import step in the job. ### [What happens to a builder profile cache that goes unused?](#what-happens-to-a-builder-profile-cache-that-goes-unused)Copy Anchor Link The builder cache has a TTL of 10 days. A profile that is not used for more than 10 days is reset automatically, and the next build on that profile runs cold. ### [Do concurrent builds on one profile share the layer cache?](#do-concurrent-builds-on-one-profile-share-the-layer-cache)Copy Anchor Link Yes, and the shared cache is eventually consistent. Layers written by one concurrent build may not be visible to another build running at the same time, and they become visible to later builds after synchronization. [Why Docker layer caches invalidate on GitHub Actions](/guides/docker-cache-invalidation-github-actions) covers the Dockerfile-side reasons a warm cache still misses. --- # Cloud Quotas for BYOC GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/guides/byoc-capacity-and-quotas Description: On BYOC, cloud quotas cap concurrent GitHub Actions jobs. Size EC2, EBS, elastic IP, and NAT quotas in your stack region against your job concurrency. # Cloud Quotas for BYOC GitHub Actions Runners On BYOC, cloud quotas cap concurrent GitHub Actions jobs. Size EC2, EBS, elastic IP, and NAT quotas in your stack region against your job concurrency. Last verified: 2026-08-13 Running GitHub Actions runners inside your own AWS account puts the concurrency ceiling in AWS Service Quotas: every concurrent job consumes vCPU against the EC2 running-instances quota, storage against the EBS volume quota, and one elastic IP, all in the single region where the stack was created. Size those quotas against your target concurrent job count before production workflows point at BYOC labels, then repeat the same exercise on GCP or Azure, where the identical shape appears under different quota names. This guide covers the failure signature of a quota ceiling, the documented quota list per stack on each cloud, a worksheet that converts a concurrency target into the increases to request, and what a low ceiling costs in wall clock time and in dollars. ## [Diagnosis](#diagnosis)Copy Anchor Link A quota ceiling looks like a capacity problem and behaves like a hard wall. Jobs queue in GitHub, the runner never registers, and the workflow log shows nothing at all because the failure happened in the cloud API before any runner agent existed. The signature that separates a quota from every other queue cause is the shape of the plateau. Running job count climbs, stops at the same number on every large fan-out, and stays there until finished jobs release capacity. The number is stable across days because a quota is a fixed integer, and it moves only when someone raises it. Three properties of cloud quotas produce most of the surprise: * Quotas are regional. A stack in `us-east-1` draws on `us-east-1` quotas, and an increase applied in another region does nothing. The WarpBuild [AWS BYOC quota reference](/docs/ci/byoc/aws) states this directly: apply the increases in the region where the stack is created. * Quotas are per account or per project, and they are shared. A data pipeline that launches instances in the same account and region eats the same vCPU headroom your runners need. * Quotas count several resources at once. A fan-out can clear the vCPU quota and still fail on elastic IPs or on disk storage, because each concurrent job consumes one of each. Match the symptom to the exhausted resource before requesting anything: | Symptom | Exhausted resource | | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | | Running job count plateaus at the same number on every fan-out | EC2 vCPU, GCP CPUs, or Azure vCPU in the stack region | | Plateau moves when you switch the runner to a smaller size | vCPU quota, since the quota counts vCPU rather than instances | | Jobs start, then a later wave fails to launch after a spike in disk-heavy runs | EBS volume storage or persistent disk quota | | Launches fail while vCPU headroom is clearly free | Elastic IPs, or free addresses in the subnet | | Only spot-backed runners stall while on-demand runners launch | The separate spot vCPU quota, or spot capacity in that instance type | | A single label never launches while other labels run fine | Runner configuration or instance type availability rather than a quota | AWS names the failing resource in the API error, and the codes are worth recognizing: `VcpuLimitExceeded` for on-demand vCPU, `MaxSpotInstanceCountExceeded` for spot vCPU, `AddressLimitExceeded` for elastic IPs, and `VolumeLimitExceeded` for EBS. GCP returns `QUOTA_EXCEEDED` with the quota name in the message, and Azure returns a quota error naming the VM family. Subnet address space is a ceiling that no quota page shows. WarpBuild recommends at least 250 usable IPs per subnet, and a subnet that runs out rejects launches the same way a quota does. Confirm the subnet mask before blaming Service Quotas. Measure the plateau instead of estimating it. Queue Timings and Jobs reports are part of the WarpBuild CI observability surface, alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger. A per-label queue wait that spikes only during large fan-outs, with duration steady, points at the ceiling. Queue wait that stays high on one label around the clock points at runner configuration instead. Rule WarpBuild out early. Run as many jobs as your workflows need, since generally available Linux and Windows runners do not have plan-level concurrency caps and capacity adjusts dynamically. On BYOC that means the control plane will keep asking your cloud for instances up to whatever your workflows demand, and the first hard stop it meets is your account quota. ## [Fix](#fix)Copy Anchor Link Raise the quotas to your target concurrency in the stack region, on the cloud that holds the stack. BYOC runs on AWS, GCP, and Azure, and the quota list per stack is documented for each. ### [AWS quotas per stack](#aws-quotas-per-stack)Copy Anchor Link Treat `CON` as the number of concurrently running jobs the stack must support. | Resource | Quota to request | Notes | | ------------- | ------------------ | ------------------------------------------------------------------------------ | | EC2 instances | CON x vCPU per job | Adjust for instance type and for the split between spot and on-demand | | EBS volumes | CON x disk per job | Adjust provisioned IOPS if the runner profile needs it | | Elastic IPs | 3 + CON | 3 for NAT gateways across 3 availability zones, 1 per concurrently running job | | S3 buckets | 1 | One bucket serves the artifact cache, container layer cache, and telemetry | | NAT gateways | 3 | One per availability zone | | VPCs | 1 | One VPC per stack | Every row is applied in the stack region. The full table, with direct links into the AWS Service Quotas console for each limit, is in the [AWS BYOC docs](/docs/ci/byoc/aws). Two caveats travel with it: quotas apply in the region where the stack is created, and the list is not exhaustive, so leave headroom rather than requesting the exact figure. ### [GCP quotas per stack](#gcp-quotas-per-stack)Copy Anchor Link | Resource | Quota to request | Notes | | --------------------------------------- | ------------------ | ----------------------------------------------------------------------- | | CPU | CON x vCPU per job | Adjust for machine type and for preemptible against on-demand | | Persistent disks | CON x disk per job | With Hyperdisk types, also raise provisioned IOPS and throughput quotas | | In-use regional external IPv4 addresses | 3 + CON | Static addresses for Cloud NAT, plus 1 per concurrently running job | | Cloud Storage | 1 | One bucket for artifact cache, container layer cache, and telemetry | | Cloud NAT | 3 | One per stack | | Networks | 1 | One VPC network | | Subnetworks | 2 | One public and one private subnetwork per stack | The Hyperdisk row is the one teams miss. Disk capacity can be approved while provisioned IOPS and throughput stay at the default, and jobs then launch onto disks that are slower than the runner profile expects. The [GCP BYOC docs](/docs/ci/byoc/gcp) link the Hyperdisk quota reference next to the disk row. ### [Azure quotas per stack](#azure-quotas-per-stack)Copy Anchor Link Azure mirrors the GCP shape: CPU at `CON` x vCPU per job, persistent disks at `CON` x disk per job, in-use regional external IPv4 addresses at 3 + `CON`, 1 object storage container, 3 NAT, 1 Vnet, and 2 subnetworks. BYOC on Azure is enabled for East US today, so plan capacity in that region and contact support for others. ### [Order of work](#order-of-work)Copy Anchor Link 1. Pick the concurrency target from real queue data rather than from the largest matrix in the repository. The largest matrix rarely runs alone, and other workflows share the account. 2. Compute every row from that target with the worksheet below. 3. Request increases in the stack region. Large vCPU and address increases can take days to approve, so file them before the migration date. 4. Re-run a representative fan-out and watch where the plateau lands. If it lands below the target, one row was missed. 5. Escalate anything the documented list does not explain. Reach out at [support@warpbuild.com](mailto:support@warpbuild.com). Two levers reduce the quota you need. Multiple instance types per runner keep jobs launching when one type is short on capacity, which is the documented best practice for spot-backed runners. Smaller runner sizes lower vCPU per job, so a 4 vCPU profile needs half the vCPU quota of an 8 vCPU profile at the same concurrency. Windows runners are the exception worth planning around: the documented minimum for Windows Server 2022 on AWS is 8 vCPU with 6,000 IOPS and 500 MBps of disk throughput, so a Windows fleet consumes vCPU quota faster than a Linux fleet of the same job count. The BYOC runner fleet covers Linux across AWS, GCP, and Azure, with Windows on AWS and Azure. ## [Configuration](#configuration)Copy Anchor Link Start from the worksheet. It converts one concurrency target into the numbers you paste into the quota console. The example assumes 200 concurrent jobs, an 8 vCPU runner profile, and the recommended 150 GB root disk per job. | Row | Formula | Example at 200 jobs, 8 vCPU, 150 GB | | ---------------------------------- | ------------------------ | ---------------------------------------------- | | vCPU in the stack region | CON x vCPU per job | 200 x 8 = 1,600 vCPU | | Disk storage | CON x disk per job | 200 x 150 GB = 30,000 GB, rounded up to 30 TiB | | Elastic or external IPv4 addresses | 3 + CON | 3 + 200 = 203 | | Object storage buckets | 1 | 1 | | NAT gateways | 3 | 3 | | Networks | 1 | 1 | | Subnetworks (GCP and Azure) | 2 | 2 | | Standby disk storage | pool size x disk per job | 20 x 150 GB = 3,000 GB on top of the disk row | The standby row exists because a [standby disk](/docs/ci/byoc/standby-disks) is a VM of the runner instance type that boots once and then shuts down, so a queued job starts in about 15 seconds. The shut-down VM stops consuming vCPU, and its disk stays allocated, so the pool adds to the disk quota and not to the vCPU quota. Spot instances are not supported for standby disks. With quotas in place, workflows target BYOC labels the same way they target any runner label. Custom runner labels on BYOC follow the `warp-custom-` prefix and the name you gave the runner: .github/workflows/test.yml Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: unit-tests: strategy: fail-fast: false max-parallel: 24 matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] runs-on: warp-custom-payments-use1-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/8 integration-tests: needs: unit-tests runs-on: warp-custom-payments-use1-16x steps: - uses: actions/checkout@v4 - run: ./scripts/integration.sh lint: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run lint ``` Three configuration decisions are visible in that file: * `max-parallel` caps a single workflow's fan-out below the account ceiling, which keeps one noisy repository from consuming the whole quota while an increase is pending. Remove it once the quota lands. * The 8x and 16x custom runners draw on the same regional vCPU quota, so the worksheet uses a weighted vCPU per job when a fleet mixes sizes. At 150 jobs on 8 vCPU and 50 jobs on 16 vCPU, the request is 150 x 8 plus 50 x 16 = 2,000 vCPU. * The hosted `warp-ubuntu-latest-x64-2x` label in the same workflow consumes no quota in your account, which makes hosted labels a useful pressure valve for light jobs while a BYOC increase is in flight. The tradeoffs between the two placements are covered in the guide to [hosted runners compared with BYOC runners](/guides/hosted-vs-byoc-runners). Keep the elastic IP row in sync with any address policy you already run. Each concurrently running job takes an address, and teams that pin egress addresses for allowlisting have a second reason to track that number, described in the guide to [static IPs for GitHub Actions runners](/guides/static-ip-github-actions-runners). Automate the parts that drift. Version the runner size and the standby pool size next to the quota worksheet, since a size bump silently doubles the vCPU quota a fleet needs. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link A quota increase costs nothing. Cloud providers bill the instances, disks, and addresses that actually run, so the ceiling is free while the traffic under it carries the usual charges. That makes the interesting question the cost of leaving the ceiling low. Take a suite of 400 jobs at 6 minutes each, so 2,400 job-minutes per run. The wall clock depends entirely on the ceiling, and the billed minutes do not move. | Concurrency ceiling | Waves | Wall clock per run | Job-minutes per run | | ------------------- | ----- | ------------------ | ------------------- | | 50 | 8 | 48 min | 2,400 | | 100 | 4 | 24 min | 2,400 | | 200 | 2 | 12 min | 2,400 | Raising the ceiling from 50 to 200 returns 36 minutes of wall clock per run. At 20 runs a day that is 720 minutes of engineer waiting removed daily, from a request that adds no charge on the cloud bill and no charge on the WarpBuild bill. Now price the minutes. WarpBuild charges $0.002 per runner minute for BYOC Linux runners and $0.002 per runner minute for BYOC Windows runners, checked on 2026-08-13 against the [WarpBuild pricing page](/pricing). Your cloud provider bills the compute, storage, and data transfer directly at your own rates, including any spot pricing you have negotiated or configured. | Monthly BYOC runner minutes | WarpBuild fee at $0.002 per minute | Cloud compute | | --------------------------- | ---------------------------------- | ------------------------------------- | | 250,000 | $500 | Billed by your provider at your rates | | 1,000,000 | $2,000 | Billed by your provider at your rates | | 2,500,000 | $5,000 | Billed by your provider at your rates | The 2,400 job-minute suite above costs $4.80 per run in WarpBuild fees at every ceiling in the table, since the fee follows minutes rather than concurrency. Two ongoing costs deserve a line in the model. Standby disks hold storage in your account while the VM is shut down, so a 20-slot pool at 150 GB carries 3,000 GB of block storage at your provider's rate whether or not jobs run. Reserved addresses are the other one: your provider charges for public IPv4 addresses on its own schedule, so request the address quota the fleet needs rather than the largest number you can imagine. Re-run the worksheet whenever the runner size, the disk size, or the standby pool changes. A profile that moves from 8 vCPU to 16 vCPU doubles the vCPU quota needed at the same concurrency, and the first sign of that is a plateau that reappears at half the job count you sized for. The architecture behind those numbers is covered on the [BYOC runners on AWS page](/byoc/aws), with the GCP equivalent on the [BYOC runners on GCP page](/byoc/gcp). ## [FAQ](#faq)Copy Anchor Link **Which AWS quotas do BYOC GitHub Actions runners need?** Per stack, WarpBuild documents EC2 instances at concurrent jobs times vCPU per job, EBS volumes at concurrent jobs times disk per job, elastic IPs at 3 plus concurrent jobs, 1 S3 bucket, 3 NAT gateways, and 1 VPC. Apply every increase in the region where the stack was created. The documented list is not exhaustive, so treat it as the floor. **Does WarpBuild cap how many BYOC runners I can run at once?** Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts dynamically. On BYOC the practical ceiling is the quota in your own cloud account and region, which is why the worksheet on this page starts from a target concurrent job count. **Do the same quotas apply on GCP and Azure?** The shape is the same and the names differ. GCP asks for CPUs, persistent disks, in-use regional external IPv4 addresses at 3 plus concurrency, 1 storage bucket, 3 Cloud NAT, 1 network, and 2 subnetworks, plus provisioned IOPS and throughput quotas when the disks are Hyperdisk. Azure mirrors GCP with a storage container and a Vnet, and BYOC on Azure is enabled for East US today. **Does raising a quota increase my cloud bill?** A quota sets a ceiling and reserves nothing, so your provider bills the instances and disks that actually run. WarpBuild charges $0.002 per runner minute for BYOC Linux runners and $0.002 per runner minute for BYOC Windows runners, checked on 2026-08-13 against the pricing page. --- # Using ccache for C and C++ Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/ccache-github-actions Description: ccache returns nothing on GitHub Actions until CCACHE_DIR survives the job. Wire the compiler launcher, cap the size, restore and save it, price the result. # Using ccache for C and C++ Builds on GitHub Actions ccache returns nothing on GitHub Actions until CCACHE\_DIR survives the job. Wire the compiler launcher, cap the size, restore and save it, price the result. Last verified: 2026-08-13 ccache turns a repeat compile of an unchanged translation unit into a hash lookup instead of a compiler invocation, and on GitHub Actions it returns nothing until `CCACHE_DIR` survives the job, because the runner VM is created for the job and destroyed when the job ends. A working setup has three parts: a compiler launcher so every compile goes through ccache, a size limit so the directory stays cheap to move, and a restore and save pair that carries the directory between runs. This guide covers what ccache hashes and why that matters on a fresh runner, the configuration for a CMake or Make build, a workflow that restores, builds, prints statistics, and saves on success, and a time model that prices a cold compile against a warm cache at a stated hit rate. It sits under the [persistent caches for GitHub Actions runs](/guides/persistent-caches-github-actions) hub. ## [Diagnosis](#diagnosis)Copy Anchor Link ### [What ccache actually hashes](#what-ccache-actually-hashes)Copy Anchor Link ccache is a [compiler cache](/glossary/compiler-cache): it computes a hash from the preprocessed source, an identity for the compiler, the command line, and a few configuration values, then looks that hash up in `CCACHE_DIR`. In direct mode it skips the preprocessor: it keeps a manifest of the include files a source pulled in on the previous compile and hashes those files instead, which is the fast path and the one you want hitting. The hashing rules, the manifest, and every setting named below are documented in the [ccache manual](https://ccache.dev/manual/latest.html). Neither path reads file modification times from the working tree. That is why ccache still hits after `actions/checkout` rewrites every source timestamp, while make and ninja compare mtimes, decide the whole tree is out of date, and re-invoke the compiler on all of it. ccache does not stop those invocations. It makes most of them cost a lookup and a copy. ### [The three reasons ccache reports no hits on GitHub Actions](#the-three-reasons-ccache-reports-no-hits-on-github-actions)Copy Anchor Link **The directory was never restored.** A fresh job starts with an empty `CCACHE_DIR`, so the first run after adding ccache is a full cold compile plus the cost of writing every object into the cache. Nothing is wrong. There is simply no prior state on the machine. **The compiler hash changes between runs.** The default `compiler_check` is `mtime`, meaning ccache hashes the compiler binary's size and modification time. Runner images install their toolchains when the image is built, so a rebuilt image can ship a byte-identical compiler with a different mtime, and every lookup misses. Setting `CCACHE_COMPILERCHECK=content` hashes the binary itself and removes the whole class of failure. **Absolute paths differ.** Debug information, `__FILE__`, and the build directory path all end up in the hash. Setting `CCACHE_BASEDIR` to the workspace rewrites absolute paths under it into relative ones, and `CCACHE_NOHASHDIR=true` keeps the current directory out of the hash for debug builds. Two more misses are common in C++ specifically. Sources that expand `__DATE__` or `__TIME__` are refused unless `sloppiness` includes `time_macros`, and precompiled headers need `pch_defines` before a compile that consumes one will cache. ### [Reading the statistics](#reading-the-statistics)Copy Anchor Link Run `ccache --show-stats --verbose` at the end of the build step and read the counters against this table before changing any configuration. | Counter in ccache -s | What it means | Common cause on a runner | | ------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------- | | Cacheable calls | Compiles ccache was allowed to handle | Denominator for the hit rate | | Hits, direct | Manifest matched on source and includes | The fast path, working as intended | | Hits, preprocessed | Direct mode missed, preprocessor output matched | Include file timestamps changed; add include\_file\_mtime to sloppiness | | Misses | A new object was compiled and stored | Real source change, flag drift, or a changed compiler hash | | Uncacheable calls | The invocation cannot be cached at all | Linking, \-save-temps, multiple source files in one invocation | | Local storage, cache size | Bytes on disk against max\_size | At the ceiling means eviction is discarding warm objects | A hit rate that sits near zero on the second run points at the compiler hash or the path settings. A hit rate that starts high and decays over a week points at eviction, so raise `CCACHE_MAXSIZE` or shrink what the build produces. ### [Decide whether the transfer pays](#decide-whether-the-transfer-pays)Copy Anchor Link The restore and the save are runner minutes. Time both steps on a real run before tuning anything else. WarpBuild's CI observability collects system metrics from the runner agent and correlates them with GitHub Actions job logs, which separates a compile phase that is saturating every core from a job that is sitting in a network transfer. The Action Debugger handles the stubborn case: pause the job, open a session on the live runner, and read `ccache -s` in place instead of guessing from logs. ## [Fix](#fix)Copy Anchor Link **1\. Install ccache and point `CCACHE_DIR` inside the workspace.** A path such as `${{ github.workspace }}/.ccache` gives the compiler launcher and the cache action the same target and keeps `CCACHE_BASEDIR` rewriting predictable. **2\. Set the compiler launcher rather than editing the build.** CMake takes `-DCMAKE_C_COMPILER_LAUNCHER=ccache` and `-DCMAKE_CXX_COMPILER_LAUNCHER=ccache`, which wraps every compile invocation for both the Ninja and the Makefile generators. A hand-written Makefile takes `CC="ccache gcc"` and `CXX="ccache g++"`. **3\. Cap the directory with `CCACHE_MAXSIZE`.** 2G suits most mid-size codebases. The ceiling bounds restore time, bounds the cache storage line on the bill, and makes eviction visible in the statistics instead of silent. **4\. Fix the hash inputs before touching keys.** `CCACHE_COMPILERCHECK=content`, `CCACHE_BASEDIR`, `CCACHE_NOHASHDIR=true`, and a `CCACHE_SLOPPINESS` list that covers `time_macros` and `pch_defines`. Key tuning cannot rescue a build whose hash inputs move every run. **5\. Restore on every run, save on the default branch.** Key the entry on the commit so it never matches exactly, let `restore-keys` fall back to the newest entry on the prefix, and gate the save on `success()` and a push to `main`. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` and is enabled by default on WarpBuild Linux runners; the mechanics are in the [caching documentation](/docs/ci/features/caching). **6\. Zero the counters at the start of the job.** `ccache -z` makes the statistics describe this run rather than the accumulated history inside the restored directory. **7\. Move to machine state when the directory outgrows the transfer.** Once the ccache directory plus the dependency tree is tens of gigabytes, the restore stops paying. [Snapshot runners](/docs/ci/features/snapshot-runners) boot a later job from a captured VM image with the cache already on disk, which removes the transfer from the critical path. Package manager trees have their own answer in [vcpkg and Conan caching on GitHub Actions](/guides/vcpkg-and-conan-caching-github-actions). ## [Configuration](#configuration)Copy Anchor Link ### [Environment](#environment)Copy Anchor Link Set these once at the workflow level so every job and every step agrees. | Setting | Environment variable | Value | Why it matters on a runner | | ----------------- | --------------------- | ---------------------------------------------- | ------------------------------------------------------- | | Cache location | CCACHE\_DIR | ${{ github.workspace }}/.ccache | One path for the launcher and the cache action | | Size limit | CCACHE\_MAXSIZE | 2G | Bounds restore time and storage cost | | Compiler identity | CCACHE\_COMPILERCHECK | content | Survives a rebuilt runner image | | Path rewriting | CCACHE\_BASEDIR | ${{ github.workspace }} | Absolute paths hash the same across checkouts | | Directory hashing | CCACHE\_NOHASHDIR | true | Keeps the build directory out of the hash | | Sloppiness | CCACHE\_SLOPPINESS | time\_macros,pch\_defines,include\_file\_mtime | Caches sources with date macros and precompiled headers | ### [CMake and Make](#cmake-and-make)Copy Anchor Link Copy Text ``` cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache cmake --build build --parallel 16 make -j"$(nproc)" CC="ccache gcc" CXX="ccache g++" ``` For an autotools project, export `CC` and `CXX` with the launcher before running `./configure`, because the generated makefiles bake in whatever the compiler variables held at configure time. ### [The workflow](#the-workflow)Copy Anchor Link Copy Text ``` name: cpp-ccache on: pull_request: push: branches: [main] env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 2G CCACHE_COMPILERCHECK: content CCACHE_BASEDIR: ${{ github.workspace }} CCACHE_NOHASHDIR: "true" CCACHE_SLOPPINESS: time_macros,pch_defines,include_file_mtime jobs: build: runs-on: warp-ubuntu-latest-x64-16x timeout-minutes: 40 steps: - uses: actions/checkout@v4 with: fetch-depth: 1 - name: Install ccache and ninja run: sudo apt-get update && sudo apt-get install -y ccache ninja-build - name: Restore the ccache directory id: ccache-restore uses: WarpBuilds/cache/restore@v1 with: path: ${{ github.workspace }}/.ccache key: ccache-linux-x64-${{ github.sha }} restore-keys: | ccache-linux-x64- - name: Reset the counters so statistics describe this run run: ccache -z - name: Configure with ccache as the compiler launcher run: > cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache - name: Build run: cmake --build build --parallel 16 - name: Print ccache statistics run: ccache --show-stats --verbose - name: Test run: ctest --test-dir build --output-on-failure --parallel 16 - name: Save the ccache directory if: success() && github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: ${{ github.workspace }}/.ccache key: ${{ steps.ccache-restore.outputs.cache-primary-key }} ``` Three details carry the design. The key ends in `github.sha` so it never matches exactly and `restore-keys` always resolves to the newest entry on the prefix. The save runs only when the build and tests passed, so a broken run never seeds later ones. And the architecture is in the key prefix, because objects built on `warp-ubuntu-latest-arm64-16x` cannot serve an x64 job. A matrix that spans architectures keeps one key prefix per architecture. The Rust and mixed-toolchain version of the same setup is on [using sccache on GitHub Actions](/guides/sccache-github-actions). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Every cost number below comes from the [pricing page](/pricing) and the GitHub billing reference, with the checked-on date attached. Replace the build inputs with numbers from your own run history. * A CMake project with roughly 1,400 translation units. * A cold build on `warp-ubuntu-latest-x64-16x` at 26 minutes, of which 21 minutes is compilation and 5 minutes is configure, link, and test. * A pull request that lands an 88 percent ccache hit rate, where a hit costs about one tenth of a full compile. * A 3 GB ccache directory, restored and saved in 1.6 minutes of runner time. * 700 pull request builds per month. | Line | Cold every run | Warm ccache at 88 percent hits | | --------------------------------- | -------------- | ------------------------------ | | Compile phase | 21.0 min | 4.4 min | | Cache restore and save | 0 min | 1.6 min | | Configure, link, test | 5.0 min | 5.0 min | | Job wall clock | 26.0 min | 11.0 min | | Cost per run at $0.032 per minute | $0.83 | $0.35 | | 700 runs per month | $582.40 | $246.40 | The warm compile phase is the misses at full cost, 0.12 times 21 minutes, plus the hits at a tenth of that cost, 0.88 times 21 times 0.1\. Two cache line items sit on top of the runner minutes: cache storage at $0.20 per GB-month, so $0.60 for the 3 GB directory, and cache operations at $0.0001 each, so $0.14 for two operations across 700 runs. Both are rounding error against the 15 minutes per run the cache returns. The break-even is worth computing once. The restore and save cost 1.6 minutes, and each percentage point of hit rate returns 0.189 minutes of compile time in this model, so the pair pays for itself above roughly a 9 percent hit rate. Any C++ repository where most pull requests touch a small share of the tree clears that easily, which is why the interesting tuning question is the miss causes in the Diagnosis section rather than whether to cache at all. Rate context for sizing: `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13 in the [Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Warm at 11 minutes, the same 700 runs list at $246.40 against $323.40 on the GitHub-hosted 16-core shape. `warp-ubuntu-latest-x64-8x` at $0.016 per minute is the size to try first when `ccache -s` shows the build is spending its time on lookups rather than on compiles, since a cache-heavy run stops scaling with cores. Sizing against link time, precompiled headers, and unity builds is covered on [C++ builds on GitHub Actions](/solutions/cpp-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [Why is my ccache hit rate zero on GitHub Actions even though the directory restored?](#why-is-my-ccache-hit-rate-zero-on-github-actions-even-though-the-directory-restored)Copy Anchor Link The usual cause is the compiler hash. ccache identifies the compiler by size and modification time by default, and a runner image installs its toolchain at image build time, so a rebuilt image changes the mtime and every lookup misses. Set `CCACHE_COMPILERCHECK=content` so the compiler binary is hashed by content. The second cause is absolute paths, which is fixed by setting `CCACHE_BASEDIR` to the workspace and `CCACHE_NOHASHDIR=true`. ### [Where should CCACHE\_DIR live in a GitHub Actions job?](#where-should-ccache%5Fdir-live-in-a-github-actions-job)Copy Anchor Link Inside the workspace, such as `${{ github.workspace }}/.ccache`, so one path works for the compiler launcher and for the cache action that restores and saves it. The home directory default under `/home/runner/.cache/ccache` also works, but a workspace path keeps the cache path identical across runners and makes `CCACHE_BASEDIR` rewriting predictable. ### [Should the ccache directory be saved on every run?](#should-the-ccache-directory-be-saved-on-every-run)Copy Anchor Link No. Save on pushes to the default branch and let pull requests restore through `restore-keys`. That keeps one authoritative entry per branch point, avoids paying the upload on every pull request, and stops a failed or partial build from writing an entry that later runs inherit. --- # Working Out CI Cost per Pull Request | WarpBuild URL: https://www.warpbuild.com/guides/ci-cost-per-pull-request Description: Cost per pull request is jobs triggered times billed minutes times the rate for each runner label, times attempts. Build it from billing rows, then cut it. # Working Out CI Cost per Pull Request Cost per pull request is jobs triggered times billed minutes times the rate for each runner label, times attempts. Build it from billing rows, then cut it. Last verified: 2026-08-13 Cost per pull request is an arithmetic result rather than a field on any invoice: multiply the jobs a pull request triggers by the billed minutes each one takes, price every job at the per minute rate for its runner label, sum that into a cost per attempt, then multiply by the average attempts per pull request. The inputs come from per job billing rows grouped by repository, and the three levers that move the answer are path filters, cancel-in-progress, and matrix width. This guide covers why the number resists a direct query, the report path that produces each input, a workflow with all three levers applied, and a worked model that prices one pull request in a multi service repository before and after the changes. ## [Diagnosis](#diagnosis)Copy Anchor Link The unit that gets billed and the unit engineers reason about are different objects, and everything hard about this number follows from that gap. **Billing is per job execution, and a pull request is a group of runs.** One row in the CI tab of the [Billing report](/docs/ci/features/reports) is a single job execution carrying its repository, job name, runner label, stack, execution time, billed time, and cost. Nothing on the row names a pull request. A pull request maps to one or more workflow runs, each run holds many jobs, and each job is a separate row. **Job count per pull request is a variable.** A matrix that expands to six legs bills six jobs. A path filter that skips the integration group on documentation changes bills none of them. Two pull requests against the same repository in the same week can differ by a factor of three in jobs triggered, so a single average hides two populations. **Attempts are the multiplier nobody models.** Pushing a fixup commit starts a fresh run. A flaky suite gets a re-run. A required check that reruns the whole matrix bills the whole matrix again. Attempts per pull request sits well above one in most repositories, and it multiplies every other term. **Billed time and execution time are separate columns.** The billing table reports both, which is what makes a row auditable. Model with billed time, because that is what appears on the invoice. **Repository averages fold expensive repositories into cheap ones.** The daily chart in the CI tab groups by repository or by runner label, and that grouping is the level the model has to be built at. A monorepo with a 24 leg e2e matrix and a docs site with one lint job share an organization and share nothing else. ## [Fix](#fix)Copy Anchor Link Write the model down first, then fill each term from a report. ### [The model](#the-model)Copy Anchor Link Copy Text ``` cost per pull request = attempts x SUM over jobs (billed minutes x rate per minute) ``` Four terms, four sources: | Term | Where it comes from | | ------------------------- | ------------------------------------------------------------------------ | | Jobs triggered | CI billing table, filtered to one repository, counted per workflow run | | Billed minutes per job | The billed time column on each row, or duration P75 from the Jobs report | | Rate per minute | The runner label on the row, priced from the [runner catalog](/pricing) | | Attempts per pull request | Workflow runs in the period divided by pull requests in the period | ### [Pull the inputs, grouped by repository](#pull-the-inputs-grouped-by-repository)Copy Anchor Link Filter the CI billing table to one repository and a date range that covers at least a full sprint. The summary cards give total cost, runner cost, snapshot cost, and total jobs for that window. The table gives the per row detail, and CSV export writes every row matching the current filters and sort order rather than the visible page, which is the shape the arithmetic wants. The Jobs section of the same report aggregates per repository, workflow, and job name combination, with run count, success rate, duration at P75 and P90, and queue time at P75 and P90\. Use it to get a stable per job minute figure instead of an average dragged around by one long tail run. For attempts, count workflow runs against pull requests over the same window through GitHub's [workflow runs endpoint](https://docs.github.com/en/rest/actions/workflow-runs), which returns the pull requests associated with each run. Dividing total runs by total pull requests gives the attempts term without joining every billing row individually. Per workflow grouping is covered in [how do I see cost per workflow](/answers/how-do-i-see-cost-per-workflow), and per team grouping in [attributing GitHub Actions cost](/guides/github-actions-cost-attribution). ### [The three levers](#the-three-levers)Copy Anchor Link **Path filters** remove job groups from pull requests that cannot have broken them. This is the largest lever in a repository holding more than one service, because it subtracts whole groups rather than trimming minutes. Mechanics and the pattern ordering rules are in [running a job only when files change](/answers/how-do-i-run-a-job-only-when-files-change). **cancel-in-progress** attacks the attempts term. A concurrency group keyed on the pull request number stops the previous attempt the moment a new commit arrives, so a five commit review stops paying for five complete runs. What re-runs cost when left alone is modelled in [re-run storms](/guides/rerun-storms-github-actions). **Matrix width** is the smallest of the three and the one most often set wrong in both directions. Every leg repeats checkout, dependency install, and toolchain setup, so a wider matrix buys wall clock with billed minutes. Narrowing it recovers minutes and costs wall clock. ## [Configuration](#configuration)Copy Anchor Link The workflow below shows the three levers applied together. The labels here are the Linux x64 sizes from the [cloud runners catalog](/docs/ci/cloud-runners). Copy Text ``` name: pr on: pull_request: paths: - 'services/api/**' - 'services/web/**' - 'packages/**' - '!**/*.md' concurrency: group: pr-${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: lint: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v5 - run: bun install --frozen-lockfile - run: bun run lint unit: runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v5 - run: bun install --frozen-lockfile - run: bun run test --shard=${{ matrix.shard }}/4 integration: needs: unit runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - run: make integration e2e: needs: unit runs-on: warp-ubuntu-latest-x64-8x strategy: fail-fast: false matrix: suite: [checkout, billing, admin] steps: - uses: actions/checkout@v5 - run: make e2e SUITE=${{ matrix.suite }} ``` Three details decide whether this behaves the way the model assumes. The trigger mixes includes and excludes with `!` prefixes under `paths`, because `paths` and `paths-ignore` cannot both be set for the same event in one workflow, and pattern order decides the result ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onpushpull%5Frequestpaths)). The concurrency group is keyed on the pull request number so that attempts on one review cancel each other while other reviews run untouched ([concurrency](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency)). And `fail-fast: false` keeps a red leg from cancelling its siblings, which trades a few minutes for a complete failure list on the first attempt. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link One multi service repository, 500 pull requests per month, 1.4 attempts per pull request measured as workflow runs divided by pull requests. Every rate comes from the runner catalog on the [pricing page](/pricing), checked on 2026-08-13\. The starting state has a six leg unit matrix and no path filters. | Job group | Runner label | Jobs | Billed minutes each | Minutes | Rate per minute | Cost | | ----------- | ------------------------- | ---- | ------------------- | ------- | --------------- | ------ | | lint | warp-ubuntu-latest-x64-2x | 1 | 3 | 3 | $0.004 | $0.012 | | unit | warp-ubuntu-latest-x64-4x | 6 | 7 | 42 | $0.008 | $0.336 | | integration | warp-ubuntu-latest-x64-8x | 1 | 12 | 12 | $0.016 | $0.192 | | e2e | warp-ubuntu-latest-x64-8x | 3 | 9 | 27 | $0.016 | $0.432 | | Total | 11 | 84 | $0.972 | | | | One attempt costs $0.972\. At 1.4 attempts the average pull request costs $1.3608, and 500 of them cost $680.40 for the month. Now apply the levers in order. Path filters: 40 percent of pull requests touch a single service, so integration and e2e drop out and those attempts cost $0.348\. Matrix width: six unit legs carry 2 minutes of setup each, so 30 minutes of test work plus 12 minutes of setup gives 42; at four legs the same work gives 7.5 minutes of tests plus 2 of setup per leg, or 38 minutes, saving $0.032 per attempt. cancel-in-progress: superseded attempts stop early, taking attempts from 1.4 to 1.15. | State | Cost per attempt | Attempts | Cost per pull request | 500 pull requests | | ----------------------- | ---------------- | -------- | --------------------- | ----------------- | | Baseline | $0.972 | 1.4 | $1.3608 | $680.40 | | Plus path filters | $0.7224 | 1.4 | $1.0114 | $505.68 | | Plus matrix width | $0.6904 | 1.4 | $0.9666 | $483.28 | | Plus cancel-in-progress | $0.6904 | 1.15 | $0.7940 | $396.98 | The cost per attempt after path filters is the blend of two populations: $0.972 on 60 percent of pull requests and $0.348 on the other 40 percent. End to end the model moves from $680.40 to $396.98 per month, a difference of $283.42 at an identical test suite. The ordering is the useful part. Path filters and cancel-in-progress together account for $261.02 of that, and the matrix change for $22.40, which is why matrix width is the last thing to touch and never the first. The rate side of the model is worth pricing against the GitHub-hosted baseline, because the integration and e2e groups are 39 of the 84 minutes. warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price, from GitHub's [minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Runner | Shape | Rate per minute | 39 minutes per attempt | | -------------------------- | ------------- | --------------- | ---------------------- | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $0.624 | | 8-core Linux larger runner | 8 vCPU, 32 GB | $0.022 | $0.858 | Two structural notes for anyone building this model against a real bill. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the per minute rates above are the whole of the runner line and nothing gets allocated by seat count. Signup includes $10 free credits, which covers about seven pull requests at the baseline mix above, or twelve after the levers land, enough to run your own repository through the billing report and check the four terms before changing any workflow. ## [FAQ](#faq)Copy Anchor Link ### [What goes into a cost per pull request number?](#what-goes-into-a-cost-per-pull-request-number)Copy Anchor Link Four inputs. The jobs a pull request triggers, the billed minutes each job takes, the per minute rate for the runner label each job ran on, and the average attempts per pull request. Multiply the first three per job, sum them for one attempt, then multiply by attempts. Every input except attempts is a column in the CI billing table; attempts comes from counting workflow runs against pull requests over the same period. ### [Which lever moves cost per pull request the most?](#which-lever-moves-cost-per-pull-request-the-most)Copy Anchor Link Path filters, in a repository holding more than one service, because they remove whole job groups from most pull requests rather than trimming minutes off every job. In the worked model above, filtering 40 percent of pull requests down to lint and unit tests takes the average from $1.36 to $1.01, cancel-in-progress takes it to $0.79 by cutting attempts from 1.4 to 1.15, and narrowing the unit matrix from six legs to four saves $0.032 per attempt because per leg setup stops being repeated. ### [How do I get the pull request count and the billed minutes for the same period?](#how-do-i-get-the-pull-request-count-and-the-billed-minutes-for-the-same-period)Copy Anchor Link Take the minutes from the CI billing report, filtered to one repository and one date range, and the pull request count from the GitHub REST API for the same range. The billing rows carry a workflow run id rather than a pull request number, so joining them to individual pull requests goes through the workflow runs endpoint. For an average across the repository you can skip the join and divide total cost by pull request count. --- # Setting Build Time Budgets for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/ci-slo-and-build-time-budgets Description: A build time budget is a p50 and p95 target per workflow class. Set the targets from measured percentiles, enforce a ceiling with timeouts, price the fix. # Setting Build Time Budgets for GitHub Actions A build time budget is a p50 and p95 target per workflow class. Set the targets from measured percentiles, enforce a ceiling with timeouts, price the fix. A build time budget is a stated p50 and p95 target for one class of workflows, checked against measured percentiles on a fixed cadence, with a named action when a target breaks. Write one budget per class rather than a single number for the whole estate, because the developer watching a pull request gate and the release captain reading a nightly report tolerate very different durations. This guide covers the workflow classes worth budgeting separately, a budget table with p50 and p95 targets sitting beside the current numbers, where those numbers come from in WarpBuild Reports, the escalation rule that fires when a budget breaks, and the arithmetic on buying the time back with a larger runner size. ## [Diagnosis](#diagnosis)Copy Anchor Link A build time budget is missing when nobody in the room can say whether 14 minutes is acceptable for a given workflow. Three symptoms follow from that gap. **No verdict.** A duration is reported, someone frowns, and the conversation moves on. Without a target the number carries no decision, so it produces discussion instead of work. **Regressions arrive as complaints.** The first signal that a gate got slower is a developer in Slack, which means the regression has already been shipping for days. A budget converts the same event into a threshold crossing on a weekly reading. **The budget sits on the mean.** A mean over a few hundred runs moves slowly and hides the runs that hurt. A gate whose slowest tenth doubled can hold a flat average for a month. Budget by class, because the tolerance comes from what the person is doing while the job runs. Five classes cover most estates. | Workflow class | What the person is doing | Target p50 | Target p95 | Current p50 | Current p95 | Verdict | | -------------------------------- | -------------------------- | ---------- | ---------- | ----------- | ----------- | ------------------ | | Pull request gate, lint and unit | Watching the checks tab | 5.0 min | 8.0 min | 4.2 min | 7.4 min | Inside | | Pull request gate, integration | Context switching and back | 10.0 min | 15.0 min | 11.8 min | 24.0 min | p50 and p95 broken | | Merge queue | Holding up other merges | 12.0 min | 18.0 min | 10.5 min | 16.2 min | Inside | | Release build and publish | Release captain waiting | 20.0 min | 30.0 min | 22.4 min | 29.1 min | p50 broken | | Nightly full suite | Asleep | 60.0 min | 90.0 min | 52.0 min | 141.0 min | p95 broken | Read the spread as well as the two figures. The ratio of p95 to p50 says which kind of problem the class has. The integration gate above sits at 24.0 over 11.8, a ratio above two, which describes a tail rather than a uniformly slow job. The nightly suite is worse at 141.0 over 52.0, and a nightly job with that shape is usually retrying something rather than computing something. Queue time gets its own budget line and stays out of the duration budget. Waiting for a runner happens before the first step, no workflow edit changes it, and queued minutes are not billed, so folding it into the duration figure mixes two problems with two different owners. ## [Fix](#fix)Copy Anchor Link The measurement source is the Jobs section of [WarpBuild Reports](/docs/ci/features/reports), which aggregates per unique repository, workflow, and job name combination and reports run count, success rate, Duration P75 and P90, Queue Time P75 and P90, and CPU and memory percentiles where [Observability](/docs/ci/features/observability) is enabled. Filters cover repository, workflow, job name, runner label, and stack, and every tab exports CSV containing all rows matching the current filters and sort order. That gives P75 and P90 without any work. Two paths follow from there. **Path one: budget on P75 and P90.** The report hands you both, the time-series chart plots either percentile per job over time, and the numbers need no post-processing. Most teams should stop here. **Path two: compute p50 and p95 exactly.** The CI billing tab is one row per job execution carrying repository, job name, runner label, stack, execution time, and billed time. Export it and any percentile is one sort away. Copy Text ``` # p50 and p95 in minutes for one job, from the CI billing CSV export # set COL to the execution time column index in your export COL=6 awk -F, -v col="$COL" 'NR>1 && $2=="integration-tests" {print $col}' ci-billing-2026-08.csv \ | sort -n > /tmp/durations N=$(wc -l < /tmp/durations) awk -v n="$N" 'NR==int((n+1)/2) {print "p50", $1}' /tmp/durations awk -v n="$N" 'NR==int((n*95+99)/100) {print "p95", $1}' /tmp/durations ``` Use execution time for the budget and billed time only when reconciling an invoice. The loop that turns those numbers into a budget is five fixed steps. 1. Assign every workflow to a class. A workflow with no class gets the gate class by default, which is the strictest, so unclassified work surfaces quickly. 2. Baseline over 30 days with the filters and sort fixed, taken on the same weekday each time. 3. Set the p50 target at or slightly below the measured p50, and the p95 target at about 1.5 times the p50 target. 4. Publish the table where the team reviews it, with the current column refreshed weekly from the same export. 5. Apply the escalation rule below, so a broken budget has an owner and a deadline instead of a frown. ### [The escalation rule](#the-escalation-rule)Copy Anchor Link | Trigger | Condition | Action | Deadline | | -------------------------- | --------------------- | -------------------------------------------------------------------------------------------- | --------- | | First reading over target | p95 over, p50 inside | Read queue time P90 and success rate on the same row before touching the workflow | Same week | | Second consecutive reading | Still over | Open an issue with both weekly exports attached and pick one lever: cache, sharding, or size | One week | | Third consecutive reading | Still over | Buy the time back with the next size up and record the monthly delta from the model below | Same day | | Any reading | p50 and p95 both over | Treat as a regression on every run and bisect against the last week that held | 48 hours | The order matters. Runner size is the last lever because it is the only one that raises the invoice, and CPU P90 is the test for whether it will work at all: a job pinned near the ceiling returns wall clock time for cores, and a job sitting in the low tens returns nothing. The Recommendations view flags an instance once max sustained CPU or max memory utilization reaches 80 percent, and Observability collects metrics only for jobs longer than about one minute. When the number that moved is queue time rather than duration, [alerting on CI regressions](/guides/alerting-on-ci-regressions) covers the notification path. ## [Configuration](#configuration)Copy Anchor Link Percentile history is keyed on repository, workflow, and job name, so a renamed job starts an empty row and loses the baseline the budget was set from. Fix the job names first, then encode the class ceiling with `timeout-minutes`. Copy Text ``` name: pr-gate on: pull_request: jobs: lint: runs-on: warp-ubuntu-latest-x64-2x timeout-minutes: 14 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run lint unit-tests: runs-on: warp-ubuntu-latest-x64-4x timeout-minutes: 14 strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/4 integration-tests: needs: unit-tests runs-on: warp-ubuntu-latest-x64-8x timeout-minutes: 25 steps: - uses: actions/checkout@v4 - run: make integration ``` Set `timeout-minutes` above the class p95 target with headroom rather than at it. A p95 target of 15.0 minutes means one run in twenty is expected to exceed it, so a timeout set at 15 converts normal tail runs into red checks. The 25 above catches a runaway run while leaving the tail alone, and the budget stays the thing that reports the tail. Filtering the Jobs report by the `warp-ubuntu-latest-x64-8x` label then shows every job on that size across every repository, which is the view for deciding whether one size is carrying more classes than it should. A Windows or macOS class budgets from the same table with its own targets. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Take the integration gate from the budget table, broken at p50 11.8 and p95 24.0 against targets of 10.0 and 15.0, running 900 times a month on `warp-ubuntu-latest-x64-8x` with CPU P90 near the ceiling. Linux x64 rates from the [pricing page](/pricing), billed per minute: | Label | vCPU | RAM | Storage | USD per minute | | -------------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Percentiles do not multiply into an invoice, so the bill needs a mean. Assume the mean duration is 13.0 minutes on the 8x size and 8.0 minutes after the move to 16x, with p50 landing at 7.5 and p95 at 14.5, both inside target. **The bill.** Today: 900 x 13.0 = 11,700 minutes at $0.016, which is $187.20 a month. After: 900 x 8.0 = 7,200 minutes at $0.032, which is $230.40 a month. The class costs $43.20 a month more to hold inside its budget. Break-even is a mean of 6.5 minutes on the 16x size, below which the move lowers the invoice as well as the clock. **The time.** The tail-only bound counts the slowest 5 percent, 45 runs, each 9.5 minutes shorter: 427.5 minutes, or 7.1 hours a month. The whole-distribution bound uses the p50 move of 4.3 minutes across all 900 runs: 3,870 minutes, or 64.5 hours a month. The real figure sits between 7.1 and 64.5 hours, and four weeks of readings settle it better than any argument about the model. Two structural notes for anyone turning a budget into a line item. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so a size change lands on the invoice as exactly the minute arithmetic above. Signup includes $10 free credits, which covers a first pass at the model on one real workflow. For the estate-wide view that these budgets roll up into, start at [GitHub Actions observability with WarpBuild](/solutions/github-actions-observability). For the percentile mechanics behind the targets, read [tracking build duration percentiles](/guides/github-actions-build-duration-percentiles), and when a budget breaks without an obvious cause, [find which workflow got slower this month](/answers/how-do-i-find-which-workflow-got-slower-this-month) ranks the candidates. ## [FAQ](#faq)Copy Anchor Link **What should a build time budget be set to?** To a figure derived from the measured percentiles of the class over a fixed window, rounded to something a team can defend, rather than to a wish. Take a 30 day baseline, set the p50 target at or slightly below the current p50, and set the p95 target at about 1.5 times the p50 target so the budget carries a stated tolerance for the tail instead of pretending the tail does not exist. **Should the budget sit on p50 or p95?** On both, because they fail for different reasons. A broken p50 means every run got heavier and the lever is the job or the runner size. A broken p95 with p50 holding means a tail formed on a minority of runs, and queue time and success rate on the same row separate contention from retries. **Does the WarpBuild Jobs report give p50 and p95 directly?** The Jobs report carries Duration P75 and P90 and Queue Time P75 and P90 per unique repository, workflow, and job name, so P75 and P90 are the pair available without extra work. For p50 and p95 exactly, export the CI billing tab, which is one row per job execution with execution time, and compute the percentiles from the raw list. --- # Running Concurrent Docker Builds Against One Cache | WarpBuild URL: https://www.warpbuild.com/guides/concurrent-docker-builds-github-actions Description: Several GitHub Actions jobs can build on one WarpBuild builder profile at once. What the shared layer cache does under load, how to size it, what it bills. # Running Concurrent Docker Builds Against One Cache Several GitHub Actions jobs can build on one WarpBuild builder profile at once. What the shared layer cache does under load, how to size it, what it bills. Jobs that name the same builder profile build in parallel on one builder VM and read one layer cache, and that cache is shared but eventually consistent, so two builds running at the same instant can each build the same base stage. The documented sizing recommendation is roughly 8 vCPU and 16 GB of memory per concurrent build job, so the width of your fan-out decides whether adding another image makes every image slower. This guide covers the documented behavior of one profile under load, the two workflow shapes that relieve contention, the knobs that control it, and a cost model in builder minutes for adding a second profile. Rates come from the [pricing page](/pricing) and the [remote Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13\. For the wider picture, start at [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link The signature of profile contention is a build step whose duration tracks the width of the matrix. Three images take four minutes each, six images take eight, and the Dockerfile never changed. Four documented behaviors explain what a profile does when several builds arrive together: * One builder profile is one dedicated Docker builder VM with caching, and multiple jobs using the same profile run their builds on that VM in parallel. * There is no limit on how many builds run concurrently on a profile. The recommended minimum resource requirement is approximately 8 vCPU and 16 GB of memory per build job. * The cache is shared and eventually consistent. Layers produced by one in-flight build may not be visible to another build running at the same moment, and they become available to later builds after synchronization occurs. * Builders bill per session. Jobs with overlapping execution on one profile share a single session, measured from the first job's builder start to the last job's completion. A multi-architecture profile opens one session per architecture, each billed independently. The first two produce CPU contention: six builds on a 16 vCPU profile hold about 2.7 vCPU each against a documented recommendation of 8\. The third produces duplicated work: a cold fan-out builds the shared base stage in more than one leg, and the run after it reads a single copy. Confirm which one you have before changing anything. Trigger a single matrix leg on its own with `workflow_dispatch` and compare its build step duration against the same leg inside the full matrix. If the solo leg finishes sooner, the fan-out is contending for the profile. If the solo leg is equally slow, the problem is layer invalidation rather than concurrency, and [sharing a Docker layer cache across jobs](/guides/share-docker-cache-between-jobs) covers that path. Then read the logs of a cold run. Count `CACHED` lines on the second and later legs. Legs that all execute the same base stage with no `CACHED` prefix are hitting eventual consistency, which costs CPU on the profile and makes the contention worse. Runner capacity is a separate question from builder capacity. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so the queue you see is on the profile rather than on the runner pool. ## [Fix](#fix)Copy Anchor Link There are two shapes, and they trade different resources. The first shape keeps one [builder profile](/runners/docker-builders) and sizes it for the width of the fan-out. Six concurrent builds at the documented 8 vCPU recommendation ask for 48 vCPU, so a 64 vCPU profile carries them: Copy Text ``` name: images on: pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-2x strategy: fail-fast: false matrix: service: [api, worker, scheduler, gateway, indexer, web] steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: Warpbuilds/build-push-action@v6 with: context: . file: services/${{ matrix.service }}/Dockerfile push: true tags: ghcr.io/acme/${{ matrix.service }}:${{ github.sha }} profile-name: services-amd64 timeout: 900000 ``` The job runner stays small because the build runs on the builder, so each leg checks out the repository, authenticates to the registry, and waits. Pick a Linux runner for the driving job, since the build itself executes on the builder. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the product surface. The second shape splits the fan-out across two profiles, which halves the builds per VM without buying a larger size. Carry the profile name in the matrix: Copy Text ``` strategy: fail-fast: false matrix: include: - service: api profile: services-core - service: worker profile: services-core - service: scheduler profile: services-core - service: gateway profile: services-edge - service: indexer profile: services-edge - service: web profile: services-edge steps: - uses: actions/checkout@v4 - uses: Warpbuilds/build-push-action@v6 with: context: . file: services/${{ matrix.service }}/Dockerfile push: true tags: ghcr.io/acme/${{ matrix.service }}:${{ github.sha }} profile-name: ${{ matrix.profile }} ``` Two profiles mean two caches. Shared base layers are stored once per profile, so a cold run after a base image bump builds that stage on both. Group the services so each profile holds one working set: images that share a base and a dependency stage belong together, and images with nothing in common are the natural split line. ## [Configuration](#configuration)Copy Anchor Link Profile size is the main knob, and it divides by concurrency rather than by image count. This table is arithmetic on the documented 8 vCPU recommendation, using the sizes and rates from the [pricing page](/pricing): | Profile size | Disk | Price per minute | 3 concurrent builds | 6 concurrent builds | 12 concurrent builds | | ---------------- | ----- | ---------------- | ------------------- | ------------------- | -------------------- | | 16 vCPU, 32 GB | 100GB | $0.06 | 5.3 vCPU | 2.7 vCPU | 1.3 vCPU | | 32 vCPU, 64 GB | 200GB | $0.12 | 10.7 vCPU | 5.3 vCPU | 2.7 vCPU | | 64 vCPU, 128 GB | 200GB | $0.24 | 21.3 vCPU | 10.7 vCPU | 5.3 vCPU | | 96 vCPU, 192 GB | 600GB | $0.36 | 32.0 vCPU | 16.0 vCPU | 8.0 vCPU | | 192 vCPU, 384 GB | 600GB | $0.72 | 64.0 vCPU | 32.0 vCPU | 16.0 vCPU | Cells at 8 vCPU or above meet the documented minimum. Memory divides the same way, and it is often the tighter constraint: 32 GB across six builds leaves 5.3 GB each against a recommendation of 16 GB. The 96 vCPU and 192 vCPU sizes also come with a 2TB disk at $0.52 and $0.88 per minute, and both are amd64 only. arm64 and multi-architecture profiles cap at 64 vCPU, so a 12-wide arm64 fan-out cannot be solved by size alone and needs the split shape above. Four other settings change what contention looks like: * `max-parallel` on the matrix strategy caps how many legs run at once. Six legs at `max-parallel: 3` give each build twice the vCPU of a full-width fan-out, and they stretch the session, since the session runs until the last leg completes. * `timeout` on the WarpBuild action is the wait for a builder to be ready, in milliseconds, and it defaults to 10 minutes. Raise it when a burst of jobs arrives at a profile that is still starting. * `platforms` with two architectures runs each architecture on a separate builder instance, which means one session per architecture and two sets of layers to keep warm. * `Warpbuilds/docker-configure@v1` is the entry point when you run your own build command. Invoke it immediately before the build step, because the builder is billed from the moment it is assigned. The builder cache has a TTL of 10 days and a profile that goes unused for longer is reset automatically. Dependency caches that live outside the Docker build follow separate rules and are covered in the [WarpBuild caching documentation](/docs/ci/features/caching). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Everything below is arithmetic on stated assumptions rather than a measurement. Replace each duration with the numbers in your own job logs before deciding on it. Assumptions: * Six service images from one shared base stage, 200 pull request runs per month. * A build that gets the recommended 8 vCPU occupies the builder for 4.0 minutes: 3.0 minutes of CPU-bound work and 1.0 minute of context transfer and registry push. * CPU-bound time scales with the vCPU shortfall. At 2.7 vCPU the CPU-bound part runs 9.0 minutes instead of 3.0; at 5.3 vCPU it runs 4.5 minutes. * Matrix legs get runners within 0.5 minutes of each other, so a session runs 0.5 minutes longer than one build. * 0.5 minutes of job runner overhead per leg for checkout and registry login, on `warp-ubuntu-latest-x64-2x` at $0.004 per minute. | Option | Builder minutes per run | Builder cost | Runner minutes | Runner cost | Cost per run | 200 runs | Fan-out wall clock | | -------------------- | ----------------------- | ------------ | -------------- | ----------- | ------------ | -------- | ------------------ | | One 16 vCPU profile | 10.5 | $0.63 | 63 | $0.25 | $0.88 | $176.40 | 10.5 min | | Two 16 vCPU profiles | 12.0 | $0.72 | 36 | $0.14 | $0.86 | $172.80 | 6.0 min | | One 64 vCPU profile | 4.5 | $1.08 | 27 | $0.11 | $1.19 | $237.60 | 4.5 min | Three readings come out of that. Contention is billed. Session billing measures wall clock, so the minutes a build spends waiting on vCPU land on the builder line. The single 16 vCPU profile bills 10.5 builder minutes for the same six images that occupy 4.5 builder minutes on the 64 vCPU profile, and the lower rate is what keeps its total down. The split buys wall clock for roughly nothing. Two profiles open two sessions and add 1.5 builder minutes per run, and the shorter jobs hand back 27 runner minutes, so the monthly totals sit within $4 of each other while the fan-out finishes 4.5 minutes sooner. The cost is cache density: after a base image bump, each profile builds the shared stage once, so the first run of the day is slower on both. The larger profile is the fastest option and the most expensive one. It keeps a single cache for all six images and finishes in 4.5 minutes, for $64.80 more per month than the split on these assumptions. Every rate here carries its source and a checked-on date, and the same rates appear on the [pricing page](/pricing) and in the [remote Docker builders documentation](/docs/ci/docker-builders). ## [FAQ](#faq)Copy Anchor Link ### [What happens when six builds hit one builder profile at the same time?](#what-happens-when-six-builds-hit-one-builder-profile-at-the-same-time)Copy Anchor Link They run in parallel on the same builder VM against the same layer cache. There is no documented limit on concurrent builds per profile, and the documented recommended minimum is roughly 8 vCPU and 16 GB of memory per concurrent build job, so a 16 vCPU profile carrying six builds gives each one about 2.7 vCPU. ### [Why do matrix builds get slower as the matrix gets wider?](#why-do-matrix-builds-get-slower-as-the-matrix-gets-wider)Copy Anchor Link Two effects stack. The legs divide one builder's vCPU between them, and the profile cache is eventually consistent, so legs that start together can each build the same base stage instead of reading it from the cache. Re-run one leg on its own and compare the build step duration to confirm which one you are looking at. ### [Does splitting a fan-out across two profiles cost more?](#does-splitting-a-fan-out-across-two-profiles-cost-more)Copy Anchor Link In builder minutes, yes, because each profile opens its own session. The shorter jobs give runner minutes back, so on the six-image model in this guide two 16 vCPU profiles land within $4 per month of one, while fan-out wall clock drops from 10.5 minutes to 6.0 minutes. --- # Cutting Container Image Size in GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/container-image-size-reduction-github-actions Description: Most of a container image is base layers and build tooling. Move the toolchain into a stage that never ships, land on a slim runtime base, and pull less. # Cutting Container Image Size in GitHub Actions Most of a container image is base layers and build tooling. Move the toolchain into a stage that never ships, land on a slim runtime base, and pull less. A container image is mostly base layers and build tooling, so the reduction that matters comes from moving the toolchain into a stage that never ships and landing the runtime on a slim base. The Node service worked through below goes from 1.50 GB to 0.40 GB compressed, which is 1.10 GB less on every push out of a build job and every pull into a deploy job. This guide covers how to attribute the bytes to base, dependencies, and application layers, the Dockerfile change that removes the largest share, the workflow configuration that keeps the size from creeping back, and a transfer model at a stated deploy frequency. The wider picture is in [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link ### [Attribute the bytes before editing anything](#attribute-the-bytes-before-editing-anything)Copy Anchor Link Two numbers describe an image and they are not interchangeable. `docker image ls` reports the uncompressed size on disk. The registry manifest reports the compressed size of each layer, and that is what a pull moves over the network: Copy Text ``` docker buildx imagetools inspect ghcr.io/acme/api:latest --raw \ | jq '[.layers[].size] | add / 1000000' docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' ghcr.io/acme/api:latest ``` The first command gives the number your transfer bill and your pull step both scale with. The second attributes it to instructions, which is what turns a total into a budget. ### [The size budget](#the-size-budget)Copy Anchor Link Here is the budget for a single stage Node 22 API image built from `node:22-bookworm`, with the shipped image carrying everything the build needed. Replace each row with your own `docker history` output before acting on it. | Component | What it holds | Compressed | Share of total | Ships in production? | | -------------------------------------- | -------------------------------------------------------------------------------- | ----------- | -------------- | ---------------------------------------- | | Base image node:22-bookworm | Debian userland, Node runtime, npm, C toolchain for native modules | 0.45 GB | 30 percent | The runtime does, the toolchain does not | | Dependencies including devDependencies | node\_modules with TypeScript, the test runner, linters, type packages | 0.48 GB | 32 percent | Production needs a subset | | Native build output | node-gyp intermediates, downloaded headers, prebuilt binaries kept after install | 0.30 GB | 20 percent | No | | Application and build context | dist/, the source tree, .git, test fixtures copied by COPY . . | 0.27 GB | 18 percent | dist/ only | | Total | 1.50 GB | 100 percent | | | Three rows in that table are accidental. The C toolchain exists so native modules compile at install time and has no job at run time. The development dependencies exist so the test and lint steps pass. The build context arrived wholesale because the Dockerfile said `COPY . .` and no `.dockerignore` filtered it, which also means `.git` history is inside every deploy. The base image row is the one that stays, and it is the row a runtime base swap addresses. What a base image is and how it constrains everything above it is covered in [base image](/glossary/base-image). ### [What the size costs you per run](#what-the-size-costs-you-per-run)Copy Anchor Link An ephemeral runner starts every job with an empty image store, so every deploy job pulls the whole compressed image, every time. A busy repository pays that pull on each merge and each rollback. The charge lands in three places. Job seconds spent inside the pull step, cloud data transfer billed per gigabyte on whichever network path the pull takes, and registry storage on every retained tag. ECR bills $0.10 per GB-month for stored images in private repositories ([Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/), checked on 2026-08-13), so a pipeline pushing one tag per merge accumulates the fat version of the image many times over. ## [Fix](#fix)Copy Anchor Link ### [Before](#before)Copy Anchor Link The single stage file that produced the budget above: Copy Text ``` FROM node:22-bookworm WORKDIR /app COPY . . RUN npm ci RUN npm run build CMD ["node", "dist/server.js"] ``` Everything `npm ci` installed, everything `COPY . .` copied, and the whole `node:22-bookworm` userland ship together. ### [After](#after)Copy Anchor Link The staged file. The toolchain stages compile and test, a separate stage resolves production dependencies only, and the release stage starts from `node:22-slim` and copies two directories in. Copy Text ``` # syntax=docker/dockerfile:1.7 FROM node:22-bookworm AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci FROM deps AS build COPY . . RUN npm run build FROM node:22-bookworm AS prod-deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev FROM node:22-slim AS release ENV NODE_ENV=production WORKDIR /app COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist USER node CMD ["node", "dist/server.js"] ``` Pair it with a `.dockerignore` so the build context stops carrying history and fixtures: Copy Text ``` .git node_modules coverage dist **/*.test.ts .github ``` The release budget after the change: | Component | Compressed | Change | | ---------------------------------------------------- | ---------- | --------------------------------------------------------------- | | Base image node:22-slim | 0.07 GB | 0.38 GB removed with the toolchain userland | | Production node\_modules | 0.28 GB | 0.20 GB removed with devDependencies | | Native prebuilt binaries for production dependencies | 0.01 GB | 0.29 GB of build intermediates left behind in prod-deps | | Compiled dist/ | 0.04 GB | 0.23 GB of source, fixtures, and .git filtered by .dockerignore | | Total | 0.40 GB | 1.10 GB smaller per pull | Two properties of this layout matter beyond the total. The `--mount=type=cache` directories keep the npm cache outside the layer graph, so nothing that speeds up installs ends up in a shipped layer. And `prod-deps` resolves against the same lockfile as `deps`, so production gets the exact versions the tests ran against. ## [Configuration](#configuration)Copy Anchor Link ### [The workflow](#the-workflow)Copy Anchor Link Copy Text ``` name: image on: pull_request: push: branches: [main] jobs: release: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v5 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: Warpbuilds/build-push-action@v6 with: context: . target: release push: ${{ github.event_name == 'push' }} tags: ghcr.io/acme/api:${{ github.sha }} profile-name: api-amd64 timeout: 600000 - name: Enforce the size budget if: github.event_name == 'push' run: | BYTES=$(docker buildx imagetools inspect \ ghcr.io/acme/api:${{ github.sha }} --raw | jq '[.layers[].size] | add') LIMIT=$((450 * 1000 * 1000)) echo "compressed image size: $BYTES bytes (limit $LIMIT)" test "$BYTES" -le "$LIMIT" ``` The gate is the part teams skip and then regret. A budget nobody checks drifts back within a quarter, because every dependency addition is individually small. The job runner here checks out, hands the build to a builder profile, and waits, which `warp-ubuntu-latest-x64-2x` covers. Image work belongs on the Linux labels. A builder profile is one dedicated builder virtual machine with a persistent layer cache on its own disk, so the `deps` and `prod-deps` stages survive between jobs; inputs, session billing, and the profile setup are in the [remote Docker builders documentation](/docs/ci/docker-builders). ### [Keep caches out of the image](#keep-caches-out-of-the-image)Copy Anchor Link Anything a build downloads to go faster belongs in a cache action rather than in a layer. `WarpBuilds/cache@v1` is a drop-in replacement for the standard cache action, documented in the [WarpBuild caching documentation](/docs/ci/features/caching), and it keeps SDKs, fixture sets, and toolchain downloads outside the image entirely. Observability is what tells you whether the pull step is still a visible share of job duration after the image shrinks. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The model is arithmetic on stated assumptions rather than a measurement. Every rate carries a source link and a checked-on date, and the same rate appears on every WarpBuild surface that states it. ### [Assumptions](#assumptions)Copy Anchor Link * A deploy pipeline running 40 jobs per weekday across 22 weekdays, which is 880 deploy jobs per month. * One full image pull per job, because ephemeral runners start with an empty image store. * 100 MB per second of effective pull throughput, covering transfer plus decompression. Replace this with the wall clock your own timed `docker pull` step reports. * Compressed image sizes as measured from the registry manifest. * Transfer priced at $0.09 per GB on the internet path and $0.00 per GB on the same-Region path ([Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/), checked on 2026-08-13). ### [Image size against pull time and transfer](#image-size-against-pull-time-and-transfer)Copy Anchor Link | Compressed image | Pull seconds per job | GB pulled per month | Internet path at $0.09 per GB | Same-Region path at $0.00 per GB | | ---------------- | -------------------- | ------------------- | ----------------------------- | -------------------------------- | | 3.0 GB | 30.0 | 2,640 | $237.60 | $0.00 | | 1.5 GB | 15.0 | 1,320 | $118.80 | $0.00 | | 1.0 GB | 10.0 | 880 | $79.20 | $0.00 | | 0.4 GB | 4.0 | 352 | $31.68 | $0.00 | Read a row for what one image size costs, and read down the column for what the Dockerfile change buys. The 1.50 GB to 0.40 GB move takes 11 seconds out of each deploy and 968 GB out of the monthly transfer. ### [Runner minutes at those pull times](#runner-minutes-at-those-pull-times)Copy Anchor Link Job runner rates from the [pricing page](/pricing), checked on 2026-08-13: | Runner label | Shape | Price per minute | | ------------------------- | ------------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | At 1.50 GB the pull consumes 220.0 runner minutes per month, which is $0.88 on `warp-ubuntu-latest-x64-2x`. At 0.40 GB it consumes 58.7 minutes, which is $0.23\. Adding transfer on the internet path, the month goes from $119.68 to $31.91. Two conclusions follow, and they point in different directions. On the internet path the transfer line carries the saving, $87.12 of the $87.77\. On the same-Region path the whole difference is $0.65 of runner time, so slimming is a wall clock lever there and machine placement is the invoice lever. The full placement model, including managed NAT residue, is in [what ECR pulls cost in GitHub Actions](/guides/ecr-pull-costs-github-actions), and the pull-side levers beyond size are in [how to reduce Docker image pull time](/answers/how-do-i-reduce-docker-image-pull-time-in-github-actions). Storage stacks on top of both. At $0.10 per GB-month, 30 retained tags of the 1.50 GB image is $4.50 per month against $1.20 for the 0.40 GB image, before any layer sharing between tags. Teams that want the transfer line removed rather than reduced should read the [zero egress page](/enterprise/zero-egress): on the enterprise tier, egress costs from the customer's cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores during deployments, on BYOC and on WarpBuild-hosted runners. ## [FAQ](#faq)Copy Anchor Link ### [How do I measure the size that actually matters?](#how-do-i-measure-the-size-that-actually-matters)Copy Anchor Link Measure the compressed layer sizes in the registry manifest, since those are the bytes a pull moves. Run `docker buildx imagetools inspect --raw` and sum the layer sizes. `docker image ls` reports the uncompressed size on disk, which is larger and is not what the network carries. ### [Does a smaller image reduce my GitHub Actions bill?](#does-a-smaller-image-reduce-my-github-actions-bill)Copy Anchor Link Only through the seconds it removes from each job. The larger line is usually cloud data transfer, billed per gigabyte by your registry's provider, so a 1.10 GB reduction across 880 monthly deploy jobs is 968 GB less moved. On a same-Region path that transfer is already $0.00 per GB, and slimming stays a wall clock lever there. ### [Should I switch to a smaller base image or split the build into stages first?](#should-i-switch-to-a-smaller-base-image-or-split-the-build-into-stages-first)Copy Anchor Link Split into stages first. The stage split removes the compiler, the package manager caches, and the development dependencies, which is usually the larger share of the total. Swapping the runtime base after that removes what is left of the operating system layer. Stage layout and how it interacts with layer reuse are covered in [multi stage Docker builds and layer reuse](/guides/multi-stage-docker-builds-github-actions). --- # Running Jobs Inside a Container | WarpBuild URL: https://www.warpbuild.com/guides/container-jobs-github-actions Description: The container key runs every step inside your image, so paths, permissions, and tooling change. Here is the setup that keeps caches and services working. # Running Jobs Inside a Container The container key runs every step inside your image, so paths, permissions, and tooling change. Here is the setup that keeps caches and services working. The `container` key on a job starts your image on the runner virtual machine and runs every step of the job inside it, per [GitHub's workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidcontainer). Three things change the moment you add it: the paths your steps see, the user those steps run as, and which tools exist at all, because the container carries its own filesystem and the runner image toolchain stays outside it. This guide covers the mount table the runner sets up, the failures each change produces, the documented cache configuration for a container job, a workflow with a container, a working cache step, and a service alongside it, and the arithmetic on image pulls. Container jobs are supported on Linux runners only, so the label under `runs-on` is a [Linux x64](/runners/linux-x64) or Linux ARM64 one. For the wider set of levers, see the [hub on speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link Work through the three changes in order. Each one produces a distinct symptom, and two of them fail without an error. ### [Paths move](#paths-move)Copy Anchor Link The runner mounts a fixed set of host directories into the job container and rewrites `HOME`. The mapping below is what [ContainerOperationProvider.cs in the actions/runner repository](https://github.com/actions/runner/blob/main/src/Runner.Worker/ContainerOperationProvider.cs) sets up on every container job. | Host path | Container path | What it holds | | ------------------------------------------- | ----------------- | -------------------------------------------------------------- | | /home/runner/work | /\_\_w | The workspace, so GITHUB\_WORKSPACE reads as /\_\_w/repo/repo | | /home/runner/externals | /\_\_e, read only | The Node.js build that executes JavaScript actions | | /home/runner/work/\_temp | /\_\_w/\_temp | RUNNER\_TEMP, step temp files | | /home/runner/work/\_actions | /\_\_w/\_actions | Checked-out action code | | The runner tool cache directory | /\_\_t | RUNNER\_TOOL\_CACHE, where setup-\* actions install toolchains | | /home/runner/work/\_temp/\_github\_home | /github/home | HOME for every step in the job | | /home/runner/work/\_temp/\_github\_workflow | /github/workflow | The event payload and the GITHUB\_ENV and GITHUB\_OUTPUT files | Paths relative to the workspace survive unchanged, so `./node_modules`, `vendor/`, and `target/` behave as they do on a plain runner. Two things break. A `~` in a workflow file expands to `/github/home`, which is a directory the runner created for this job and which most official language images ignore in favor of an absolute path baked in at image build time. And any absolute host path a script hardcodes, such as `/home/runner/work/repo/repo/dist`, points at nothing inside the container. The cache case is the expensive one because it stays silent. A step caching `~/.npm` inside a container reports a restore, the install step downloads every package anyway, and the job time never moves. The [answer on caching inside a container job](/answers/does-caching-work-inside-a-container-job) works through that mismatch image by image. ### [Permissions change](#permissions-change)Copy Anchor Link The container runs as the user its image declares, which for most official language images is root. Files that steps write into the mounted workspace therefore land owned by root, while the workspace directory itself was created by the runner user on the host. Two failures come out of that split: * An image that sets a non-root `USER` cannot write into the workspace it was handed, and `actions/checkout` fails on permission denied before the first useful step. * `git` refuses to operate on a repository owned by a different user and reports detected dubious ownership, which shows up when a working tree checked out by one user is read by a step running as another. Privileged operations behave the same way they do in any container. Mounting filesystems, changing iptables rules, or talking to a Docker daemon requires options you pass explicitly, because the container gets Docker's default capability set and no access to the host socket. ### [The image is the tool set](#the-image-is-the-tool-set)Copy Anchor Link Whatever the image lacks, the step lacks. The runner image on the host carries a large preinstalled toolchain, and none of it is visible from inside the container except the tool cache mounted at `/__t`. Binaries already sitting in that tool cache were built against the host C library, so a glibc-linked binary from `/__t` will not execute in a musl-based Alpine image. | Symptom | Cause | Fix | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------- | | Checkout produces a working tree with no .git directory | git is absent, so actions/checkout falls back to downloading a tarball through the REST API | Install git in the image | | zstd version: null followed by a 404 warning on restore | zstd is absent, so the cache action falls back to gzip and cannot read an entry saved with zstd | Install zstd | | The cache step cannot authenticate | WARPBUILD\_RUNNER\_VERIFICATION\_TOKEN was not passed into the container env block | Pass the variable through | | A reported cache hit over an empty directory | HOME is /github/home, so the cached path is not where the toolchain writes | Resolve the path from the tool | | Connection refused at 127.0.0.1:5432 | Inside a job container, 127.0.0.1 addresses the job container itself | Address the service by its label | | Permission denied writing to the workspace | The image sets a non-root USER | Set options: --user 0 or chown the workspace | ## [Fix](#fix)Copy Anchor Link Five changes, in this order. **1\. Decide whether the job needs a container.** A container is the right answer when the job needs a specific base operating system, a system library set you control, or the exact image your service runs in production. When all you need is a language runtime, a `setup-*` action on the plain runner installs it into the mounted tool cache without paying an image pull on every job. The [base image](/glossary/base-image) entry covers the distinction between the image a job runs in and the image a job builds. **2\. Pin the image.** A moving tag such as `:latest` changes the toolchain under a workflow that nobody edited. Pin an exact tag, or a digest when reproducibility matters more than patch updates. **3\. Bake the prerequisites in.** The [WarpBuild caching documentation](/docs/ci/features/caching) lists three conditions for the cache action inside a custom container: `wget`, because the action downloads entries with it; `zstd`, because entries are compressed with it; and `WARPBUILD_RUNNER_VERIFICATION_TOKEN` passed into the container `env`, because that variable lives on the runner host and does not cross into the container on its own. Installing `git`, `wget`, and `zstd` in a step works and costs a package install on every run. Adding them to the image costs nothing per run. **4\. Resolve cache paths from the toolchain.** `npm config get cache`, `go env GOMODCACHE`, `pip cache dir`, and `cargo config get` each print the path that image resolved. Feed that output to the cache step instead of writing `~` into the workflow file. **5\. Address services by label.** Set the connection string once in the container `env` block so no step has to know the topology. ## [Configuration](#configuration)Copy Anchor Link The whole workflow: a container job, a cache step that restores a real directory, and a Postgres service alongside it. Copy Text ``` name: integration on: pull_request: push: branches: [main] jobs: integration: runs-on: warp-ubuntu-latest-x64-8x container: image: node:22-bookworm env: WARPBUILD_RUNNER_VERIFICATION_TOKEN: ${{ env.WARPBUILD_RUNNER_VERIFICATION_TOKEN }} DATABASE_URL: postgres://app:app@postgres:5432/app_test options: --user 0 services: postgres: image: postgres:17 env: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app_test options: >- --health-cmd "pg_isready -h 127.0.0.1 -U app -d app_test" --health-interval 5s --health-timeout 5s --health-retries 20 --health-start-period 10s steps: - name: Install prerequisites run: apt-get update && apt-get install -y --no-install-recommends git wget zstd - uses: actions/checkout@v5 - name: Resolve the npm cache path id: npm run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT" - name: Restore the npm cache id: cache uses: WarpBuilds/cache@v1 with: path: ${{ steps.npm.outputs.dir }} key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - run: npm ci - name: Verify the restore landed run: du -sh "${{ steps.npm.outputs.dir }}" - name: Migrate run: npm run migrate - name: Test run: npm test ``` The prerequisite install runs before checkout because checkout needs `git`. On Alpine images the same line reads `apk add --no-cache git wget zstd`. Keep the `du -sh` step for a run or two after any image change: a hit with a few kilobytes behind it is the empty-directory failure, and a hit with hundreds of megabytes behind it is a working cache. ### [The container block, key by key](#the-container-block-key-by-key)Copy Anchor Link `image` takes a registry reference. `credentials` supplies a username and password for a private registry. `env` sets variables for every step, and it is the only way host variables such as the verification token reach the container. `volumes` adds mounts on top of the ones the runner already makes. `options` is passed to `docker create`, so `--user`, `--privileged`, `--cpus`, and `--shm-size` all go there. ### [Services seen from inside a container](#services-seen-from-inside-a-container)Copy Anchor Link | Where the job runs | Service address | Does ports matter | | ------------------------------ | ------------------------------------------------------ | ------------------------------------------------------- | | Directly on the runner machine | 127.0.0.1 on the published port | Yes, each service needs a ports mapping | | Inside a job container | The service label as a hostname, on the container port | No, both containers share a user-defined bridge network | The runner creates that bridge network before it creates any container, which is why the label resolves ([GitHub service container documentation](https://docs.github.com/en/actions/tutorials/use-containerized-services/use-docker-service-containers)). Health checks matter the same amount either way, and the [guide to service containers for integration tests](/guides/service-containers-github-actions) covers the gate that removes the startup race. ### [Architecture has to match](#architecture-has-to-match)Copy Anchor Link An image built for `amd64` only does not run on an ARM64 label. Publish a multi-architecture image, or keep the container job on an x64 label and read the shapes from the [cloud runners documentation](/docs/ci/cloud-runners). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Runners are ephemeral, so the image is pulled on every job. Assumptions, stated so you can substitute your own step timings: * 600 runs per month on `warp-ubuntu-latest-x64-8x` at $0.016 per minute ([pricing page](/pricing), checked on 2026-08-13). * A 450 MB compressed image that pulls and unpacks in 0.35 minutes. * A prerequisite package install of 0.25 minutes when it runs as a step. * A cache restore of 0.20 minutes. | Setup | Pull | In-job install | Cache restore | Minutes per run | Monthly minutes | Monthly cost | | --------------------------------------------- | ---- | -------------- | ------------- | --------------- | --------------- | ------------ | | Stock image, prerequisites installed each run | 0.35 | 0.25 | 0.20 | 0.80 | 480 | $7.68 | | Same image with prerequisites baked in | 0.35 | 0.00 | 0.20 | 0.55 | 330 | $5.28 | | Slim custom image with prerequisites | 0.15 | 0.00 | 0.20 | 0.35 | 210 | $3.36 | Cache fees sit on top and are billed separately from runner minutes. Six GB of resident entries at $0.20 per GB-month is $1.20, and 600 restores plus 60 saves is 660 operations at $0.0001 each, or $0.07, both from the [pricing page](/pricing) checked on 2026-08-13\. On BYOC runners, cache storage and operations are included. Runner size sets what every remaining minute costs, and the job container plus each service container share that machine's memory: | Runner label | vCPU | RAM | Price per minute | | -------------------------- | ---- | ------ | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 | The container key applies to the Linux labels. When the image pull and the setup inside it dominate the job rather than the work, a snapshot runner captures the prepared machine instead; snapshot runners sit alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger in the product surface, and the observability reports show how much of the job the "Initialize containers" group actually takes. ## [FAQ](#faq)Copy Anchor Link ### [Does every step of the job run inside the container?](#does-every-step-of-the-job-run-inside-the-container)Copy Anchor Link Yes. The runner creates the container before the first step and runs every step inside it, including the actions your workflow calls. JavaScript actions execute with the Node.js build the runner mounts read only at `/__e`, so the image does not need Node installed for actions to start. Everything a `run` step invokes has to be present in the image, because the tooling installed on the runner host is outside the container. ### [How do I reach a service container from a job that runs in a container?](#how-do-i-reach-a-service-container-from-a-job-that-runs-in-a-container)Copy Anchor Link By the service label as a hostname, on the port the service listens on inside its own container. The runner puts the job container and every service container on the same user-defined bridge network, so a Postgres service labeled `postgres` answers at `postgres:5432` and the `ports` keyword is unnecessary. Inside a job container, `127.0.0.1` means the job container itself, which is why a workflow copied from a non-container job reports connection refused. ### [Why does the same cache step work outside a container and restore an empty directory inside one?](#why-does-the-same-cache-step-work-outside-a-container-and-restore-an-empty-directory-inside-one)Copy Anchor Link `HOME` is `/github/home` inside a container job, so a path written as `~/.npm` or `~/.cargo` resolves under a directory the runner created for the job rather than the location the toolchain in the image uses. The step reports a hit and the install runs at full cost anyway. Print the path from the tool itself with `npm config get cache` or `go env GOMODCACHE` and pass that value to the cache action. The [answer on caching inside a container job](/answers/does-caching-work-inside-a-container-job) lists the images where this bites hardest. --- # Cross Compiling or Native ARM64 Builds | WarpBuild URL: https://www.warpbuild.com/guides/cross-compiling-vs-native-arm64-builds Description: Cross compile ARM64 artifacts on an x64 runner when the build links no native code and tests can stay on x64. Build natively when cgo or ARM64 tests run. # Cross Compiling or Native ARM64 Builds Cross compile ARM64 artifacts on an x64 runner when the build links no native code and tests can stay on x64\. Build natively when cgo or ARM64 tests run. Last verified: 2026-08-13 Cross compiling produces an ARM64 artifact on an x64 runner, while a native build runs the same compile on an ARM64 runner. Cross compiling is enough when the build links no native code and the test suite exercises no architecture-specific behaviour; a native ARM64 runner is required once cgo, native extensions, or tests that must execute on aarch64 enter the pipeline. WarpBuild carries both shapes as runner labels. The cross-compiling job runs on `warp-ubuntu-latest-x64-8x` at $0.016 per minute and the native job runs on `warp-ubuntu-latest-arm64-8x` at $0.012 per minute. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so a repository can hold both legs without leaving GitHub Actions. This page owns the compile-strategy decision. Container emulation is covered in [QEMU compared with native ARM64 builds](/guides/qemu-vs-native-arm64-builds), the full label and price list sits on the [Linux ARM64 runner catalog](/runners/linux-arm64), and running the same jobs on your own AWS account is covered in [Graviton builds in GitHub Actions](/guides/graviton-builds-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link ### [What a cross compiler actually proves](#what-a-cross-compiler-actually-proves)Copy Anchor Link A cross compiler emits machine code for a target triple such as `aarch64-unknown-linux-gnu` while executing on the x64 host. A green cross build proves that the source compiled and linked for that triple. Nothing on the machine executed the output, so every property that appears at execution time stays unverified until something runs the binary on ARM64 hardware. Three classes of problem live on the execution side. ### [cgo and native extensions](#cgo-and-native-extensions)Copy Anchor Link The moment the build links C or C++, cross compiling stops being a compiler flag and becomes a toolchain assembly job. Go with `CGO_ENABLED=1` needs `aarch64-linux-gnu-gcc` and headers for every linked library. Rust crates with a `build.rs` that shells out to a C compiler need the same, plus a linker entry in `.cargo/config.toml`. Python packages without an `aarch64` wheel fall back to building from source, and Node addons compiled through node-gyp pick up the host compiler unless you override it. The failure modes are specific. `ld: cannot find -lssl` means the sysroot lacks the target library. `wrong ELF class: ELFCLASS64` means the build grabbed a host object file. `undefined reference to __aarch64_ldadd4_acq_rel` means the compiler and the target libgcc disagree on atomics support. ### [Architecture-specific tests](#architecture-specific-tests)Copy Anchor Link An x64 host cannot execute an aarch64 test binary, so `go test`, `cargo test`, and `ctest` all stop at the compile step in a cross build. Skipping the tests is the quiet part of the decision, because aarch64 semantics differ from x86-64 in ways that only execution reveals. * aarch64 uses a weaker memory model than x86-64 store ordering. Missing barriers and benign-looking data races surface as flaky failures on ARM64 and stay invisible on x64. * `char` defaults to unsigned on AArch64 Linux and signed on x86-64, so byte comparisons that assume a negative value change branch on the target. * Fused multiply-add contraction changes last-bit float results, which breaks exact-equality assertions in numeric suites. * SSE intrinsics and `__builtin_ia32_*` calls compile only behind an `#ifdef`, so the ARM64 path is often the branch nobody has executed. * Page size assumptions baked into `mmap` arithmetic differ between host and target. ### [Toolchain drift](#toolchain-drift)Copy Anchor Link Cross toolchains age separately from the code they build. The sysroot pins a glibc version, and a sysroot newer than the deployment target yields a binary that refuses to start with `GLIBC_2.38 not found`, which the build log never shows. Autoconf scripts that probe with `uname -m` or `nproc` read the host. A `-march=native` flag left in a release build targets the x64 host CPU and produces an ARM64 binary tuned for nothing. Conditional compilation guarded by `#ifdef __aarch64__` or `cfg(target_arch = "aarch64")` compiles only on the target path, so that code gets its first compiler pass the day you cross compile and its first execution much later. ### [Classify the repository in one step](#classify-the-repository-in-one-step)Copy Anchor Link Run this in a scratch job before choosing a strategy. Copy Text ``` - name: classify the build run: | go list -f '{{.ImportPath}} {{len .CgoFiles}}' ./... | grep -v ' 0$' || echo "no cgo packages" find . -name build.rs -not -path './target/*' | head grep -rn "march=native\|__aarch64__\|target_arch" --include='*.c' --include='*.cc' --include='*.rs' . | head grep -rn "CMAKE_TOOLCHAIN_FILE\|--host=" --include='*.yml' --include='*.cmake' . | head ``` Empty output across all four lines points at cross compiling. Any hit on the first two lines pulls the build toward a native ARM64 runner, and hits on the last two mean the cross path needs a maintained sysroot that somebody owns. ## [Fix](#fix)Copy Anchor Link ### [Three questions decide it](#three-questions-decide-it)Copy Anchor Link 1. Does the build link native code? A yes moves the build toward native, because assembling and maintaining a cross toolchain is ongoing work. 2. Must the tests execute on aarch64? A yes forces at least one native job, whatever the build strategy is. 3. Does the toolchain publish a supported target triple with a maintained sysroot? A no removes cross compiling from the options. ### [The toolchain table](#the-toolchain-table)Copy Anchor Link | Toolchain | Cross compiling to linux/arm64 | Tests on the target architecture | Build shape and time cost | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | Go, CGO\_ENABLED=0 | Built into the standard toolchain through GOOS=linux GOARCH=arm64. No extra packages. | go test compiles the test binary and stops. No ARM64 execution. | One compile pass on the x64 host. No toolchain install step. | | Go, CGO\_ENABLED=1 | Needs gcc-aarch64-linux-gnu and CC=aarch64-linux-gnu-gcc, plus target headers for every linked library. | Same block: the test binary builds and cannot run. | Adds an apt install step per run unless the packages are cached. | | Rust, pure crate graph | rustup target add aarch64-unknown-linux-gnu plus a linker entry in .cargo/config.toml. Proc macros still build for the host. | cargo test --target aarch64-unknown-linux-gnu requires a runner binary to execute the tests. | Adds a target download step. The dependency graph compiles twice when host and target both need it. | | Rust with \-sys crates or build.rs | Needs the cross C toolchain and PKG\_CONFIG\_SYSROOT\_DIR pointed at the target sysroot. | Same as above, with the added risk that the linked C library version differs from the target. | Adds toolchain install plus sysroot setup per run. | | C++ with CMake | Needs a toolchain file, a sysroot, and find\_package paths that resolve inside it. | ctest cannot execute the cross-built tests on the host. | Adds sysroot fetch and configure time. The configure step reruns whenever the toolchain file changes. | ### [The three workable shapes](#the-three-workable-shapes)Copy Anchor Link **Cross only.** One job on an x64 label produces the artifact. Correct for a pure Go or pure Rust binary whose behaviour carries no architecture dependence, where the tests already run on x64 and prove the logic. **Native only.** One job on an ARM64 label builds and tests on the architecture you ship to. Correct when cgo, native extensions, or aarch64 semantics are in play, and it removes the cross toolchain from the repository. **Split.** Cross compile the release artifact on x64, then run the test suite on a native ARM64 runner. Correct when the release build is heavily parallel and the test suite is short, or when a cross toolchain already exists and works. Pick one per artifact rather than per repository. A service binary and a native Python extension in the same repository often land on different answers. ## [Configuration](#configuration)Copy Anchor Link ### [Cross-compiling job on an x64 label](#cross-compiling-job-on-an-x64-label)Copy Anchor Link Copy Text ``` name: release on: push: tags: ["v*"] jobs: cross-build-arm64: # warp-ubuntu-latest-x64-8x: 8 vCPU, 32GB, $0.016 per minute runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.24" - name: install the aarch64 cross toolchain run: | sudo apt-get update sudo apt-get install -y gcc-aarch64-linux-gnu - name: build the arm64 binary env: GOOS: linux GOARCH: arm64 CGO_ENABLED: "1" CC: aarch64-linux-gnu-gcc run: go build -o dist/app-linux-arm64 ./cmd/app - name: assert the artifact targets aarch64 run: file dist/app-linux-arm64 | grep -q "ARM aarch64" - uses: actions/upload-artifact@v4 with: name: app-linux-arm64 path: dist/app-linux-arm64 ``` The `file` assertion earns its place. A misconfigured `CC` or a stale `GOARCH` yields an x86-64 binary with the right filename, and the assertion fails the job instead of shipping it. ### [Native job on an ARM64 label](#native-job-on-an-arm64-label)Copy Anchor Link Copy Text ``` native-build-arm64: # warp-ubuntu-latest-arm64-8x: 8 vCPU, 32GB, $0.012 per minute runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.24" - uses: WarpBuilds/cache@v1 with: path: | ~/.cache/go-build ~/go/pkg/mod key: go-${{ runner.arch }}-${{ hashFiles('**/go.sum') }} - run: go build -o dist/app-linux-arm64 ./cmd/app - run: go test -race ./... ``` The environment block disappears because the host is the target. `go test -race` now executes on aarch64, which is where the race detector has something to say about the weak memory model. ### [Rust cross linker configuration](#rust-cross-linker-configuration)Copy Anchor Link Copy Text ``` # .cargo/config.toml [target.aarch64-unknown-linux-gnu] linker = "aarch64-linux-gnu-gcc" [env] PKG_CONFIG_ALLOW_CROSS = "1" ``` Without the linker entry, `cargo build --target aarch64-unknown-linux-gnu` calls the host `cc` and fails at link time with a stream of unrecognized object file errors. Sizing, caching, and toolchain setup for the rest of a Rust pipeline are covered on the [Rust on GitHub Actions solution page](/solutions/rust-github-actions). ### [Split shape as a matrix](#split-shape-as-a-matrix)Copy Anchor Link Copy Text ``` jobs: build: strategy: matrix: include: - runner: warp-ubuntu-latest-x64-8x job: cross-build - runner: warp-ubuntu-latest-arm64-4x job: native-test runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: WarpBuilds/cache@v1 with: path: target key: cargo-${{ runner.arch }}-${{ hashFiles('**/Cargo.lock') }} - if: matrix.job == 'cross-build' run: | rustup target add aarch64-unknown-linux-gnu cargo build --release --target aarch64-unknown-linux-gnu - if: matrix.job == 'native-test' run: cargo test --release ``` Both legs start together. Generally available Linux and Windows runners do not have plan-level concurrency caps, so the matrix width is a scheduling choice rather than a quota question. ### [Labels, sizes, and rates](#labels-sizes-and-rates)Copy Anchor Link | Label | vCPU | RAM | Storage | Per minute | | ---------------------------- | ---- | ----- | --------- | ---------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 | | warp-ubuntu-latest-arm64-2x | 2 | 8GB | 150GB SSD | $0.003 | | warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 | | warp-ubuntu-latest-arm64-8x | 8 | 32GB | 150GB SSD | $0.012 | | warp-ubuntu-latest-arm64-16x | 16 | 64GB | 150GB SSD | $0.024 | | warp-ubuntu-latest-arm64-32x | 32 | 128GB | 150GB SSD | $0.048 | The Ubuntu 24.04 ARM64 image sets the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/`, so read `$GITHUB_WORKSPACE` in any script that currently hardcodes a path. Ubuntu 26.04 labels use the same sizes and rates under `warp-ubuntu-2604-arm64-`. Full platform coverage sits in the [cloud runners documentation](/docs/ci/cloud-runners), and the drop-in cache action used above is documented in the [caching documentation](/docs/ci/features/caching). Cache keys carry the architecture through `runner.arch` for a reason. Cache entries are keyed by a hash covering the runner OS compression tool and the cached paths, so an entry written on an x64 label does not restore on an ARM64 label. Without the key split, the two legs invalidate each other on every run. When a test passes on the cross-built path and fails on the native one, the Action Debugger pauses the workflow and opens an SSH session on the ARM64 machine so you can run the binary by hand, and CI observability correlates runner system metrics with the job logs. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Assumptions](#assumptions)Copy Anchor Link * 1,000 workflow runs per month, which is 50 runs per weekday across 20 weekdays. * One ARM64 artifact per run. * Cross path: `warp-ubuntu-latest-x64-8x` at $0.016 per minute, for `C` minutes. * Native path: `warp-ubuntu-latest-arm64-8x` at $0.012 per minute, for `N` minutes. * Billing is per minute of runner time. * `C` and `N` come from your own run history. The values below show the arithmetic. ### [Monthly cost at 1,000 runs](#monthly-cost-at-1000-runs)Copy Anchor Link Monthly cost on the cross path is `1000 * C * $0.016`, which is `$16.00 * C`. Monthly cost on the native path is `1000 * N * $0.012`, which is `$12.00 * N`. | Cross minutes C | Cross monthly cost | Native minutes N | Native monthly cost | Same N on the GitHub-hosted 8-core Linux ARM64 runner at $0.014 | | --------------- | ------------------ | ---------------- | ------------------- | --------------------------------------------------------------- | | 4 | $64.00 | 5 | $60.00 | $70.00 | | 6 | $96.00 | 7 | $84.00 | $98.00 | | 10 | $160.00 | 12 | $144.00 | $168.00 | | 18 | $288.00 | 24 | $288.00 | $336.00 | The last row is the break-even. Setting `0.016 * C` equal to `0.012 * N` gives `N = 1.33 * C`, so the native path costs less on the invoice unless the native job needs more than four minutes for every three cross-compiled minutes. That margin absorbs a native build that lands slower than the cross build on a specific workload. ### [The split shape](#the-split-shape)Copy Anchor Link The split shape adds a native test job to the cross build. At 1,000 runs with `C` of 6 on `warp-ubuntu-latest-x64-8x` and a 5 minute test job on `warp-ubuntu-latest-arm64-4x` at $0.006 per minute, the monthly total is `1000 * (6 * 0.016 + 5 * 0.006)`, which is $126.00\. The native-only shape at `N` of 7 costs $84.00\. The split shape buys the cross toolchain nothing over native-only here, which is the usual result once a native runner is on the table. ### [List-price anchors](#list-price-anchors)Copy Anchor Link GitHub publishes its own per-minute rates on the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Size | WarpBuild x64 | GitHub-hosted x64 same core count | WarpBuild ARM64 | GitHub-hosted Linux ARM64 same core count | | ---- | ------------- | --------------------------------- | --------------- | ----------------------------------------- | | 2x | $0.004 | $0.006 | $0.003 | $0.005 | | 4x | $0.008 | $0.012 | $0.006 | $0.008 | | 8x | $0.016 | $0.022 | $0.012 | $0.014 | | 16x | $0.032 | $0.042 | $0.024 | $0.026 | | 32x | $0.064 | $0.082 | $0.048 | $0.050 | At the 8 vCPU size the cross-compiling job is $0.016 per minute against $0.022 for the GitHub-hosted 8-core Linux runner, a 27 percent lower list price, and the native job is $0.012 against $0.014, a 14 percent lower list price (GitHub pricing, checked on 2026-08-13). The full rate sheet, including cache and snapshot line items, is on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link ### [When is cross compiling to ARM64 good enough?](#when-is-cross-compiling-to-arm64-good-enough)Copy Anchor Link When the build links no native code, the test suite exercises no architecture-specific behaviour, and the toolchain publishes a supported target triple. A pure Go binary with `CGO_ENABLED=0` and a pure Rust crate graph both fit. The compiler emits aarch64 machine code on the x64 host and the artifact ships. ### [What does cross compiling hide that a native ARM64 job catches?](#what-does-cross-compiling-hide-that-a-native-arm64-job-catches)Copy Anchor Link Everything that only appears at execution time. Weak memory ordering on aarch64 surfaces races that x86-64 store ordering hides, `char` defaults to unsigned on ARM64, fused multiply-add changes last-bit float results, and a sysroot glibc newer than the deployment target produces a binary that fails to start. None of these stop the cross build. ### [Can I cross compile the artifact and still test on ARM64?](#can-i-cross-compile-the-artifact-and-still-test-on-arm64)Copy Anchor Link Yes, and it is the usual middle path. Build on `warp-ubuntu-latest-x64-8x` at $0.016 per minute, upload the binary as an artifact, then download and run the test suite on `warp-ubuntu-latest-arm64-4x` at $0.006 per minute. The test job proves the artifact runs on the architecture you ship to. ### [Does a native ARM64 runner cost more than cross compiling on x64?](#does-a-native-arm64-runner-cost-more-than-cross-compiling-on-x64)Copy Anchor Link The ARM64 label carries a lower per-minute rate at every size. `warp-ubuntu-latest-arm64-8x` is $0.012 per minute against $0.016 for `warp-ubuntu-latest-x64-8x`, so the native path costs less on the invoice unless the native job needs more than four minutes for every three cross-compiled minutes. --- # Triggering Workflows Across Repositories | WarpBuild URL: https://www.warpbuild.com/guides/cross-repository-workflows-github-actions Description: Two ways to trigger a workflow in another repository: a repository dispatch event or a workflow dispatch API call. Token permissions, refs, and cost. # Triggering Workflows Across Repositories Two ways to trigger a workflow in another repository: a repository dispatch event or a workflow dispatch API call. Token permissions, refs, and cost. GitHub exposes two ways to start a workflow in another repository: a repository dispatch event, `POST /repos/{owner}/{repo}/dispatches`, which fires a named event that any workflow on the target's default branch can listen for, and a workflow dispatch, `POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches`, which names one workflow file, one `ref`, and typed inputs. Both calls need a credential that reaches across the repository boundary, because `GITHUB_TOKEN` is limited to the repository whose workflow is running ([triggering a workflow from a workflow](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/trigger-a-workflow), checked on 2026-08-13). This guide covers how to tell the two mechanisms apart, the token permission each endpoint requires, workflow YAML for the calling side and the receiving side, the two failure modes that look like success, and what the caller costs when it waits for the downstream run. ## [Diagnosis](#diagnosis)Copy Anchor Link Both endpoints answer `204 No Content` with an empty body. There is no run id in the response, so a dispatch that started nothing and a dispatch that started the wrong copy of a workflow both read as success from the calling job. Start from the response code and the target repository's Actions tab together. | What you see | What happened | Where to look | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | 204, no run appears in the target | No workflow on the target's default branch declares that event\_type under on: repository\_dispatch: types: | The default branch copy of the workflow file rather than your branch | | 404 | The token cannot see the repository at all: a fine-grained token without that repository selected, or an app that is not installed on it | The token's repository access list, or the app installation | | 403 | The token authenticates but lacks the permission the endpoint requires | The permission table below | | 422 | Workflow dispatch only: the file at that ref has no workflow\_dispatch trigger, or the inputs you sent do not match the ones it declares | The workflow file as it exists on the dispatched ref | | A run appears, on the wrong branch | Repository dispatch always runs the default branch copy | The run's branch field | ### [The two mechanisms side by side](#the-two-mechanisms-side-by-side)Copy Anchor Link | Repository dispatch | Workflow dispatch | | | ----------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- | | Endpoint | POST /repos/{owner}/{repo}/dispatches | POST /repos/{owner}/{repo}/actions/workflows/{workflow\_id}/dispatches | | What you address | An event name, matched by every listening workflow | One workflow file, by filename or id | | Branch that runs | Always the target's default branch | The ref you send, which is required | | Payload | client\_payload, free-form JSON, up to 10 top-level properties | inputs, up to 10, declared in the workflow file | | Reading it | github.event.client\_payload.\* | inputs.\*, typed as declared | | Filtering | types: list in the on: block | You picked the file, so no filter | | Fine-grained token permission | Contents: write | Actions: write | | Fits | Fan-out, where several workflows react to one upstream event | A named job with a contract, where you want typed arguments | Endpoint shapes, limits, and permissions come from the [repository dispatch reference](https://docs.github.com/en/rest/repos/repos#create-a-repository-dispatch-event) and the [workflow dispatch reference](https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event), checked on 2026-08-13\. The trigger semantics are in [events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#repository%5Fdispatch), and the input types a receiving workflow can declare are `string`, `choice`, `boolean`, `number`, and `environment` ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onworkflow%5Fdispatchinputs)). The [workflow dispatch glossary entry](/glossary/workflow-dispatch) carries the trigger definition on its own. ### [Failure mode one: a token with the wrong permission](#failure-mode-one-a-token-with-the-wrong-permission)Copy Anchor Link The two endpoints want different permissions, which surprises teams who wire the second one after the first already works. A fine-grained personal access token or GitHub App installation token needs Contents: write to post a repository dispatch, and Actions: write to post a workflow dispatch. A classic personal access token needs the `repo` scope. Granting one and calling the other returns 403 with no hint about which permission is missing, and a token that simply does not list the target repository returns 404 instead, because GitHub does not confirm the existence of repositories a credential cannot see. ### [Failure mode two: a dispatch that lands on the wrong branch](#failure-mode-two-a-dispatch-that-lands-on-the-wrong-branch)Copy Anchor Link Repository dispatch has no `ref` parameter, and the run uses the workflow file on the target's default branch. A payload field named `ref` changes nothing by itself. It only matters if the receiving workflow reads it and checks it out. Workflow dispatch takes `ref`, which makes the branch explicit in the API call, and `gh workflow run` falls back to the repository's default branch when `--ref` is omitted. The version of the workflow file that runs is the version on the dispatched ref, so a ref that predates a new input produces a 422 while a ref that predates a bug fix produces a green run of stale logic. ## [Fix](#fix)Copy Anchor Link ### [Choose the mechanism before writing YAML](#choose-the-mechanism-before-writing-yaml)Copy Anchor Link Reach for a workflow dispatch when the caller knows exactly which downstream job it wants and wants to pass arguments with types. Reach for a repository dispatch when one upstream event should be visible to several receivers and you would rather not maintain a list of workflow filenames in the caller. If the two repositories share build steps rather than a trigger, neither endpoint is the answer: see [reusable workflows at scale](/guides/reusable-workflows-at-scale-github-actions). The manual path for the same trigger is covered in [how to run a workflow manually](/answers/how-do-i-run-a-workflow-manually). ### [Mint a credential that crosses the boundary](#mint-a-credential-that-crosses-the-boundary)Copy Anchor Link A long-lived personal access token in an organization secret works and ages badly, because it carries a person's access and outlives their team membership. A [GitHub App](/glossary/github-app) installed on the target repositories is the better shape: `actions/create-github-app-token` exchanges the app id and private key for an installation token at the start of the run, the token expires an hour later, and its access is the installation rather than a user. Scope the installation to the target repositories only, grant Actions: write for workflow dispatch or Contents: write for repository dispatch, and grant nothing else. ### [Pin the ref on purpose](#pin-the-ref-on-purpose)Copy Anchor Link Send `ref` explicitly on every workflow dispatch, even when the value is `main`, so the call fails loudly against a branch that no longer exists rather than quietly running something else. For repository dispatch, pass the ref inside `client_payload` and check it out on the receiving side, since the workflow file itself always comes from the default branch. ### [Make the run identifiable](#make-the-run-identifiable)Copy Anchor Link Pass a correlation id built from `github.run_id` and the matrix index, and echo it into `run-name` on the receiving workflow. That turns an unidentifiable 204 into a run you can find, watch, and attribute. ### [Where the dispatch job itself runs](#where-the-dispatch-job-itself-runs)Copy Anchor Link The calling job is a token exchange plus a few HTTPS requests, so it wants the smallest runner in the catalog rather than a build shape. A fan-out job that holds a cross-repository token belongs on Linux at 2 vCPU. Each WarpBuild runner runs in its own virtual machine, created on demand and destroyed after the build, and build secrets stay in your repository rather than with the runner provider ([security documentation](/docs/ci/security)). ## [Configuration](#configuration)Copy Anchor Link The calling side mints an installation token, then fans out over a matrix of target repositories. All values in the `inputs` object go over the wire as JSON strings; the receiving workflow applies the declared type inside the run. Copy Text ``` name: release-fanout on: workflow_dispatch: inputs: version: description: Version tag to roll out required: true type: string jobs: dispatch: runs-on: warp-ubuntu-latest-x64-2x strategy: matrix: target: - acme/payments-service - acme/search-service - acme/web-frontend steps: - uses: actions/create-github-app-token@v2 id: app-token with: app-id: ${{ vars.RELEASE_BOT_APP_ID }} private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} owner: acme repositories: payments-service,search-service,web-frontend - name: Dispatch the downstream release workflow env: GH_TOKEN: ${{ steps.app-token.outputs.token }} TARGET: ${{ matrix.target }} VERSION: ${{ inputs.version }} CORRELATION: ${{ github.run_id }}-${{ strategy.job-index }} run: | jq -n \ --arg version "$VERSION" \ --arg cid "$CORRELATION" \ '{ref: "main", inputs: {version: $version, correlation_id: $cid, environment: "production", dry_run: "false"}}' \ | gh api "repos/$TARGET/actions/workflows/release.yml/dispatches" \ --method POST --input - ``` The same job posts a repository dispatch instead when the receivers are a set rather than a list: Copy Text ``` - name: Announce the release to every listener env: GH_TOKEN: ${{ steps.app-token.outputs.token }} TARGET: ${{ matrix.target }} run: | jq -n \ --arg version "${{ inputs.version }}" \ --arg sha "$GITHUB_SHA" \ '{event_type: "upstream-release", client_payload: {version: $version, sha: $sha, ref: "refs/heads/main"}}' \ | gh api "repos/$TARGET/dispatches" --method POST --input - ``` The receiving repository declares typed inputs and accepts both triggers: Copy Text ``` name: release run-name: release ${{ inputs.version }} (${{ inputs.correlation_id }}) on: workflow_dispatch: inputs: version: required: true type: string correlation_id: required: true type: string environment: type: choice default: staging options: [staging, production] dry_run: type: boolean default: true repository_dispatch: types: [upstream-release] jobs: release: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.client_payload.ref || github.ref }} - name: Resolve the version from either trigger id: args run: | echo "version=${{ inputs.version || github.event.client_payload.version }}" >> "$GITHUB_OUTPUT" - if: ${{ inputs.dry_run == false }} run: ./scripts/release.sh "${{ steps.args.outputs.version }}" "${{ inputs.environment }}" ``` Two details in that file earn their keep. The `inputs` context preserves the declared types, so `inputs.dry_run == false` is a boolean comparison, while `github.event.inputs` would hand back the string `"false"`. And the checkout falls back to `github.ref` because a repository dispatch run has an empty `inputs` context and its own payload shape. The caller can wait for the downstream run when it needs the result, using the correlation id it sent: Copy Text ``` run_id=$(gh run list --repo "$TARGET" --workflow release.yml \ --json databaseId,displayTitle --limit 20 \ --jq "map(select(.displayTitle | contains(\"$CORRELATION\"))) | .[0].databaseId") gh run watch "$run_id" --repo "$TARGET" --exit-status ``` ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Waiting is the only expensive part of a cross-repository trigger. The dispatch call itself takes a fraction of a minute; a job that blocks until the downstream run finishes bills for the whole downstream duration on the caller's side as well. ### [Assumptions](#assumptions)Copy Anchor Link | Input | Value | Source | | ------------------------------- | --------------------------------------------------------- | -------------------------------------------- | | Target repositories per release | 8 | The fan-out above, extended | | Releases per month | 40 | Your workflow run history | | Downstream run duration | 11 minutes | The target repository's run history | | Caller work per dispatch | 0.6 minutes for checkout, token exchange, and 8 API calls | Your run log | | Runner rate | $0.004 per minute on warp-ubuntu-latest-x64-2x | [pricing page](/pricing), checked 2026-08-13 | ### [Three designs, same triggers](#three-designs-same-triggers)Copy Anchor Link | Design | Caller minutes per release | Caller minutes per month | Caller cost per month | | ----------------------------------------- | -------------------------- | ------------------------ | --------------------- | | Fire and forget, no waiting | 0.6 | 24 | $0.10 | | One waiting job per target, 8 in a matrix | 92.8 | 3,712 | $14.85 | | One job polling all 8 targets in parallel | 11.6 | 464 | $1.86 | Arithmetic for the middle row: 11 minutes of waiting plus 0.6 minutes of work is 11.6 minutes per target, multiplied by 8 targets is 92.8 minutes per release, multiplied by 40 releases is 3,712 minutes, multiplied by $0.004 is $14.85\. The third row runs the same waits inside one job, so the caller bills 11.6 minutes per release rather than 92.8. ### [Size the waiting job down rather than up](#size-the-waiting-job-down-rather-than-up)Copy Anchor Link A polling loop sleeps. Running it on a larger label buys nothing and multiplies the bill, and the rates make the gap concrete. | Label | vCPU | RAM | Rate per minute | | ------------------------- | ---- | ----- | --------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | Rates come from the [pricing page](/pricing) and the cloud runner catalog, checked on 2026-08-13\. The 3,712 waiting minutes from the middle row cost $14.85 a month on the 2x label and $59.39 a month on `warp-ubuntu-latest-x64-8x`, for a job whose busiest instruction is `sleep`. One more limit shapes the design at scale: dispatch calls count against the REST rate limit, which is 5,000 requests per hour for a personal access token ([rate limits reference](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api), checked on 2026-08-13). Eight calls per release is nothing; a matrix that dispatches per commit across two hundred repositories is worth counting first. For where this sits against the rest of the pipeline, see the guide to [speeding up GitHub Actions](/guides/speed-up-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [Can GITHUB\_TOKEN trigger a workflow in another repository?](#can-github%5Ftoken-trigger-a-workflow-in-another-repository)Copy Anchor Link No. `GITHUB_TOKEN` is scoped to the repository whose workflow is running, so a cross-repository dispatch needs a fine-grained personal access token or a GitHub App installation token that has the target repository in its scope. Inside one repository, `GITHUB_TOKEN` can fire `workflow_dispatch` and `repository_dispatch`, which are the two documented exceptions to the rule that events triggered by `GITHUB_TOKEN` do not create a new workflow run ([triggering a workflow from a workflow](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/trigger-a-workflow), checked on 2026-08-13). ### [Why did my repository dispatch run on the default branch instead of my branch?](#why-did-my-repository-dispatch-run-on-the-default-branch-instead-of-my-branch)Copy Anchor Link Because that is the documented behavior. A `repository_dispatch` run uses the workflow file on the target repository's default branch, and the endpoint takes no `ref` parameter ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#repository%5Fdispatch), checked on 2026-08-13). Put the ref in `client_payload` and check it out on the receiving side, or switch to a workflow dispatch, whose `ref` field is required. ### [How do I get the run id of the workflow I triggered?](#how-do-i-get-the-run-id-of-the-workflow-i-triggered)Copy Anchor Link Neither endpoint returns one. Both answer `204 No Content` with an empty body, so pass a correlation id as an input, echo it into `run-name` on the receiving workflow, and poll `gh run list` for the run whose display title carries that id. The snippet in the configuration section above does exactly that before handing off to `gh run watch`. --- # Custom Runner Images for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/custom-runner-images-github-actions Description: Bake your toolchain into a cloud VM image, register it on a WarpBuild BYOC stack, and route GitHub Actions jobs to it with a warp-custom- runner label. # Custom Runner Images for GitHub Actions Bake your toolchain into a cloud VM image, register it on a WarpBuild BYOC stack, and route GitHub Actions jobs to it with a warp-custom- runner label. Last verified: 2026-08-13 A custom runner image for GitHub Actions is a VM image you build in your own cloud account and register with WarpBuild, so a job starts on a machine that already carries your toolchain instead of installing it step by step. On WarpBuild this is a BYOC capability: you build the image, add it from the custom images page, attach it to a custom runner, and reference that runner in `runs-on` under its `warp-custom-` Runner ID. This guide covers when a custom image earns its maintenance, what the image has to contain, how the pre-job and post-job hooks work, and what the change does to your bill. The cloud account setup that has to exist first is on the [AWS BYOC page](/byoc/aws), and the requirement list is maintained in the [custom VM images documentation](/docs/ci/byoc/custom-vm-images). ## [Diagnosis](#diagnosis)Copy Anchor Link Every job that installs a tool pays for that install on every run. A custom image moves the install to build time, where it happens once per image version rather than once per job. The question is whether the minutes you reclaim are worth the pipeline you now maintain. ### [Symptoms that point at a custom image](#symptoms-that-point-at-a-custom-image)Copy Anchor Link * The same package installs sit at the top of several workflows: `apt-get install`, `choco install`, a vendor CLI tarball, a JDK or .NET SDK that the base image does not carry. * A setup action downloads the same toolchain archive on every run because the tool cache in the base image does not hold that version. * An install step reaches a private registry and needs a credential fetch before it can run, which adds a second failure mode to a step that produces no build output. * Install steps fail on network flakes. A registry timeout marks a job red for reasons that have nothing to do with the change under test. * Tool versions drift between workflows because each one pins its own install line, and a bug reproduces on one job and not another. ### [What belongs in the image](#what-belongs-in-the-image)Copy Anchor Link Sort each step by whether it changes with the commit. | Step in the job | Changes per commit | Where it belongs | | ------------------------------------------------------ | ------------------ | ---------------- | | Base OS packages such as curl, wget, jq, build tooling | No | Image | | Pinned language toolchain or SDK | Only on upgrade | Image | | Vendor CLI or internal toolchain tarball | Only on release | Image | | Certificates, CA bundles, registry configuration | Rarely | Image | | Project dependencies resolved from a lockfile | Yes | Cache | | The application build and test steps | Yes | Job | Dependencies from a lockfile stay out of the image. They change too often to bake, and the cache handles them. Baking them produces an image rebuild on every dependency bump and a stale layer between rebuilds. ### [The break-even in install minutes per run](#the-break-even-in-install-minutes-per-run)Copy Anchor Link Take the arithmetic in two parts: what the image pipeline costs to run, and what the installs cost to keep. An image build job that takes 12 minutes on `warp-ubuntu-2404-x64-4x` at $0.008 per minute costs $0.096 per rebuild. Rebuilt weekly, that is about $0.42 per month of runner fees ([pricing page](/pricing), checked on 2026-08-13). Now the other side. At 1,200 workflow runs per month, one install minute per run is 1,200 runner minutes per month. Setting the two equal gives the break-even: * Against the BYOC Linux fee of $0.002 per minute: `0.42 / (1200 * 0.002)` is about **0.18 install minutes per run**, roughly 11 seconds. * Against a hosted 4 vCPU rate of $0.008 per minute: `0.42 / (1200 * 0.008)` is about **0.04 install minutes per run**, roughly 3 seconds. Because the fee break-even lands in seconds, maintenance minutes decide the question. The image pipeline needs an owner, a version scheme, a rebuild trigger when the base AMI gets security updates, and a rollback path. Teams that run this well usually set a practical bar higher than the fee break-even: bake when the installs you would remove run longer than about 90 seconds per job, or when the install step is a recurring source of red builds regardless of its length. Two cases still argue against a custom image. If the tooling you need already ships in the WarpBuild runner images, changing the label is the cheaper move, and the hosted catalog covers Linux x64, Linux ARM64, macOS, and Windows runners. If the slow part is machine start rather than tool install, look at [standby disks](/byoc/standby-disks) and the [cold start guide](/guides/github-actions-cold-starts) first, because a fatter image does nothing for boot time. ## [Fix](#fix)Copy Anchor Link The loop has five steps. Steps 1 and 2 are one-time per cloud account. 1. Connect your cloud account and create a WarpBuild Stack, which fixes the region, VPC, and object storage the runners use. BYOC runs on AWS, GCP, and Azure. 2. Pick a base image the requirements support, then bake your toolchain onto it with Packer or your existing image tooling. 3. Build the image in your own account and tag it with a version. 4. Add the image from the custom images page in the dashboard. Images in the region of your stack are listed there. 5. Create a custom runner that uses the image, then reference the Runner ID in `runs-on`. ### [Bake the image](#bake-the-image)Copy Anchor Link A Packer template keeps the image reproducible and diffable in review. The shape below starts from an Ubuntu 24.04 base and installs the packages the runner agent needs, plus whatever your jobs were installing per run. Copy Text ``` locals { version = "1.4.0" } source "amazon-ebs" "ci-base" { region = "us-east-1" instance_type = "t3.micro" ami_name = "ci-base-v${local.version}" source_ami_filter { filters = { name = "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.*-amd64-server-*" root-device-type = "ebs" } owners = ["099720109477"] most_recent = true } ssh_username = "ubuntu" } build { sources = ["source.amazon-ebs.ci-base"] provisioner "shell" { inline = [ "sudo apt-get update", "sudo apt-get install -y curl wget jq unzip git libicu-dev", "sudo mkdir -p /opt/hostedtoolcache", "sudo chown -R runner:runner /opt/hostedtoolcache || true", ] } } ``` ### [Build it on a WarpBuild runner](#build-it-on-a-warpbuild-runner)Copy Anchor Link The image build is itself a GitHub Actions job, so it runs on a normal runner label and leaves an audit trail in the Actions history. .github/workflows/ci-image.yml Copy Text ``` name: build ci base image on: workflow_dispatch: push: branches: [main] paths: - "packer/ci-base/**" permissions: contents: read id-token: write jobs: build-ami: runs-on: warp-ubuntu-2404-x64-4x steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::ACCOUNT_ID:role/ci-image-builder role-session-name: build-ci-base aws-region: us-east-1 - uses: hashicorp/setup-packer@v3 with: version: 1 - run: packer plugins install github.com/hashicorp/amazon - run: packer validate packer/ci-base/ci-base.pkr.hcl - run: packer build packer/ci-base/ci-base.pkr.hcl ``` ### [Point workflows at the custom runner](#point-workflows-at-the-custom-runner)Copy Anchor Link Once the image is attached to a custom runner, the workflow change is one line. The Runner ID is the full name prefixed with `warp-custom-`, and the prefix is required. .github/workflows/ci.yml Copy Text ``` jobs: build: runs-on: warp-custom-ci-base-4x steps: - uses: actions/checkout@v4 - run: make build - run: make test ``` The install steps that used to sit above `make build` come out of the workflow in the same pull request. Keep the first migrated workflow on a branch for a week of real traffic before widening. When a job fails on the new image in a way the logs do not explain, the Action Debugger pauses the workflow and opens an SSH session on the runner, and CI observability correlates runner system metrics with the job logs. ## [Configuration](#configuration)Copy Anchor Link ### [Linux image requirements](#linux-image-requirements)Copy Anchor Link The distro the image is based on has to use `systemd`, because WarpBuild relies on it to run its agent. Ubuntu and Amazon Linux 2023 both qualify. These packages must be present in the image: | Package | Why it is required | | ------- | ------------------------------------------------------------------ | | curl | Artifact and agent downloads | | wget | Artifact and agent downloads | | bash | Hook and setup script execution | | jq | Runner orchestration | | libicu | Required by the GitHub Actions runner as a .NET runtime dependency | These come by default on every supported distro and must stay in the image: `tar`, `gzip`, `coreutils` (which provides `id`, `chpasswd`, `chown`, `tr`, and `sed`), `shadow-utils` (which provides `useradd` and `usermod`), and `systemd`. A minimal or hardened base that strips them produces a runner that registers and then fails on the first job. The package name for `libicu` varies by distro version, so resolve it against your base image rather than copying a name across distros. ### [Windows image requirements](#windows-image-requirements)Copy Anchor Link Windows AMIs add three requirements. * `aria2` must be present and reachable through the system `PATH`. WarpBuild uses it to download the artifacts the runner needs, because the default Windows download path is slow. * On AWS, the EC2 instance must be sysprepped before you make a Windows image out of it. Both the EC2Launch GUI path and the Packer automation path are documented. * Runners execute under the `runneradmin` user, the same user GitHub's Windows runners use. If that user is not present, it gets added. User-scoped environment variables set under another account are invisible to the runner, so move them to machine level or set them in the `runneradmin` environment. ### [Scripts before and after each job](#scripts-before-and-after-each-job)Copy Anchor Link GitHub self-hosted runners read `ACTIONS_RUNNER_HOOK_JOB_STARTED` and `ACTIONS_RUNNER_HOOK_JOB_COMPLETED`. WarpBuild already uses both internally to orchestrate runners for your jobs, so a custom image sets the `WARPBUILD_` prefixed variables instead: * `WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_STARTED` runs before each job. * `WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_COMPLETED` runs after each job. Set them system-wide on the image so the runner agent picks them up at start. On Linux, add them to `/etc/environment`: Copy Text ``` WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_STARTED=/opt/ci/pre-job.sh WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/opt/ci/post-job.sh ``` On Windows, set them as machine-level environment variables: Copy Text ``` [Environment]::SetEnvironmentVariable( 'WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_STARTED', 'C:\ci\pre-job.ps1', 'Machine' ) ``` The rules match GitHub's native hooks: 1. The script must end in `.sh` or `.ps1`. A `.sh` script runs with `bash --noprofile --norc -e -o pipefail`, falling back to `sh -e` when bash is unavailable. A `.ps1` script is dot-sourced with `pwsh -command`, falling back to `powershell -command`. 2. The path must be absolute. 3. The pre-hook runs after WarpBuild's own pre-hook and before the job. The post-hook runs after the job completes, whether the job passed or failed. 4. A script that is missing, carries an unsupported extension, or exits non-zero fails the job. A failing pre-hook means the job never starts. Treat the hook script as production code and test it in the image build. 5. Permission errors usually mean the script is not executable. `chmod +x /opt/ci/pre-job.sh` during the image build. ### [Where the image runs](#where-the-image-runs)Copy Anchor Link Custom VM images are a BYOC capability, so the image runs on instances in your cloud account under a custom runner configuration you own. That configuration also carries the instance types, disks, and IP settings. Listing several instance types in one runner configuration gives the runner a fallback when capacity for the first type is unavailable in the region, which matters most on spot. The rest of the platform behaves the same on a custom image. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts dynamically, so a fleet on your own image scales with the queue rather than against a ceiling. The setup path itself is documented in the [BYOC documentation](/docs/ci/byoc), and the wider tradeoff between the hosted fleet and instances in your own account is worked through in [hosted runners versus BYOC runners](/guides/hosted-vs-byoc-runners). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link There is no additional cost for using custom VM images. The line items are the BYOC runner fee, your own cloud bill for the instances, and the runner minutes the image pipeline consumes. | Path | Label shape | WarpBuild fee per minute | Compute billed by | | -------------------------------- | -------------------------- | ------------------------ | ------------------ | | Hosted Linux x64, 4 vCPU, 16GB | warp-ubuntu-latest-x64-4x | $0.008 | WarpBuild | | Hosted Linux x64, 8 vCPU, 32GB | warp-ubuntu-latest-x64-8x | $0.016 | WarpBuild | | Hosted Windows x64, 4 vCPU, 16GB | warp-windows-latest-x64-4x | $0.016 | WarpBuild | | BYOC Linux, custom image | warp-custom- | $0.002 | Your cloud account | | BYOC Windows, custom image | warp-custom- | $0.002 | Your cloud account | Rates from the [pricing page](/pricing), checked on 2026-08-13\. BYOC add-ons such as cache and networking are included in the BYOC fee. ### [Worked model](#worked-model)Copy Anchor Link Assumptions: 1,200 workflow runs per month, one job per run, `I` install minutes removed per run, a 12 minute image build rebuilt weekly at $0.008 per minute. | Install minutes removed per run (I) | Runner minutes removed per month | Fee removed at $0.008 per minute | Fee removed at $0.002 per minute | Image pipeline fee per month | | ----------------------------------- | -------------------------------- | -------------------------------- | -------------------------------- | ---------------------------- | | 0.5 | 600 | $4.80 | $1.20 | $0.42 | | 1.5 | 1,800 | $14.40 | $3.60 | $0.42 | | 3 | 3,600 | $28.80 | $7.20 | $0.42 | | 6 | 7,200 | $57.60 | $14.40 | $0.42 | Measure `I` from your own step timings rather than the placeholder rows. The fee column understates the BYOC case, because instance time in your account comes out at the same rate whether the minute is spent installing tools or compiling code, so removing install minutes removes cloud spend as well as the WarpBuild fee. The time column is the one engineers feel. At `I` of 3 and 1,200 runs, the change gives back 3,600 runner minutes per month, and 3 minutes of every developer's wait on every pull request. ### [What the change does not add](#what-the-change-does-not-add)Copy Anchor Link SSO is available for a flat $250 per month, whatever the user count, and it is the one line item on this page that does not move with usage. ## [FAQ](#faq)Copy Anchor Link ### [Does WarpBuild charge extra for custom VM images?](#does-warpbuild-charge-extra-for-custom-vm-images)Copy Anchor Link No. There is no additional cost for using custom VM images. You pay the BYOC runner fee of $0.002 per minute for Linux and $0.002 per minute for Windows, and your own cloud account bills the instance, disk, and data transfer at your rates. ### [Which images do the documented custom image requirements cover?](#which-images-do-the-documented-custom-image-requirements-cover)Copy Anchor Link AMIs, Linux and Windows. The documented requirements describe what an AMI must carry to run WarpBuild runners on AWS. BYOC itself runs on AWS, GCP, and Azure, so check the [custom VM images documentation](/docs/ci/byoc/custom-vm-images) before assuming the same requirement list applies to another cloud. ### [Why does ACTIONS\_RUNNER\_HOOK\_JOB\_STARTED have no effect on a WarpBuild runner?](#why-does-actions%5Frunner%5Fhook%5Fjob%5Fstarted-have-no-effect-on-a-warpbuild-runner)Copy Anchor Link WarpBuild already uses the GitHub-prefixed hook variables internally to orchestrate runners for your jobs. Set `WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_STARTED` and `WARPBUILD_ACTIONS_RUNNER_HOOK_JOB_COMPLETED` instead, system-wide on the image, with absolute paths to scripts ending in `.sh` or `.ps1`. ### [How do I reference a custom runner image in a workflow?](#how-do-i-reference-a-custom-runner-image-in-a-workflow)Copy Anchor Link Through the Runner ID of the custom runner that uses the image. The Runner ID is the full name prefixed with `warp-custom-`, and it goes in `runs-on` the same way any other runner label does. --- # How to Debug Failed GitHub Actions Jobs | WarpBuild URL: https://www.warpbuild.com/guides/debug-github-actions-failures Description: Debug a failed GitHub Actions job by classifying the failure, reading per-instance runner metrics, then opening an SSH session on the runner itself. # How to Debug Failed GitHub Actions Jobs Debug a failed GitHub Actions job by classifying the failure, reading per-instance runner metrics, then opening an SSH session on the runner itself. Last verified: 2026-08-13 A failed GitHub Actions job is debugged fastest by classifying the failure before changing any code, then reproducing it on the runner itself rather than through a loop of re-runs. WarpBuild supplies two tools for that: the Action Debugger, which pauses a workflow and opens an SSH session on the running runner after you add one line to the job, and CI observability, which shows per-instance CPU, memory, filesystem, disk I/O, and network metrics beside the GitHub Actions logs for the same job. This guide covers the five failure classes worth separating, how to read the Observability Usage view to tell a resource failure from a logic failure, the exact workflow change that opens a debug session, the GitHub setting that has to be on first, and a worked time and cost model for the interactive session against the re-run-and-guess loop. ## [Diagnosis](#diagnosis)Copy Anchor Link Answer one question before reading a single stack trace: did the machine run out of something, or did the code and configuration do the wrong thing? Those two answers lead to different fixes, and the job log alone often cannot separate them. A step killed by the kernel out-of-memory killer prints no Python traceback and no Go panic. It prints nothing, and the job log shows a step that ended with exit code 137. Five failure classes cover the large majority of red GitHub Actions runs. Sort the failure into one of them first. | Failure class | What the job log shows | What confirms it | | ---------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | | Environment drift | The job passes locally and fails on the runner, often on a version-sensitive step | Tool versions, locale, timezone, and paths printed on the runner differ from the local machine | | Missing tooling in the image | command not found, No such file or directory on a binary, a package manager reaching the network | The binary is absent from the runner image and no setup step installs it | | Resource exhaustion | Exit code 137, Killed, no space left on device, a step that stops mid-output | Memory or filesystem utilization at the ceiling in the Observability Usage view at the failing timestamp | | Flaky ordering | The same commit passes on re-run, and failures move between tests | Two runs of the same SHA disagree, or the failure follows test shard order | | Secrets and permissions | Resource not accessible by integration, HTTP 401 or 403, an empty variable | The token scope, the permissions block, or the fork status of the pull request explains the denial | ### [Environment drift between the local machine and the runner](#environment-drift-between-the-local-machine-and-the-runner)Copy Anchor Link Drift is the class that produces the most wasted re-runs, because the failing command works on the laptop of the person debugging it. The usual sources are compiler and runtime versions, a lockfile that resolves differently under a different package manager version, a case-sensitive filesystem on the runner against a case-insensitive one locally, and a different default working directory. Working directory is worth checking explicitly. WarpBuild Ubuntu 24.04 ARM64 runners set the work directory to `/runner/_work`, while GitHub-hosted runners use `/home/runner/work/`. Any script with a hardcoded path breaks on that difference alone, and the error it produces looks like a missing file rather than a path assumption. ### [Missing tooling in the image](#missing-tooling-in-the-image)Copy Anchor Link WarpBuild runner images carry the same tooling as GitHub-hosted runner images, so a binary that is missing on a WarpBuild runner is usually missing on GitHub-hosted runners too. That narrows the fix: the workflow needs a setup step or an install step, and the difference between the two matters for cache behavior on later runs. Two platform limits produce failures that read as missing tooling. macOS runners do not support nested virtualization and cannot run Docker, so a Docker step in a macOS job fails at the daemon connection rather than at the image pull. Nested virtualization on Linux x64 runners is opt-in through the `nested-virtualization.enabled=true` label, so an Android emulator job that needs `/dev/kvm` fails until the label is added. ### [Resource exhaustion](#resource-exhaustion)Copy Anchor Link Resource exhaustion is the class most often misdiagnosed as flakiness, because it depends on machine size rather than on code. A test suite that runs four Jest workers on a 2 vCPU, 8 GB runner and eight on a 16 vCPU, 64 GB runner fails on the small machine only. The signal is exit code 137, an `ENOSPC` write error, a Docker build that stops during a layer export, or a step that ends with truncated output. ### [Flaky ordering](#flaky-ordering)Copy Anchor Link Order-dependent tests fail when a shared fixture, a database row, a temp file, or a global mock survives one test and changes another. Confirm the class by running the same commit twice. If one run passes and the other fails, stop looking at the runner and start looking at test isolation and at any randomized seed the framework prints at the top of the run. ### [Secrets and permissions](#secrets-and-permissions)Copy Anchor Link Workflows triggered by `pull_request` from a fork receive no repository secrets, which makes every secret-consuming step fail with an empty value rather than an error. The `GITHUB_TOKEN` carries the permissions declared in the workflow, so a step that writes a check, a comment, or a package fails with `Resource not accessible by integration` until the `permissions` block grants the scope. Both failures look like broken code and are configuration. ### [Read the runner metrics before guessing](#read-the-runner-metrics-before-guessing)Copy Anchor Link The [WarpBuild observability documentation](/docs/ci/features/observability) describes two views. Recommendations aggregates metrics by repository, workflow, job, and instance type, and flags instances that sit outside resource thresholds. Usage shows metrics and logs for one runner instance, which is the view to open when a specific job failed. The Usage view carries three things for the failing instance: system logs from the runner, the GitHub Actions logs for the job, and the utilization charts. The logs sit together on purpose, so the timestamp of the failing step lines up against the metric charts without exporting anything. Five metrics are collected per instance: * CPU utilization, as the maximum rolling average over the last 30 seconds * Memory utilization, as the maximum * Filesystem utilization, as the maximum * Disk I/O, as the maximum rolling average of read plus write throughput over the last 30 seconds * Network utilization, as the maximum rolling average of read plus write throughput over the last 30 seconds WarpBuild labels an instance under-provisioned at these thresholds: | Metric | Threshold | Alert label | | -------------------------- | -------------------------------------------- | --------------------- | | Max sustained CPU | 80 percent or higher | High CPU Usage | | Max memory utilization | 80 percent or higher | High Memory Usage | | Max filesystem utilization | 80 percent or higher | High Filesystem Usage | | Max disk I/O | 80 percent or higher of supported throughput | High Disk IO | The reading rule is short. If memory or filesystem sits at the ceiling at the timestamp of the failing step, the job died of resource exhaustion and a bigger label fixes it. If all five metrics stay low and flat through the failure, the machine was fine and the failure is a logic, configuration, or permissions problem, which is the point where an interactive session pays for itself. Two collection details change what you see. Observability collects metrics and logs only for jobs longer than about 1 minute, so a job that fails in 20 seconds has no charts to read. Collection can also be paused for an organization, in which case the Usage view shows the agent initializing and no data. Telemetry is gathered with OpenTelemetry over port `33931`. ## [Fix](#fix)Copy Anchor Link Once the class is known, the fix for a logic, drift, or permissions failure is the same move: stop the job at the moment of failure and get a shell on the machine that produced it. ### [Add one line to open an SSH session](#add-one-line-to-open-an-ssh-session)Copy Anchor Link The [Action Debugger](/docs/ci/tools/action-debugger) is a free, open-source GitHub Action that lets you SSH into a running GitHub Action. The workflow addition is a single line: Copy Text ``` - uses: Warpbuilds/action-debugger@v1.3 ``` In a real job, pin it behind a failure condition so normal green runs never pause: Copy Text ``` name: ci on: [push] jobs: build: runs-on: warp-ubuntu-latest-x64-4x permissions: contents: read checks: write steps: - uses: actions/checkout@v4 - run: make build - run: make test - name: Setup interactive ssh session if: ${{ failure() }} uses: Warpbuilds/action-debugger@v1.3 with: limit-access-to-actor: true timeout-minutes: 15 ``` Workflow execution pauses as soon as the Action Debugger step is invoked. An SSH session starts on the runner machine, and the SSH URL is written to the action logs and posted as a check on the corresponding GitHub run. The action holds the workflow on that step until a user connects and exits the session. ### [The GitHub setting to enable first](#the-github-setting-to-enable-first)Copy Anchor Link Posting the SSH URL as a check needs write permission on the checks scope. In the repository settings, under Actions and then General, set Workflow permissions to read and write permissions. On many organizations this has to be set at the organization level rather than the repository level, because the organization setting overrides the repository one. When the URL appears in the job logs but no check shows up on the run, this setting is the cause. Access control matters on the same step. By default, if the GitHub user who triggered the run has SSH keys on their account, only that user can connect. If they have no keys on the account, anyone who has or guesses the generated SSH URL can connect to the session. Setting `limit-access-to-actor: true` forces the restriction, and it is required when you use named sessions. ### [What to run once you are on the runner](#what-to-run-once-you-are-on-the-runner)Copy Anchor Link The session drops you into `tmux`. A blank screen on connect is usually `tmux` waiting, and `ctrl+c` drops you into a shell, at the cost of the multiplexing. Work through the classes in order: * `printenv | sort` and compare `GITHUB_WORKSPACE`, `RUNNER_TEMP`, `PATH`, and `LANG` against the values your local shell reports * `which node python3 go docker` followed by each `--version` to catch drift and missing tooling in one pass * `df -h` and `free -m` for filesystem and memory headroom at the exact moment of failure * re-run the failing command by hand, then bisect it by commenting out flags rather than by pushing commits * `ls -la "$GITHUB_WORKSPACE"` to confirm the checkout produced what the next step expects The value of the session is that every one of those checks costs seconds instead of a full queue-and-run cycle. ### [Fix by class](#fix-by-class)Copy Anchor Link Resource exhaustion is fixed by changing the `runs-on` label to a larger size, or by lowering worker counts to match the machine. Drift is fixed by pinning the tool version in the workflow with a setup action so the runner and the laptop agree. Missing tooling is fixed with an explicit install step. Flaky ordering is fixed in the test suite, and the interactive session mainly helps you reproduce it with the same seed. Permissions are fixed in the `permissions` block or in the organization Actions settings. ## [Configuration](#configuration)Copy Anchor Link Sizing is the most common fix after a resource failure, so keep the catalog in front of you. Linux x64 sizes, rates, and shapes: | Runner label | vCPU | RAM | Storage | USD per minute | | -------------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Rates come from the [WarpBuild pricing page](/pricing) and are billed per minute. Runner storage is ephemeral and is deleted when the runner terminates, so anything you want to keep from a debug session has to be uploaded as an artifact before you exit. ### [Reproduce on the platform that failed](#reproduce-on-the-platform-that-failed)Copy Anchor Link A platform-specific failure can be reproduced on the same platform instead of being guessed at from a Linux shell. A throwaway matrix job narrows a "works on my machine" report to one platform in a single run: Copy Text ``` name: reproduce on: workflow_dispatch: jobs: reproduce: strategy: fail-fast: false matrix: runner: - warp-ubuntu-latest-x64-4x - warp-ubuntu-latest-arm64-4x - warp-macos-15-arm64-6x - warp-windows-latest-x64-4x runs-on: ${{ matrix.runner }} permissions: contents: read checks: write steps: - uses: actions/checkout@v4 - run: node --version - run: make test - name: Setup interactive ssh session if: ${{ failure() }} uses: Warpbuilds/action-debugger@v1.3 with: limit-access-to-actor: true timeout-minutes: 20 ``` `fail-fast: false` matters here. The default cancels sibling jobs on the first failure, which is exactly the evidence you are trying to collect. ### [Debug the tail of a job without blocking it](#debug-the-tail-of-a-job-without-blocking-it)Copy Anchor Link Detached mode runs every step normally and pauses at the end of the job instead of at the debugger step. Use it to inspect the final state of the workspace, the caches, and the build output after a job that failed late: Copy Text ``` - name: Setup interactive ssh session uses: Warpbuilds/action-debugger@v1.3 with: detached: true limit-access-to-actor: true timeout-minutes: 30 ``` Always set `timeout-minutes`. Runner minutes are billed while the session is open, and GitHub kills workflows after 6 hours by default, so an abandoned session is a 6 hour bill. Deterministic SSH URLs are available through named sessions, which produce a URL in the form `/@gha.warp.build` instead of a fresh random string per run. Named sessions require an API key from WarpBuild support and require `limit-access-to-actor: true`. ### [Automation and access](#automation-and-access)Copy Anchor Link Teams that prefer to ask for it in natural language can point an MCP host at the [WarpBuild MCP server](/guides/github-actions-mcp-server). If your runners live in your own cloud account, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. That matters during a drift investigation, because the instance shape that ran the failing job is described in code you can diff against the shape you expected. Access to the Observability views follows your WarpBuild organization membership, and SSO is available for a flat $250 per month, whatever the user count. See the [pricing page](/pricing) for the current rates. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The re-run-and-guess loop is expensive in wall clock time and cheap in dollars. Here is the arithmetic on a single realistic failure. Assumptions, stated so you can substitute your own: * The job runs on `warp-ubuntu-latest-x64-4x` at $0.008 per minute. * The failing step is reached 9 minutes into the job, so every re-run pays the full 9 minutes. * The loop takes 6 iterations before the cause is found. * Each iteration adds 3 minutes of human time to edit, commit, push, and wait for the job to be picked up. * The interactive path pays one 9 minute run to reach the pause, then 20 minutes of connected session, all billed as runner minutes. * Billing is per minute on both platforms in the table. | Path | Billed runner minutes | Cost on warp-ubuntu-latest-x64-4x at $0.008 per minute | Cost on the GitHub-hosted 4-core Linux larger runner at $0.012 per minute | | ------------------------------ | --------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------- | | Re-run and guess, 6 iterations | 54 | $0.432 | $0.648 | | One interactive debug session | 29 | $0.232 | $0.348 | | Difference | 25 | $0.200 | $0.300 | The GitHub-hosted figure is the published list price for the 4-core Linux larger runner, SKU `linux_4_core`, at $0.012 per minute, taken from the [GitHub Actions minute multipliers billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing), checked on 2026-08-13\. Stated as a rate comparison: `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13. Now the number that actually matters, engineer wall clock time on the same failure: | Path | Engineer minutes | How it adds up | | ------------------------------ | ---------------- | ---------------------------------------------------------------- | | Re-run and guess, 6 iterations | 72 | 6 x (9 minutes of waiting + 3 minutes of edit, commit, and push) | | One interactive debug session | 29 | 9 minutes to reach the pause + 20 minutes connected | | Difference | 43 | Time returned to the engineer per failure | Scale that with your own numbers. A team of 20 engineers hitting two such failures each per week recovers 43 x 2 x 20 = 1,720 engineer minutes per week, or about 28 hours, while the runner bill moves by 2 x 20 x $0.20 = $8.00 per week in the other direction. The compute is the small term in this model. Two caveats keep the model honest. Jobs that fail in under a minute produce no observability charts, so the metrics half of this workflow does not apply to them. And a debug session that stays open is billed the whole time, which is why `timeout-minutes` belongs on every debugger step. Full rates are on the [pricing page](/pricing). For the fleet-wide view of where jobs fail and how instances are utilized across every repository, see [GitHub Actions observability on WarpBuild](/solutions/github-actions-observability). For the per-metric reference behind the charts described above, see [how to read GitHub Actions runner metrics](/guides/github-actions-runner-metrics). If the job succeeds and the complaint is duration rather than failure, start at [why GitHub Actions workflows are slow](/answers/why-are-github-actions-slow). ## [FAQ](#faq)Copy Anchor Link **How do I SSH into a running GitHub Actions job?** Add the Action Debugger step to the job. The workflow pauses at that step, prints an SSH URL in the job logs and as a check on the run, and stays paused until someone connects and exits the session. The check requires read and write workflow permissions on the repository or organization. **How do I tell a resource failure from a logic failure?** Open the Usage view on the WarpBuild Observability page for the instance that ran the job and line up the timestamp of the failing step against CPU, memory, filesystem, disk I/O, and network. WarpBuild flags an instance as under-provisioned when max sustained CPU, memory, or filesystem utilization reaches 80 percent. Flat, low metrics point at logic instead. **Why does my job fail with exit code 137 and no error message?** Exit code 137 means the process was killed with SIGKILL, which on a runner almost always means the kernel out-of-memory killer took it. Check memory utilization in the Usage view at the timestamp of the failing step, then move the job to a label with more RAM, such as `warp-ubuntu-latest-x64-8x` with 32 GB. **Does an interactive debug session cost runner minutes?** Yes. Runner minutes are billed for as long as the session stays open, and GitHub kills workflows after 6 hours by default, so set `timeout-minutes` on the step. At the `warp-ubuntu-latest-x64-4x` rate of $0.008 per minute, a 20 minute session costs $0.16. --- # Dependency Update Jobs on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/dependabot-and-renovate-jobs-github-actions Description: Dependabot and Renovate pull requests trigger the same workflows a human one does. Scope the update check, group the updates, and price the weekly batch. # Dependency Update Jobs on GitHub Actions Dependabot and Renovate pull requests trigger the same workflows a human one does. Scope the update check, group the updates, and price the weekly batch. Last verified: 2026-08-13 A dependency bot opens pull requests on a schedule, and each one fires the same `pull_request` workflows a human pull request fires, so a weekly batch of ten update pull requests bills the full matrix ten times before anyone reads a diff. Two changes contain that: scope the workflow so a bot branch runs one update-check job rather than the whole matrix, and decide deliberately where the bot's own update job runs, because GitHub applies its self-hosted rules to managed runners. This guide covers the documented Dependabot behavior on managed runners and the setup it needs, the workflow that keeps bot pull requests off the matrix without breaking required status checks, and a priced model of a weekly update batch. ## [Diagnosis](#diagnosis)Copy Anchor Link Two workloads travel under the word Dependabot, and they bill differently. **The update job** resolves the new version, edits the manifest, and opens the pull request. It runs on GitHub's own infrastructure by default. Moving it onto your runners is an explicit opt-in: an owner turns on the Dependabot on self-hosted runners setting and provides a runner carrying the `dependabot` label, and GitHub does not run Dependabot updates on public repositories when self-hosted runners are in use ([managing Dependabot on self-hosted runners](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/managing-dependabot-on-self-hosted-runners), checked on 2026-08-13). **The workflow run triggered by the resulting pull request** is where your minutes go, and it is indistinguishable from a human pull request unless you make it distinguishable. Three details set the volume: * The default ceiling is five open pull requests per package ecosystem, raised or lowered with `open-pull-requests-limit`, and `groups` collapses several updates into one pull request ([Dependabot options reference](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference), checked on 2026-08-13). Three ecosystems at the default means up to fifteen open branches, each rebuilding on every rebase. * A run triggered by Dependabot gets a read-only `GITHUB_TOKEN` and reads Dependabot secrets rather than Actions secrets ([automating Dependabot with GitHub Actions](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions), checked on 2026-08-13). A matrix leg that pulls a private registry credential from Actions secrets fails on bot branches only, and the failure looks like a flaky job until someone reads the log. * Renovate runs either as a GitHub App or as a scheduled workflow job in your own repository. In the second shape the update job is an ordinary workflow job that takes whatever `runs-on` label you give it, so it is priced like any other job on your account. The compounding case is a failing group. One bad transitive dependency turns into repeated re-runs across every open bot branch, which is the pattern described in [containing re-run storms on GitHub Actions](/guides/rerun-storms-github-actions). ## [Fix](#fix)Copy Anchor Link Four moves, ordered by how much they remove. **Cut the number of pull requests.** A weekly schedule with `groups` turns a dozen individual bumps into one branch per ecosystem. This is the largest lever because it divides the run count directly. **Cut the jobs per pull request.** A dependency bump needs proof that the tree still installs, compiles, and passes unit tests. It rarely needs the full platform matrix on the first pass. The honest split is to run one Linux update-check job on the bot branch and keep the four-platform matrix for human pull requests and for the merge commit on the default branch. **Keep required status checks reporting.** A job skipped by an `if` condition reports no conclusion, and a required check that never reports leaves the pull request unmergeable ([troubleshooting required status checks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks), checked on 2026-08-13). Add one gate job with `if: always()` that reads the `needs` results and make that the required check. **Place the update job on purpose.** Leaving Dependabot's own update job on GitHub-hosted infrastructure is the correct default for most teams and the only option on public repositories. When a private repository needs the update job to reach an internal registry or a private network, the [common issues documentation](/docs/ci/common-issues#running-dependabot) covers the Allow Dependabot option on a custom runner, which assigns Dependabot workflows to WarpBuild custom runners. ## [Configuration](#configuration)Copy Anchor Link Group first, in `.github/dependabot.yml`: .github/dependabot.yml Copy Text ``` version: 2 updates: - package-ecosystem: npm directory: / schedule: interval: weekly day: monday open-pull-requests-limit: 3 groups: npm-minor-and-patch: update-types: [minor, patch] ``` Major updates stay outside the group, so each arrives on its own branch with its own run. Then scope the workflow. The `update-check` job is the only thing a bot branch runs: .github/workflows/ci.yml Copy Text ``` name: ci on: pull_request: push: branches: [main] concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: update-check: if: github.actor == 'dependabot[bot]' || github.actor == 'renovate[bot]' runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version-file: .nvmrc - run: npm ci - run: npm run build - run: npm test -- --runInBand matrix: if: github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' strategy: fail-fast: false matrix: runner: - warp-ubuntu-latest-x64-4x - warp-ubuntu-latest-arm64-4x - warp-macos-latest-arm64-6x - warp-windows-latest-x64-4x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version-file: .nvmrc - run: npm ci - run: npm test gate: if: always() needs: [update-check, matrix] runs-on: warp-ubuntu-latest-x64-2x steps: - name: Fail if any required job failed run: | if [ "${{ contains(needs.*.result, 'failure') }}" = "true" ]; then exit 1 fi ``` Set `gate` as the required status check and leave `update-check` and `matrix` unrequired. `gate` reports on every run, so a bot branch merges on the update check alone and a human branch has to clear all four platforms. Label shapes and sizes come from the [cloud runners documentation](/docs/ci/cloud-runners). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Assume forty bot pull requests per month across three ecosystems, one run each, a matrix that expands to six legs (the four platforms above plus two extra Node versions on Linux) averaging seven minutes, and a scoped update check of nine minutes. Rates come from the [pricing page](/pricing). | Configuration | Bot pull requests | Jobs each | Billed minutes | Cost at $0.008 per minute | | ------------------------------- | ----------------- | --------- | -------------- | ------------------------- | | Full matrix on every bot branch | 40 | 6 | 1,680 | $13.44 | | Scoped update check | 40 | 1 | 360 | $2.88 | | Scoped and grouped weekly | 12 | 1 | 108 | $0.86 | The merge path is unchanged: each of the twelve grouped updates still runs the full matrix once on the default branch, adding 504 minutes and $4.03\. Total for the scoped and grouped shape is 612 minutes at $4.90 against 2,184 minutes at $17.47 for the naive shape, a removal of 1,572 billed minutes per month on one repository. Multiply by the repository count before deciding this is small. The same minutes on GitHub-hosted list prices, from the [minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers): warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13\. The naive shape prices out at $26.21 and the scoped and grouped shape at $7.34 at that rate. | Shape | Billed minutes | At $0.008 | At $0.012 | | -------------------------------------------- | -------------- | --------- | --------- | | Full matrix on every bot branch, plus merges | 2,184 | $17.47 | $26.21 | | Scoped and grouped, plus merges | 612 | $4.90 | $7.34 | Every line above is minutes multiplied by a rate, which is the whole invoice. The same arithmetic applies to the other recurring bot workloads on a repository: the scheduled scans in [security scanning jobs on GitHub Actions](/guides/security-scanning-jobs-github-actions) and the rebuild cadence in the [base image update workflow](/guides/base-image-update-workflow-github-actions). Per pull request accounting for the human side is in [what one pull request costs](/guides/ci-cost-per-pull-request), and the account level view is in [cutting GitHub Actions costs without rewrites](/guides/reduce-github-actions-costs). ## [FAQ](#faq)Copy Anchor Link ### [Can Dependabot update jobs run on WarpBuild runners?](#can-dependabot-update-jobs-run-on-warpbuild-runners)Copy Anchor Link On private repositories, yes, with a setting on each side. GitHub runs Dependabot updates on GitHub-hosted infrastructure unless an owner turns on the Dependabot on self-hosted runners setting and offers a runner carrying the `dependabot` label, and a managed runner registers through the same self-hosted path, so the rule applies to it. On the WarpBuild side the custom runner carries an Allow Dependabot option, documented in the [common issues page](/docs/ci/common-issues#running-dependabot), which assigns Dependabot workflows to WarpBuild custom runners. GitHub excludes public repositories from this path for security reasons. ### [How do I stop a bot pull request from running the whole matrix?](#how-do-i-stop-a-bot-pull-request-from-running-the-whole-matrix)Copy Anchor Link Gate the matrix job on the actor with a condition such as `github.actor != 'dependabot[bot]'`, and give the bot branch a single update-check job instead. Then add a gate job that runs with `always()` and inspects the `needs` results, and make that gate job the required status check, because a job skipped by an `if` condition never reports a conclusion and a required check that never reports blocks the merge. ### [Does grouping updates hide a breaking dependency?](#does-grouping-updates-hide-a-breaking-dependency)Copy Anchor Link It can, so group by update type rather than grouping everything. Patch and minor updates batch well because a failing group is usually one obvious culprit in a short list. Keep major version updates out of the groups so each one arrives as its own pull request with its own run, which is the case where a bisect across a grouped branch would cost more time than the extra runs. --- # Deployment Jobs on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/deployment-jobs-github-actions Description: A deploy job needs one environment, a fixed concurrency group with cancel-in-progress off, an OIDC credential exchange, and an artifact pinned by digest. # Deployment Jobs on GitHub Actions A deploy job needs one environment, a fixed concurrency group with cancel-in-progress off, an OIDC credential exchange, and an artifact pinned by digest. A deployment job on GitHub Actions is a job that ships an artifact that something else already built, and its shape comes down to four keys: `environment` to carry the protection rules, `concurrency` with a fixed group name and `cancel-in-progress` left at `false`, `permissions: id-token: write` to exchange a workflow token for short-lived cloud credentials, and a `needs` output holding the digest of the thing being deployed. Everything that produces bytes, including compiles, image builds, and registry pushes, sits in jobs upstream of the gate, so the artifact an approver releases is the artifact that was tested. This guide covers how to tell a deploy job that carries too much from one that carries the right amount, the four keys that fix it, a full workflow with an [OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) credential exchange and a digest pull, and a time model for a deploy job whose minutes are dominated by artifact transfer. ## [Diagnosis](#diagnosis)Copy Anchor Link Read the deploy job's step list before reading anything else. Five shapes account for most of the trouble, and each one puts time or risk in a different place. | What the deploy job does | What it means | Where the cost lands | | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | Runs docker build or a compile step after the gate | The approver released a commit, and the job produced a fresh artifact from it | Build minutes billed behind an approval, and a binary nobody tested | | Pulls by tag rather than by digest | A moving tag can resolve to different bytes between the test job and the deploy job | Silent drift between environments | | Holds a long-lived cloud access key in a repository secret | The credential outlives the run and every job that can read the secret can use it | Blast radius, and a rotation task nobody owns | | Shares a concurrency group with the checks workflow | GitHub resolves group names across the repository, so a test run and a release occupy one lane | A release parked behind a test run | | Sets cancel-in-progress: true on the deploy lane | The next merge stops an apply where it stands | A half-applied target and manual reconciliation | The first row is the one that hides. A gate exists so a person can look at what is about to ship, and a build step after the gate means the bytes that ship were produced after the person looked. The [build once and deploy many](/guides/build-once-deploy-many-github-actions) pattern is the structural answer, and this page covers the job on the receiving end of it. The last two rows look identical in the run view: a deploy sitting in a waiting state. Which hold you are looking at decides the fix, and the mapping from symptom to mechanism is in [how to stop a workflow from blocking deploys](/answers/how-do-i-stop-a-workflow-from-blocking-deploys). One thing that is rarely the problem: platform capacity. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Capacity adjusts dynamically, so serializing the deploy lane is a decision you write into the YAML rather than a limit you inherit. ## [Fix](#fix)Copy Anchor Link ### [Bind the job to an environment](#bind-the-job-to-an-environment)Copy Anchor Link The `environment` key is what turns an ordinary job into a gated one. A job that references an environment stays in a waiting state until every protection rule on that environment passes, and GitHub dispatches it to a runner only after that ([deployments and environments reference](https://docs.github.com/en/actions/reference/deployments-and-environments), checked on 2026-08-13). Required reviewers accept up to 6 users or teams and one approval releases the job. A wait timer accepts 1 to 43,200 minutes. Deployment branch and tag rules match against `GITHUB_REF`, which keeps a fork or a feature branch from opening a production deployment at all. Because those holds happen before dispatch, the approval window is unbilled. The full rule table sits in the [deployment environment glossary entry](/glossary/deployment-environment). ### [Give the deploy lane its own concurrency group](#give-the-deploy-lane-its-own-concurrency-group)Copy Anchor Link Name the group for the target, not for the branch. `group: deploy-production` resolves to the same string on every merge, so releases line up in order, and `cancel-in-progress: false` keeps a running apply alive when the next merge arrives. GitHub matches the resolved group string across the repository rather than per workflow file ([workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency), checked on 2026-08-13), which is why a checks workflow keyed on `github.ref` can share a lane with a release. ### [Exchange a token for short-lived credentials](#exchange-a-token-for-short-lived-credentials)Copy Anchor Link Request `id-token: write` in the job's `permissions` block, then trade the workflow's OIDC token for cloud credentials scoped to that repository, branch, and environment. The subject claim can be pinned to `repo:owner/name:environment:production`, so a credential minted for a production deploy is unusable from a pull request run. Nothing long lived sits in a repository secret afterwards. The runner side of that story matters for a job that briefly holds production credentials. Each WarpBuild runner runs in its own virtual machine, created on demand and destroyed after the build, and build secrets stay in your repository rather than with the runner provider ([security documentation](/docs/ci/security)). ### [Pull the artifact by digest](#pull-the-artifact-by-digest)Copy Anchor Link Take the digest as a job output from the build and use it verbatim: `registry/api@sha256:...` resolves to one immutable manifest. Workflow artifacts get the same treatment through `actions/download-artifact` with an explicit `name`, and an explicit `retention-days` on the upload side keeps the storage line predictable. ### [Size the job for the bytes, then stop](#size-the-job-for-the-bytes-then-stop)Copy Anchor Link Rates and shapes below come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13. | runs-on label | OS | vCPU | RAM | Storage | Rate per minute | | -------------------------- | ------------ | ---- | ----- | --------- | --------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | A release job that signs and uploads a mobile build runs on a macOS label while the service deploy stays on Linux, both gated by the same environment. Ordering these levers against the rest of a pipeline is covered in the guide to [speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Configuration](#configuration)Copy Anchor Link The build job pushes once and exports the digest. The deploy job consumes it, and does nothing else that produces bytes. Copy Text ``` name: release on: push: branches: [main] concurrency: group: deploy-production cancel-in-progress: false jobs: build: runs-on: warp-ubuntu-latest-x64-8x permissions: contents: read id-token: write outputs: digest: ${{ steps.push.outputs.digest }} steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/gha-build aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - uses: Warpbuilds/build-push-action@v6 id: push with: context: . push: true profile-name: api-builder tags: ${{ steps.ecr.outputs.registry }}/api:${{ github.sha }} - uses: actions/upload-artifact@v4 with: name: migrations path: db/migrations/ retention-days: 14 deploy: needs: build runs-on: warp-ubuntu-latest-x64-4x environment: name: production url: https://app.example.com permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 with: sparse-checkout: deploy/ - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/gha-deploy-production aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - uses: actions/download-artifact@v4 with: name: migrations path: db/migrations/ - name: Pull the exact image that was tested run: docker pull "${{ steps.ecr.outputs.registry }}/api@${{ needs.build.outputs.digest }}" - name: Apply run: ./deploy/apply.sh "${{ needs.build.outputs.digest }}" - name: Smoke test run: ./deploy/smoke.sh https://app.example.com ``` Five details carry the behavior. The `concurrency` block sits at workflow level with a fixed group name, so every merge to `main` queues in one release lane. `environment: production` holds the deploy job before dispatch, and the `url` field populates the deployments view. Each job requests its own `id-token: write` and assumes a different role, so the build role can push to the registry while the deploy role can reach production. The pull uses `@${{ needs.build.outputs.digest }}` rather than a tag. And the checkout is sparse, because the deploy job needs the scripts under `deploy/` rather than the whole tree. Adding a second target is one more job with its own environment and its own role, promoting the same digest: Copy Text ``` deploy-eu: needs: [build, deploy] runs-on: warp-ubuntu-latest-x64-4x environment: name: production-eu url: https://eu.app.example.com permissions: contents: read id-token: write steps: - uses: actions/checkout@v4 with: sparse-checkout: deploy/ - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/gha-deploy-eu aws-region: eu-west-1 - run: ./deploy/apply.sh "${{ needs.build.outputs.digest }}" ``` ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Deploy jobs are short, and the minutes inside them go somewhere unintuitive: the steps that move gigabytes, rather than the step that applies the change. ### [Assumptions](#assumptions)Copy Anchor Link | Input | Value | Source | | ------------------------------------- | ------------------------------- | -------------------------------------------- | | Merges to main per month | 120 | Your workflow run history | | Deploy targets per merge | 3 (staging, canary, production) | The pipeline above, extended | | Container image pulled per deploy | 1.6 GB | Your registry metrics | | Artifact bundle downloaded per deploy | 0.7 GB | Your artifact retention view | | Approval wait on production | 35 minutes | Your deployments view | | Runner rates | $0.004 to $0.016 per minute | [pricing page](/pricing), checked 2026-08-13 | ### [Where a single deploy spends its minutes](#where-a-single-deploy-spends-its-minutes)Copy Anchor Link The transfer steps are network bound, so they flatten out as the label grows, while the apply and smoke steps track cores. Substitute your own step timings from a run log before committing to a size. | Step | 2x (2 vCPU) | 4x (4 vCPU) | 8x (8 vCPU) | | --------------------------------- | ------------------------ | ------------------------ | ------------------------ | | Waiting for the environment gate | 35.0 min, not dispatched | 35.0 min, not dispatched | 35.0 min, not dispatched | | Sparse checkout and OIDC exchange | 0.4 min | 0.4 min | 0.4 min | | Pull image by digest, 1.6 GB | 2.0 min | 1.4 min | 1.3 min | | Download artifact bundle, 0.7 GB | 0.9 min | 0.6 min | 0.6 min | | Apply | 4.6 min | 3.4 min | 2.9 min | | Smoke test | 1.8 min | 1.2 min | 1.0 min | | Billed minutes | 9.7 | 7.0 | 6.2 | | Rate per minute | $0.004 | $0.008 | $0.016 | | Cost per deploy | $0.0388 | $0.0560 | $0.0992 | The approval row is 35 minutes of wall clock and zero billed minutes, because the hold happens before the job reaches a runner. ### [Monthly, at 360 deploys](#monthly-at-360-deploys)Copy Anchor Link | Label | Billed minutes per month | Monthly cost | Wall clock per deploy | | ------------------------- | ------------------------ | ------------ | --------------------- | | warp-ubuntu-latest-x64-2x | 3,492 | $13.97 | 9.7 min | | warp-ubuntu-latest-x64-4x | 2,520 | $20.16 | 7.0 min | | warp-ubuntu-latest-x64-8x | 2,232 | $35.71 | 6.2 min | Moving the label from 2 vCPU to 8 vCPU buys 3.5 minutes of wall clock per deploy and costs $21.74 a month at this volume. Deploy jobs are usually where that trade is worth taking, because the minutes are few and the wait is in front of a release. The bytes are the other half. At 2.3 GB per deploy and 360 deploys, this pipeline moves 828 GB a month out of the registry and the artifact store, and your cloud provider prices that path rather than GitHub. On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you, whether your runners live in your cloud or ours; the scope is on [zero egress for the enterprise tier](/enterprise/zero-egress). Every rate above carries a source link and a checked-on date, and the step timings are assumptions to replace with your own. ## [FAQ](#faq)Copy Anchor Link ### [What belongs in a deployment job and what belongs upstream?](#what-belongs-in-a-deployment-job-and-what-belongs-upstream)Copy Anchor Link The deploy job resolves credentials, fetches an already built artifact by digest, applies it to one target, and verifies the result. Compiling, packaging, image builds, and registry pushes belong to jobs upstream of the approval gate, so the thing that ships is the thing that was tested. ### [Does a waiting approval cost runner minutes?](#does-a-waiting-approval-cost-runner-minutes)Copy Anchor Link No. A job bound to an environment stays in a waiting state until every protection rule passes, and GitHub dispatches it to a runner only after that, so an approval that sits overnight and a wait timer of up to 43,200 minutes both accrue zero job minutes ([deployments and environments reference](https://docs.github.com/en/actions/reference/deployments-and-environments), checked on 2026-08-13). ### [What size runner does a deployment job need?](#what-size-runner-does-a-deployment-job-need)Copy Anchor Link Size it for the bytes it moves rather than for cores. A job that pulls a 1.6 GB image and a 700 MB bundle spends most of its minutes on transfer, which is network bound, so `warp-ubuntu-latest-x64-4x` at $0.008 per minute is the common landing spot, and `warp-ubuntu-latest-x64-2x` at $0.004 is cheaper per deploy while running about 2.7 minutes longer. Full rates are on the [pricing page](/pricing). --- # Passing Secrets into Docker Builds Safely | WarpBuild URL: https://www.warpbuild.com/guides/docker-build-secrets-github-actions Description: A --build-arg token lands in the image history. Mount it with RUN --mount=type=secret in GitHub Actions instead, then prove it is absent from every layer. # Passing Secrets into Docker Builds Safely A --build-arg token lands in the image history. Mount it with RUN --mount=type=secret in GitHub Actions instead, then prove it is absent from every layer. A build argument is a parameter, and BuildKit records the parameters an instruction ran with, so `--build-arg NPM_TOKEN=...` publishes that token to everyone who can pull the image. Pass the value through `RUN --mount=type=secret` instead: BuildKit exposes the file for the duration of one instruction, keeps it out of the layer diff, and keeps it out of the recorded command line. This guide covers where each leaky pattern deposits the value, the Dockerfile and workflow changes that replace it, the [BuildKit](/glossary/buildkit) mount syntax for the common package managers, a verification step that fails the build when a credential reaches a layer, and the arithmetic on what the checks cost per month. For the wider picture, start at [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link GitHub Actions masks a secret in the log stream, and that masking stops at the log. The same value can still be written into an image layer, into the image config, or into a provenance attestation, and all three travel to whoever pulls the tag. GitHub documents the boundary in [using secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions). Four patterns account for most leaks, and each one leaves the value somewhere different. | Pattern | Where the value survives | How to see it | | ------------------------------------------------------------ | ------------------------------------------------------------------------------- | ---------------------------------------------- | | ARG plus \--build-arg | The created\_by string of the layer, and the provenance attestation at mode=max | docker history --no-trunc | | ENV TOKEN=... | The image config, and the environment of every process started from the image | docker inspect --format '{{json .Config.Env}}' | | COPY creds.json then RUN rm creds.json | The earlier layer diff, which ships inside the image | docker save and read the layer tarballs | | A secret used in a build stage that the final image discards | The builder cache, and any registry cache exported with mode=max | Pull the cache ref and inspect its blobs | The first one is the most common because it looks contained. The argument is declared, consumed by one command, and never copied into the runtime stage, so the assumption is that it disappeared with the stage. It did not: Copy Text ``` $ docker history --no-trunc --format '{{.CreatedBy}}' ghcr.io/acme/api:1.4.2 |1 NPM_TOKEN=npm_7Qa4KjR2vX9pLtB6 /bin/sh -c npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN && npm ci ``` The fourth pattern is the one that survives a correct multi-stage Dockerfile. The discarded stage keeps the secret out of the published image and still writes it into the layers the builder caches. If those layers are exported to a registry cache with `mode=max`, the cache ref carries the credential and is usually readable by anyone who can read the repository. ## [Fix](#fix)Copy Anchor Link A secret mount is a bind mount that BuildKit attaches for the duration of a single `RUN` instruction. The file exists at `/run/secrets/` by default, is never part of the filesystem diff that becomes the layer, and its value is never written into the recorded command line. Before, with the token as a build argument: Copy Text ``` FROM node:22-slim WORKDIR /app ARG NPM_TOKEN COPY package.json package-lock.json ./ RUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN && npm ci COPY . . RUN npm run build ``` After, with the credential file mounted at the path npm already reads: Copy Text ``` # syntax=docker/dockerfile:1.7 FROM node:22-slim WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true \ npm ci COPY . . RUN npm run build ``` Three details carry the change. **Mount the credential file rather than the raw value.** `npm config set` writes `/root/.npmrc` inside the container, and that file lands in the layer. Mounting the finished `.npmrc` at the path the tool reads removes the write step entirely. The same shape works for `/root/.netrc`, `~/.docker/config.json`, and `~/.m2/settings.xml`. **Set `required=true`.** Without it, a missing secret produces a build that runs anyway and fails later with a confusing registry error, or worse, succeeds against the public registry and ships a different dependency tree. **Read the file inside the instruction when a tool wants an environment variable.** The value stays inside the process that needs it: Copy Text ``` RUN --mount=type=secret,id=sentry_token,required=true \ SENTRY_TOKEN="$(cat /run/secrets/sentry_token)" \ ./scripts/upload-sourcemaps.sh ``` For private Git dependencies, `RUN --mount=type=ssh` forwards an agent socket instead, which avoids putting a deploy key on disk at any point. Registry authentication for the base image is a separate problem with its own answer in [how to authenticate to a private registry in GitHub Actions](/answers/how-do-i-authenticate-to-a-private-registry-in-github-actions). ## [Configuration](#configuration)Copy Anchor Link ### [The workflow, on a remote Docker builder](#the-workflow-on-a-remote-docker-builder)Copy Anchor Link `Warpbuilds/build-push-action@v6` is a drop-in replacement for the upstream build and push action and keeps its inputs, including `secrets` and `secret-files`, with `profile-name` selecting the builder profile. Every input is listed in the [Docker Builders documentation](/docs/ci/docker-builders). Copy Text ``` name: docker on: pull_request: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-2x permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Write the registry credential file run: | printf '//registry.npmjs.org/:_authToken=%s\n' \ "${{ secrets.NPM_TOKEN }}" > "$RUNNER_TEMP/npmrc" - uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/api:${{ github.sha }} profile-name: api-amd64 secret-files: | npmrc=${{ runner.temp }}/npmrc secrets: | sentry_token=${{ secrets.SENTRY_TOKEN }} ``` The credential file goes under `RUNNER_TEMP` so it stays outside the build context and outside anything `COPY . .` can reach. Workflows triggered by `pull_request` from a fork receive no repository secrets, so a build that declares `required=true` fails on fork pull requests by design; run those through a separate job that builds without the private registry. The local equivalent takes the same two secrets from a file and from the environment: Copy Text ``` docker buildx build \ --secret id=npmrc,src="$HOME/.npmrc" \ --secret id=sentry_token,env=SENTRY_TOKEN \ -t ghcr.io/acme/api:local . ``` ### [Proving the value is absent](#proving-the-value-is-absent)Copy Anchor Link Run these against a throwaway credential the first time, because the last check greps for the literal value. Copy Text ``` docker buildx build --load -t api:verify \ --secret id=npmrc,src="$RUNNER_TEMP/npmrc" . docker history --no-trunc --format '{{.CreatedBy}}' api:verify \ | grep -qi -e '_authToken' -e 'NPM_TOKEN=' && exit 1 docker inspect --format '{{json .Config.Env}}' api:verify \ | grep -qi 'token' && exit 1 docker save api:verify -o "$RUNNER_TEMP/api.tar" mkdir -p "$RUNNER_TEMP/api" && tar -xf "$RUNNER_TEMP/api.tar" -C "$RUNNER_TEMP/api" grep -rql --binary-files=text \ "$(cut -d= -f2 "$RUNNER_TEMP/npmrc")" "$RUNNER_TEMP/api" && exit 1 echo "credential absent from history, config, and every layer" ``` The history check catches build arguments, the config check catches `ENV`, and the layer scan catches a file that was copied in and deleted later. On a build that pushed rather than loaded, replace the first command with `docker buildx imagetools inspect ghcr.io/acme/api:$GITHUB_SHA --format '{{json .Provenance}}'` and read the recorded invocation parameters. Docker builds run from the Linux labels. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger. On the platform side, WarpBuild does not access or store build secrets, and each runner runs in its own virtual machine that is created on demand and destroyed after the build, both documented in the [security documentation](/docs/ci/security). WarpBuild is SOC 2 Type 2, with the report and control documentation available in the [trust center](https://trust.warpbuild.com/). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The checks are cheap enough that the argument for running them on every build is arithmetic rather than judgement. Rates below are the job runner `warp-ubuntu-latest-x64-2x` (2 vCPU, 8 GB) at $0.004 per minute and a 16 vCPU, 32 GB, 100 GB disk builder profile at $0.06 per minute, both from the [pricing page](/pricing) and checked on 2026-08-13, for a roughly 900 MB Node service image built 600 times per month. | Check | Where it runs | Time per run | Runs per month | Runner minutes | Cost | | ------------------------------------ | ---------------------------- | ------------ | -------------- | -------------- | ----- | | History and config inspect | Every build | 0:08 | 600 | 80.0 | $0.32 | | Full layer scan with docker save | Nightly and release builds | 0:50 | 40 | 33.3 | $0.13 | | Provenance inspect on the pushed tag | Pushes to the default branch | 0:05 | 120 | 10.0 | $0.04 | | Total | 123.3 | $0.49 | | | | Set that against the rebuild after a credential reaches a published tag. Rotating the token invalidates every image that embedded it, so each affected tag has to be rebuilt and republished: 60 tags at 6:00 of builder session each is 360 minutes at $0.06 per minute, or $21.60, plus 60 jobs at 2:00 on the runner at $0.004 per minute, or $0.48\. Compute comes to $22.08\. The rebuild is the small part of that day. The rotation itself, the audit of who pulled the affected tags, and the wait for downstream teams to move off them are what the incident actually costs. Builder sizes and disks are listed on [remote Docker builders](/runners/docker-builders), and the repository-level side of credential handling is covered in [managing GitHub Actions secrets across repositories](/guides/github-actions-secrets-management). ## [FAQ](#faq)Copy Anchor Link ### [Does a build arg really end up in the published image?](#does-a-build-arg-really-end-up-in-the-published-image)Copy Anchor Link Yes. BuildKit records the arguments a `RUN` instruction was executed with in that layer's `created_by` string, so `docker history --no-trunc` on any pulled tag prints the value. With provenance set to `mode=max` the build arguments are also recorded in the attestation attached to the pushed image. ### [Does rotating a token invalidate the layers built with it?](#does-rotating-a-token-invalidate-the-layers-built-with-it)Copy Anchor Link No. BuildKit keeps the secret value out of the layer cache key, so a rotated credential on its own does not force the `RUN` instruction to execute again. Change something the cache key does cover, such as the lockfile or a cache-busting build argument, when a rebuild with the new credential matters. ### [Where does the secret live when the build runs on a remote Docker builder?](#where-does-the-secret-live-when-the-build-runs-on-a-remote-docker-builder)Copy Anchor Link The value is sent with the build request and mounted for the duration of the one `RUN` instruction that declares it, and it is never written into a layer. WarpBuild does not access or store build secrets, and each runner VM is created on demand and destroyed after the job. --- # Configuring Remote Docker Builder Profiles | WarpBuild URL: https://www.warpbuild.com/guides/docker-builder-profiles-github-actions Description: A builder profile pins a name, vCPU size, architecture, and disk to one Docker builder VM. Field settings, the API calls that manage it, and session costs. # Configuring Remote Docker Builder Profiles A builder profile pins a name, vCPU size, architecture, and disk to one Docker builder VM. Field settings, the API calls that manage it, and session costs. A remote Docker builder profile is one configuration record with four fields, a name, a vCPU count, an architecture, and a disk size, and it maps to one dedicated builder VM whose persistent layer cache sits on that disk. Create a profile once in the dashboard or with `POST /builder-profiles`, then every job that passes the same `profile-name` reaches the same VM and the same cache. This guide covers the build symptoms that trace back to a profile field rather than to a Dockerfile, what each field controls and where the catalog caps it, the API calls that create, read, and reset a profile with their request shapes, and a session-minute model comparing one shared profile against several dedicated ones. For the wider picture start at [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions), and per-minute rates are on the [pricing page](/pricing). ## [Diagnosis](#diagnosis)Copy Anchor Link Five failures look like build problems and are profile settings. Read the profile back before you edit any YAML. | Symptom | Profile field | What to check | | ------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------- | | exec format error on a multi-platform build | architecture | The profile has to carry both architectures. A multi-arch build runs one builder instance per architecture. | | Every build cold after a workflow edit | name | The cache belongs to the profile. A renamed or retyped profile-name reaches a different profile and an empty cache. | | Builds crawl when the team pushes together | cpu | The documented recommended minimum is roughly 8 vCPU and 16GB memory per concurrent build job. | | Hit rate falls as branches accumulate | storage | One disk holds the layer cache for every image built on that profile. | | First build after a quiet stretch runs cold | last\_used\_at | The builder cache has a TTL of 10 days, and an unused profile resets automatically. | Read the profile with a name filter, which is faster than paging the whole list: Copy Text ``` curl -s -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles?name=api-amd64&per_page=30&page=1" ``` The response carries a `builder_profiles` array plus `page`, `per_page`, `total_items`, `total_pages`, and `next_page`. Compare the `cpu`, `architecture`, and `storage` values in that array against the table above before you conclude that the Dockerfile is at fault. ## [Fix](#fix)Copy Anchor Link Set the four fields against the workload rather than against a default. Each one has a hard constraint from the builder catalog. | Field | Type | What it controls | Constraint | | ------------ | ------- | --------------------------------------------------------- | ------------------------------------------------------------------------------- | | name | string | The value the workflow passes as profile-name | Has to match the workflow input character for character | | cpu | integer | vCPU on the builder VM, and therefore the per-minute rate | 16, 32, and 64 on every architecture; 96 and 192 are amd64 only | | architecture | string | Which architectures the profile can build | arm64 and multi-arch profiles cap at 64 vCPU | | storage | integer | Disk in GB holding the persistent layer cache | Catalog pairs: 100GB at 16 vCPU, 200GB at 32 and 64, 600GB or 2TB at 96 and 192 | Size `cpu` from peak concurrency rather than from average load, at the documented recommended minimum of roughly 8 vCPU and 16GB per concurrent build job. Four builds that regularly overlap on one profile want the 32 vCPU, 64GB size. The workflow side is one input. Nothing else changes when you move a build to a different profile: Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ghcr.io/acme/api:${{ github.sha }} profile-name: api-multiarch timeout: 600000 ``` Two notes on the job runner. This job belongs on a Linux runner, since macOS runners do not support nested virtualization and cannot run Docker. On a runner WarpBuild does not operate, add `api-key: ${{ secrets.WARPBUILD_API_KEY }}` so the action can request the builder assignment. ## [Configuration](#configuration)Copy Anchor Link Create an API key with the `ci` and `cache` scopes first, as described in the [automation documentation](/docs/ci/api-keys/automation), and export it as `WARPBUILD_API_KEY`. Create a profile with the four fields in the request body: Copy Text ``` curl -s -X POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ -d '{"name":"api-amd64","cpu":32,"architecture":"amd64","storage":200}' \ https://api.warpbuild.com/api/v1/builder-profiles ``` The response is the stored profile. These are the fields worth keeping: | Response field | Use | | ------------------------------------------ | --------------------------------------------------------- | | id | Path segment for the read, cache reset, and delete calls | | name | The profile-name value your workflows pass | | cpu, storage | The size that sets the per-minute rate and the cache disk | | architecture | Whether a multi-platform build will run or fail | | last\_used\_at | How close the profile is to the 10 day cache TTL | | created\_at, updated\_at, organization\_id | Ownership and audit trail | Read one profile back by id: Copy Text ``` curl -s -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles/$BUILDER_PROFILE_ID" ``` Reset the cache on that profile when a base image change leaves a large volume of dead layers, or when the disk is close to full. The call takes an optional `force` query parameter and returns a success message: Copy Text ``` curl -s -X POST \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles/$BUILDER_PROFILE_ID/cache/reset" ``` `DELETE /builder-profiles/{builder_profile_id}` removes the profile. Wait for every build on it to finish first. One shape of the API matters for planning. It offers create, list, read, reset cache, and delete, and no update call, so changing `cpu` or `architecture` through the API means creating a second profile and switching `profile-name` to it. The new profile starts empty, so make that switch where one cold build is acceptable. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface, and each of those has the same style of endpoint in the [API reference](/docs/api). The action inputs, the bake action, the CLI flow, and the session billing rules are in the [remote Docker builders documentation](/docs/ci/docker-builders). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The comparison that decides profile layout is one shared profile against several dedicated ones, measured in builder session minutes. The arithmetic below runs on stated assumptions rather than measurements, so replace each number with your own before acting on it. * Five service images, 400 image builds per month across all five. * A warm build occupies its builder for 4.0 minutes, and the builder is assigned 0.5 minutes before the build step, so a build on its own holds a session for 4.5 minutes. * Overlapping builds on one profile are billed as one session, from the first job start to the last job completion, with starts staggered by 1.0 minute. * A session covering `n` overlapping builds therefore runs 4.5 + (n - 1) minutes, which is 1 + 3.5/n builder minutes per build. | Option | Profile size | Rate per minute | Builds per session | Session minutes per month | Monthly cost | | -------------------------- | -------------------- | --------------- | ------------------ | ------------------------- | ------------ | | A. One shared profile | 32 vCPU, 64GB, 200GB | $0.12 | 4 | 750 | $90.00 | | B. Five dedicated profiles | 16 vCPU, 32GB, 100GB | $0.06 | 2 | 1,100 | $66.00 | Option A merges more builds into each session and still costs more, because the size it needs to hold four concurrent builds bills at twice the rate. Setting the per-build figures equal gives the crossover: option B costs 2.75 builder minutes at $0.06, or $0.165 per build, and option A matches when 1 + 3.5/n equals 1.375, which lands at about nine builds per session. The shared profile has to merge roughly nine builds into every session before it draws level with five dedicated 16 vCPU profiles at two. Sizing pushes that crossover further out. At the recommended minimum of roughly 8 vCPU per concurrent build job, nine overlapping builds want more than 64 vCPU, and 64 vCPU bills at $0.24 per minute. A shared profile kept in size for its peak concurrency gives back the rate advantage it was chasing. See [concurrent Docker builds on GitHub Actions](/guides/concurrent-docker-builds-github-actions) for how to measure real overlap, and [can two jobs share one Docker builder](/answers/can-two-jobs-share-one-docker-builder) for what sharing does to a single build. Two facts push the other way. A shared profile keeps one cache, so five images built from the same base layers reuse each other's work, while five profiles each pay the base layer cost once. Disk works the same way in reverse: five 100GB profiles hold 500GB of layers in total against 200GB on the shared profile, so the split option starts evicting later as branches accumulate. Split profiles when images have different base layers, and share when they do not. Then measure. `GET /reports/billing/docker-builder` takes `start_date`, `end_date`, and a `profiles` filter, and returns per-session billing rows plus a summary for the range, which replaces the assumed session counts above with real ones. Rates here are the builder profile list rates from the [pricing page](/pricing) and the [remote Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13, and the full size table is on the [remote Docker builder catalog page](/runners/docker-builders). ## [FAQ](#faq)Copy Anchor Link ### [Can I change the size of an existing builder profile?](#can-i-change-the-size-of-an-existing-builder-profile)Copy Anchor Link The builder profile API exposes create, list, read, reset cache, and delete. There is no update call, so a change to `cpu` or `architecture` through the API means creating a second profile and pointing `profile-name` at it. The second profile starts with an empty cache, so schedule the switch where one cold build is acceptable. ### [Does every builder profile keep its own layer cache?](#does-every-builder-profile-keep-its-own-layer-cache)Copy Anchor Link Yes. A profile maps to one dedicated builder VM and the cache lives on that VM's disk, so two profiles never share layers. Every job that passes the same `profile-name` reaches the same cache, whichever repository it runs in. ### [How do I find out how many builder session minutes a profile used?](#how-do-i-find-out-how-many-builder-session-minutes-a-profile-used)Copy Anchor Link Call `GET /reports/billing/docker-builder` with `start_date` and `end_date`, and filter by profile. The response carries a session list and a summary for the range, which is the measured replacement for the assumed session counts in any cost model. --- # Why Your Docker Layer Cache Misses Every Run | WarpBuild URL: https://www.warpbuild.com/guides/docker-cache-invalidation-github-actions Description: A Docker layer cache misses every run when an early layer changes: a whole-context COPY, a build arg consumed too early, lockfile churn, or a reset builder. # Why Your Docker Layer Cache Misses Every Run A Docker layer cache misses every run when an early layer changes: a whole-context COPY, a build arg consumed too early, lockfile churn, or a reset builder. Last verified: 2026-08-13 A Docker layer cache misses on every GitHub Actions run because one instruction near the top of the image changes each time, and BuildKit invalidates every layer after the first instruction whose inputs moved. In practice the trigger is a `COPY . .` placed above the dependency install, a build arg consumed early, lockfile churn, a multi-arch build that keeps one cache per architecture, or a builder whose cache was reset. This guide is the diagnosis half of the problem. Keeping a layer cache alive between runs is covered in [Docker layer caching on GitHub Actions](/guides/docker-layer-caching-github-actions), and the trade-offs between exported cache backends are covered in [comparing buildx cache backends on GitHub Actions](/guides/buildx-cache-backends-github-actions). What follows is how to find the layer that broke, the Dockerfile change that fixes the common case, the platform-side causes that no Dockerfile edit will touch, and a time model that prices a single cache miss. ## [Diagnosis](#diagnosis)Copy Anchor Link BuildKit computes a cache key per instruction from the parent layer digest, the instruction text, and the digests of the inputs that instruction reads. A `COPY` reads the contents and mode of the files it copies. A `RUN` reads the command string plus whatever its parent layer contains. Change any of those and the layer misses, and every layer below it misses with it, whatever the cache backend is. ### [Find the first uncached instruction](#find-the-first-uncached-instruction)Copy Anchor Link Run the build with plain progress output so every step prints: Copy Text ``` docker buildx build --progress=plain -t acme/api:diag . ``` Read the log from the top and stop at the first step without a `CACHED` prefix. Everything after that point is a miss by definition, so the steps below it carry no information. The single instruction you stopped at is the diagnosis, and the four causes below are what usually sits there. ### [A whole-context COPY above the dependency install](#a-whole-context-copy-above-the-dependency-install)Copy Anchor Link `COPY . .` before the package install is the most common cause. Its cache key covers every file in the build context, so any edit anywhere in the repository invalidates it and the dependency install below it. Two details make this worse than teams expect. A missing or thin `.dockerignore` pulls files into the context that the image never uses, so a README edit, a workflow file change, or a stray `.git` directory rewrites the digest. And generated files that land in the context before the build carry churn of their own: a version file written by a release step, a `.env` rendered from templates, or code generation output that embeds a timestamp or a build ID rewrites bytes on every run even when the logical content is identical. Keep generated files out of the context, or generate them after the dependency layers. Where a generator insists on stamping a timestamp, normalize it with `SOURCE_DATE_EPOCH` or strip the stamped line before the copy. ### [Lockfile churn](#lockfile-churn)Copy Anchor Link Once ordering is right, the dependency layer is only as stable as the lockfile. Three patterns break it: a dependency bot opening lockfile-only pull requests most days, a build step that regenerates the lockfile instead of honoring it, and a range constraint that resolves to a new patch release inside the build. Measure the churn rather than guessing at it. On the default branch, count how many of the last 100 commits touched a dependency input: Copy Text ``` git log --oneline -100 -- package.json package-lock.json | wc -l ``` That number over 100 is roughly the miss rate you should expect for the dependency layer, and it is the input the cost model at the end of this guide takes. If it comes back near 40, the fix belongs in dependency policy rather than in the Dockerfile. ### [Build args baked into early layers](#build-args-baked-into-early-layers)Copy Anchor Link A build arg invalidates every instruction that references it. The trap is a version stamp declared and consumed at the top of the file: Copy Text ``` ARG GIT_SHA ENV APP_VERSION=${GIT_SHA} ``` `GIT_SHA` changes on every commit, so the `ENV` layer misses on every commit, and the dependency install placed below it misses too. The same applies to `BUILD_DATE`, to a `CACHEBUST` arg someone added during an incident and never removed, and to any arg carrying a per-run value. The value has to reach the image, so the fix is placement rather than removal: consume it after the layers you want to keep. ### [Multi-arch builds split the cache per architecture](#multi-arch-builds-split-the-cache-per-architecture)Copy Anchor Link For multi-arch builds, each architecture runs on a separate builder instance, which means one cache per architecture and one billed session per architecture. A workflow that builds `linux/amd64` on pull requests and `linux/amd64,linux/arm64` on release tags keeps the amd64 half warm and leaves the arm64 half cold on most releases. Two profiles never share a cache either, so a per-repository profile naming convention that drifted, or a typo in `profile-name`, sends a build to a builder that has never seen the image. Multi-stage images add one more path: an invalidated shared base stage cascades into every stage built from it, which is covered in [multi-stage Docker builds on GitHub Actions](/guides/multi-stage-docker-builds-github-actions). ### [The platform-side causes](#the-platform-side-causes)Copy Anchor Link Three causes sit outside the Dockerfile, and no ordering change touches them. The first is the TTL. A WarpBuild builder profile cache is reset automatically after 10 days without use. A profile driven by a nightly workflow stays warm. A profile driven by a release workflow that runs every two or three weeks is cold on most releases, and the symptom is a cold build with no code change to explain it. The second is a manual reset. Cache reset is an API call, so it is easy for a teammate to run one during an incident and easy for nobody to remember afterwards. The recovery is one warm build. The third is eventual consistency. The cache on a profile is shared between concurrent builds but eventually consistent, so a layer produced by one in-flight build may not be visible to another build already in flight; a later build picks it up after synchronization. Two pull requests opened a minute apart will both build the same base layers once. This is a property of concurrency rather than a fault, and it is worth knowing before you spend an afternoon on it. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. One more symptom belongs here because it looks like a cache problem and is really a sizing problem. A `Failed to commit cache (Docker)` error usually means the layers are large and the runner is small; layers above about 5GB on a `2x` runner will produce it. Move the job to a larger runner size. ## [Fix](#fix)Copy Anchor Link Reorder the Dockerfile so the layers you want to keep sit above the inputs that change most often. Here is a Python service before and after. Before, with the whole context copied first and a version stamp at the top: Copy Text ``` FROM python:3.12-slim ARG GIT_SHA ENV APP_VERSION=${GIT_SHA} WORKDIR /app COPY . . RUN apt-get update && apt-get install -y --no-install-recommends build-essential RUN pip install -r requirements.txt RUN python manage.py collectstatic --noinput CMD ["gunicorn", "app.main:app"] ``` After, with dependencies above the source copy and the stamp at the bottom: Copy Text ``` FROM python:3.12-slim WORKDIR /app RUN apt-get update \ && apt-get install -y --no-install-recommends build-essential \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt ./ RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt COPY . . RUN python manage.py collectstatic --noinput ARG GIT_SHA ENV APP_VERSION=${GIT_SHA} CMD ["gunicorn", "app.main:app"] ``` The edits read as three moves: Copy Text ``` FROM python:3.12-slim - ARG GIT_SHA - ENV APP_VERSION=${GIT_SHA} WORKDIR /app - COPY . . - RUN apt-get update && apt-get install -y --no-install-recommends build-essential - RUN pip install -r requirements.txt + RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt ./ + RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt + COPY . . RUN python manage.py collectstatic --noinput + ARG GIT_SHA + ENV APP_VERSION=${GIT_SHA} CMD ["gunicorn", "app.main:app"] ``` What each commit type now rebuilds: | Commit type | First invalidated instruction | Layers rebuilt | | -------------------------------- | ----------------------------- | ---------------------------------- | | Source file only | COPY . . | source copy, collectstatic, export | | requirements.txt edit | COPY requirements.txt ./ | pip install and everything below | | Base image bump | FROM python:3.12-slim | all | | New GIT\_SHA with no code change | ARG GIT\_SHA | final config layer only | The `--mount=type=cache` line adds a second layer of protection. Even when `requirements.txt` changes, pip reuses its own download cache from the builder disk instead of refetching every wheel, and wheels built from source stay built. That cache mount lives on the builder, so it survives with the layer cache and disappears with a per-job buildx instance. Ordering only pays off if the cache survives the job, which is what a builder profile provides: one dedicated build VM per profile with a persistent layer cache on local disk, selected from the workflow by name. Remote Docker builders sit in the WarpBuild product surface alongside snapshot runners, CI observability, an MCP server, and the Action Debugger. ## [Configuration](#configuration)Copy Anchor Link ### [Symptom to change](#symptom-to-change)Copy Anchor Link | Symptom in the build log | Cause | Change | | ----------------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------ | | First non-CACHED step is COPY . . above the install | whole context copied first | copy the manifest and lockfile first, then install, then copy the source | | First non-CACHED step reads $GIT\_SHA or $BUILD\_DATE | build arg consumed early | declare and consume the arg below the dependency layers | | Dependency layer misses on most commits | lockfile churn | measure with git log, pin ranges, batch bot updates | | arm64 misses while amd64 hits | one cache per architecture | build both architectures on the default branch | | Everything misses after a quiet period | profile cache reset after 10 unused days | schedule a warmup build inside the window | | Two concurrent builds both miss | shared cache is eventually consistent | stagger the burst, or let the next build pick it up | | Failed to commit cache (Docker) | layers above about 5GB on a small runner | move the job to a larger runner size | ### [Keep both architectures warm](#keep-both-architectures-warm)Copy Anchor Link Building both architectures on the default branch keeps both halves of a multi-arch profile fed, and passing the version stamp as a build arg is safe once the arg is consumed at the bottom of the Dockerfile: Copy Text ``` name: docker on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ghcr.io/acme/api:${{ github.sha }} profile-name: api-multiarch build-args: | GIT_SHA=${{ github.sha }} ``` Both architectures must be enabled on the profile in the WarpBuild UI, otherwise the build fails with `exec format error`. Keep Docker builds on the Linux runners, since macOS runners do not support nested virtualization and cannot run Docker. ### [Trim the build context](#trim-the-build-context)Copy Anchor Link Copy Text ``` .git .github docs *.md tests/fixtures node_modules .venv ``` Every path removed here is a path that can no longer invalidate a `COPY`. ### [Reset a profile on purpose](#reset-a-profile-on-purpose)Copy Anchor Link Reset after a base image change that leaves a large volume of dead layers, or when a profile disk is close to full: Copy Text ``` curl -s -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles?per_page=30&page=1" curl -s -X POST \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles/$BUILDER_PROFILE_ID/cache/reset" ``` Session billing, the bake action, and the full input list are in the [remote Docker builders documentation](/docs/ci/docker-builders), and the drop-in cache action for dependency caching outside the Docker build is in the [WarpBuild caching documentation](/docs/ci/features/caching). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The model below is arithmetic on stated assumptions rather than a measurement. Replace each row with the step durations in your own build log before you act on the result. ### [Assumptions](#assumptions)Copy Anchor Link * A Python service image, 2.6 GB final size, eight layers, built from `python:3.12-slim`. * 700 image builds per month. * 0.5 minutes of job overhead per build for checkout, registry login, and action setup. * Builds arrive in bursts of four on one builder profile, staggered by 0.5 minutes, and a session is billed from the first job start to the last job completion. * Job runner `warp-ubuntu-latest-x64-4x` at $0.008 per minute and a 16 vCPU builder profile at $0.06 per minute, both from the [pricing page](/pricing). ### [One cache miss, step by step](#one-cache-miss-step-by-step)Copy Anchor Link | Step | Cold | Warm | | ----------------------------------------------------- | ---- | ---- | | Pull python:3.12-slim | 0:20 | 0:00 | | apt-get install build-essential | 1:35 | 0:00 | | COPY requirements.txt ./ | 0:02 | 0:00 | | pip install, 180 packages, 3 wheels built from source | 4:10 | 0:00 | | COPY . . | 0:08 | 0:08 | | collectstatic | 0:35 | 0:35 | | Export and push changed layers | 2:10 | 0:47 | | Total | 9:00 | 1:30 | With overhead, a cold build occupies its job runner for 9.5 minutes and a warm one for 2.0 minutes. One avoidable miss therefore costs 7.5 extra job minutes at $0.008 and, for a build billed in a session of its own, 7.5 extra builder minutes at $0.06\. That is $0.51 and 7.5 minutes of wall clock that a pull request spends waiting. Keep the number in mind when someone proposes adding a `CACHEBUST` arg at the top of the Dockerfile. ### [Rates used](#rates-used)Copy Anchor Link | Runner label | Shape | Price per minute | | -------------------------- | -------------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | | Builder size | Disk | Price per minute | Architectures | | ---------------- | ----- | ---------------- | ------------------- | | 16 vCPU, 32 GB | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU, 64 GB | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU, 128 GB | 200GB | $0.24 | amd64, arm64, multi | | 96 vCPU, 192 GB | 600GB | $0.36 | amd64 | | 96 vCPU, 192 GB | 2TB | $0.52 | amd64 | | 192 vCPU, 384 GB | 600GB | $0.72 | amd64 | | 192 vCPU, 384 GB | 2TB | $0.88 | amd64 | arm64 and multi-arch profiles top out at 64 vCPU. The 96 vCPU and 192 vCPU sizes are amd64 only. ### [Monthly cost at three miss rates](#monthly-cost-at-three-miss-rates)Copy Anchor Link A burst of four warm builds holds a session for 3.0 minutes, which is 0.75 builder minutes per build. A burst of four cold builds holds it for 10.5 minutes, which is 2.625 builder minutes per build. | Miss rate | Cold builds | Job minutes | Builder minutes | Monthly cost | | ----------- | ----------- | --------------- | ---------------- | ------------ | | 100 percent | 700 | 6,650 at $0.008 | 1,837.5 at $0.06 | $163.45 | | 40 percent | 280 | 3,500 at $0.008 | 1,050.0 at $0.06 | $91.00 | | 8 percent | 56 | 1,820 at $0.008 | 630.0 at $0.06 | $52.36 | Moving from an invalidation on every run to an invalidation on one run in twelve takes the monthly bill from $163.45 to $52.36 on these assumptions and cuts the wait on a pull request from 9.5 minutes to 2.0 minutes. The miss rate is the lever the Dockerfile ordering controls, and the `git log` count above is how you find your current position on this table. For a baseline, GitHub's 4-core Linux larger runner lists at $0.012 per minute in the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. Running all 700 builds cold on that runner with no persistent cache is 6,650 minutes, or $79.80 per month, with every pull request waiting the full 9.5 minutes. Rates for every runner size and builder size are on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link ### [Why does my Docker cache miss when the Dockerfile did not change?](#why-does-my-docker-cache-miss-when-the-dockerfile-did-not-change)Copy Anchor Link The Dockerfile is only one input. BuildKit invalidates a layer when the inputs of that instruction change, then invalidates every layer after it, so a source file that lands in an early `COPY` or a build arg consumed near the top rebuilds the rest of the image. Run the build with `--progress=plain` and find the first step that prints without a `CACHED` prefix; that instruction is the one whose inputs moved. ### [Do build args invalidate the Docker layer cache?](#do-build-args-invalidate-the-docker-layer-cache)Copy Anchor Link Yes, for every instruction that references them. An `ARG GIT_SHA` or `ARG BUILD_DATE` declared and consumed above the dependency install changes value on every commit, so the dependency layer and everything after it rebuilds each run. Declare the `ARG` and the `ENV` that consumes it below the dependency layers. ### [Why did my layer cache go cold after a quiet week?](#why-did-my-layer-cache-go-cold-after-a-quiet-week)Copy Anchor Link A WarpBuild builder profile cache has a TTL of 10 days and a profile that goes unused for longer than that is reset automatically. A manual cache reset through the WarpBuild API has the same effect. A profile driven only by a release workflow that runs every two weeks will be cold on most releases. ### [Why do two builds that start at the same time both miss?](#why-do-two-builds-that-start-at-the-same-time-both-miss)Copy Anchor Link The layer cache on a builder profile is shared between concurrent builds but eventually consistent, so a layer written by one in-flight build may not be visible to another build already running. Later builds pick it up after the cache synchronizes. Multi-arch builds are a second case, since each architecture runs on its own builder instance with its own cache. --- # Docker Layer Caching on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/docker-layer-caching-github-actions Description: Docker layer caches miss on GitHub Actions because every job builds on a fresh buildx instance. Move the cache onto a builder that outlives the job. # Docker Layer Caching on GitHub Actions Docker layer caches miss on GitHub Actions because every job builds on a fresh buildx instance. Move the cache onto a builder that outlives the job. Last verified: 2026-08-13 Docker layer caching misses on almost every GitHub Actions run for one structural reason: the job gets a fresh machine, so the buildx instance it creates starts with an empty layer store and rebuilds or re-downloads every layer. The durable fix is to run the build on a builder that outlives the job, which is what a WarpBuild remote Docker builder profile gives you: a dedicated build VM that keeps its layer cache on local disk between runs, selected by name from the workflow. Per-minute rates for job runners and builder profiles are on the [pricing page](/pricing), and the model at the end of this guide works them through. This guide covers the four reasons a layer cache misses, the workflow change that fixes the common case, the exact configuration for `Warpbuilds/build-push-action`, and a worked time and cost model you can re-run with your own numbers. For the wider picture of Docker on GitHub Actions, start at [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link Before changing anything, work out which of these four is producing your misses. They have different fixes and they stack. ### [A fresh buildx instance per job](#a-fresh-buildx-instance-per-job)Copy Anchor Link `docker/setup-buildx-action` creates a builder inside the job. The container that backs it is created when the step runs and destroyed when the job ends, along with the machine underneath it. Its layer store therefore starts empty on every run. Nothing in a default buildx setup carries state between two jobs. The symptom is a build log where every `RUN` and every `COPY` line prints as executed, with no `CACHED` prefix, on a commit that touched one source file. If you see `CACHED` on none of your layers even when the Dockerfile and the lockfile are unchanged since the last green run, the builder is the problem and no Dockerfile change will help. ### [cache-from and cache-to that never populate](#cache-from-and-cache-to-that-never-populate)Copy Anchor Link The usual response is to add an external cache backend: Copy Text ``` - uses: docker/build-push-action@v6 with: cache-from: type=gha cache-to: type=gha,mode=max ``` This can work, and it fails quietly in three common ways. First, `cache-to` runs at the end of the build, so a failed or cancelled build writes nothing and the next run is still cold. Second, the cache scope is keyed by branch by default, so a pull request branch reads its own empty scope rather than the cache written by the last build on the default branch. Third, `mode=max` stores every intermediate layer, which makes the export large, and a large export competes with the size and eviction limits of whatever backend you pointed it at. See [working around the GitHub Actions cache size limit](/guides/github-actions-cache-size-limit) for how those limits bite. The symptom here is a build that spends real time in `importing cache manifest` and `exporting cache` steps and still prints no `CACHED` lines. That means the manifest resolved but the layer digests did not match, which sends you to the next two causes. ### [COPY ordering that invalidates early layers](#copy-ordering-that-invalidates-early-layers)Copy Anchor Link BuildKit invalidates a layer when its inputs change, then invalidates every layer after it. A Dockerfile that copies the whole context before installing dependencies invalidates the dependency install on every commit, because the context includes the source file you just edited. Copy Text ``` FROM node:22-slim WORKDIR /app COPY . . RUN npm ci RUN npm run build CMD ["node", "dist/server.js"] ``` Every commit changes something under `COPY . .`, so `npm ci` reruns even when no dependency moved. The ordered version separates the two inputs: Copy Text ``` FROM node:22-slim WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci COPY . . RUN npm run build CMD ["node", "dist/server.js"] ``` Now `npm ci` only reruns when `package.json` or `package-lock.json` changes. The `--mount=type=cache` line adds a second layer of protection: the package manager's own download cache lives in a BuildKit cache mount, which survives on a persistent builder and disappears with a per-job buildx instance. That difference is one concrete reason a persistent builder reuses more work than a per-job buildx instance on the same Dockerfile. Apply the same ordering to every language. Copy `go.mod` and `go.sum` before the rest of a Go tree, `pyproject.toml` and the lock file before a Python tree, `Cargo.toml` and `Cargo.lock` before a Rust tree. ### [Lockfile churn](#lockfile-churn)Copy Anchor Link Once ordering is correct, the dependency layer is only as stable as the lockfile. Three patterns break it: * A dependency bot that opens lockfile-only pull requests daily, so the default branch cache is invalidated most days. * A build step that regenerates the lockfile rather than honoring it, which produces a different hash per run even with identical dependencies. * A range constraint that resolves to a new patch release, which rewrites the lockfile inside the build. Check the frequency yourself. Run `git log --oneline --since="30 days ago" -- package-lock.json | wc -l` on the default branch. If that number is close to your commit count, the dependency layer will miss most of the time whatever caching backend you use, and the fix belongs in dependency policy rather than in the workflow. ## [Fix](#fix)Copy Anchor Link Move the build off the job runner and onto a builder profile that keeps its cache. A WarpBuild builder profile is one dedicated Docker builder VM with a persistent layer cache on local disk, addressed from the workflow by `profile-name`. Creating one and sizing it is covered in [set up remote Docker builders for GitHub Actions](/guides/remote-docker-builders). Jobs come and go; the profile and its cache stay. Remote Docker builders are part of the WarpBuild product surface alongside snapshot runners, CI observability, an MCP server, and the Action Debugger. Here is a working before and after on a pull request build that pushes to GitHub Container Registry. Before, with a per-job buildx instance and an external cache backend: Copy Text ``` name: docker on: pull_request: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/api:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max ``` After, with a builder profile that keeps its cache: Copy Text ``` name: docker on: pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/api:${{ github.sha }} profile-name: api-amd64 timeout: 600000 ``` The change reads as four edits: Copy Text ``` - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-x64-2x - - uses: docker/setup-buildx-action@v3 - - uses: docker/build-push-action@v6 + - uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/api:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max + profile-name: api-amd64 + timeout: 600000 ``` Two of those edits deserve a note. The `docker/setup-buildx-action` step goes away because the WarpBuild action configures the remote builder itself. The `cache-from` and `cache-to` lines go away because a cached builder profile keeps its layers on disk and reuses them for subsequent builds, so the export and import round trip buys nothing. The job runner shrinks too. With the build running on the builder, the job checks out the repository, authenticates to the registry, and waits, so `warp-ubuntu-latest-x64-2x` is usually enough. For this workflow pick a Linux runner, since macOS runners do not support nested virtualization and cannot run Docker. ## [Configuration](#configuration)Copy Anchor Link ### [The action inputs that matter](#the-action-inputs-that-matter)Copy Anchor Link | Input | Value | Notes | | ------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | | profile-name | Name of the builder profile | Selects the builder VM and therefore the cache. Required. | | api-key | ${{ secrets.WARPBUILD\_API\_KEY }} | Not required on WarpBuild runners. Required when the job runs on a GitHub-hosted runner or any other machine. | | timeout | Milliseconds to wait for the builder | Defaults to 10 minutes for Warpbuilds/build-push-action. | | platforms | linux/amd64,linux/arm64 | Both architectures must be enabled on the profile in the WarpBuild UI, otherwise the build fails with exec format error. | Running from a GitHub-hosted runner works with the same action plus the key: Copy Text ``` jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ghcr.io/acme/api:${{ github.sha }} profile-name: api-multiarch api-key: ${{ secrets.WARPBUILD_API_KEY }} ``` If your build has custom steps between setup and `docker build`, use `Warpbuilds/docker-configure@v1` instead. It configures the builder and outputs its details, leaving the build command to you. Invoke it immediately before the build step, since the builder is billed from the moment it is assigned. ### [What persists between runs and what resets](#what-persists-between-runs-and-what-resets)Copy Anchor Link This is the part teams get wrong, so be precise about the boundaries. | Item | Behavior between runs | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | Docker layer cache on the builder disk | Persists on the builder profile until 10 days of no use, or until you reset it. | | BuildKit cache mounts written by RUN --mount=type=cache | Persist on the builder disk with the layer cache. | | Job runner filesystem and workspace | Reset every job. Runner storage is ephemeral and is deleted when the runner terminates. | | Registry credentials from docker/login-action | Reset every job. They live on the job runner. | | amd64 and arm64 layers inside one multi-arch profile | Kept separately. Each architecture runs on a separate builder instance, so each keeps its own cache. | | Layers in a different builder profile | Never shared. Each profile is one builder VM with one cache. | Two boundaries follow from that table. The cache is scoped to the builder profile, so every job naming the same `profile-name` reaches the same cache regardless of which repository it runs in, and no cache is shared between two profiles or across your organization as a whole. And for jobs building at the same time on one profile, the cache is shared but eventually consistent, so a layer produced by one in-flight build may not be visible to another until it syncs; a later build picks it up. Dependency caches that live outside the Docker build follow different rules, covered in [persistent caches for GitHub Actions jobs](/guides/persistent-caches-github-actions). Those two facts drive profile layout. One profile per image gives each image a clean, dense cache. One profile shared across a monorepo's images gives you fewer sessions to pay for and a cache that has to hold more. Split when images have different base layers; share when they do not. ### [Resetting and inspecting a profile](#resetting-and-inspecting-a-profile)Copy Anchor Link Cache reset is one of them, so list your profiles and reset the one you want: Copy Text ``` curl -s -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles?per_page=30&page=1" curl -s -X POST \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles/$BUILDER_PROFILE_ID/cache/reset" ``` Reset after a base image change that leaves a large volume of dead layers, or when a profile's disk is close to full. The full input list, the bake action, the CLI flow, and the session billing rules live in the [remote Docker builders documentation](/docs/ci/docker-builders); the drop-in cache action for dependency caching is covered in the [WarpBuild caching documentation](/docs/ci/features/caching). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The model below is arithmetic on stated assumptions rather than a measurement. Replace each row with the step durations printed in your own job log before you use the result to make a decision. ### [Assumptions](#assumptions)Copy Anchor Link * A Node.js service image, 1.8 GB final size, seven layers, built from `node:22-slim`. * Build step durations assumed for a 4 vCPU class machine. The model holds those durations constant across every option, so the only variable is the layer cache. A 16 vCPU builder has more cores than a 4 vCPU job runner and the model gives it no credit for that. * 600 image builds per month. One build in ten is cold, meaning a base image bump, a Dockerfile change, or a lockfile change invalidates the dependency layer: 60 cold and 540 warm. * 0.5 minutes of per-job overhead for checkout, registry login, and action setup. * For the external cache backend option, 2.0 minutes per warm build to import and export the layer cache, and 1.0 minute extra on a cold build to export it in full. This is the number you should replace first, since it varies most. * Builds arrive in bursts and share a builder session. Starts are staggered by 0.5 minutes. ### [Cold versus warm layer cache](#cold-versus-warm-layer-cache)Copy Anchor Link | Step | Cold | Warm | | ----------------------------------- | ---- | ---- | | Pull node:22-slim base image | 0:25 | 0:00 | | Install system build dependencies | 1:10 | 0:00 | | COPY package.json package-lock.json | 0:02 | 0:00 | | npm ci, 1,400 packages | 2:30 | 0:00 | | COPY . . | 0:03 | 0:03 | | npm run build | 2:00 | 2:00 | | Export and push changed layers | 1:50 | 0:40 | | Total | 8:00 | 2:43 | Adding the 0.5 minutes of job overhead, a warm pull request build occupies its job runner for 3.2 minutes and a cold one for 8.5 minutes. ### [Rates used](#rates-used)Copy Anchor Link Linux x64 job runners, from the [WarpBuild pricing page](/pricing): | Runner label | Shape | Storage | Price per minute | | -------------------------- | --------------- | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | 150GB SSD | $0.064 | Builder profiles, billed per session from when the builder action starts until the job completes: | Builder size | Disk | Price per minute | Architectures | | ---------------- | ----- | ---------------- | ------------------- | | 16 vCPU, 32 GB | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU, 64 GB | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU, 128 GB | 200GB | $0.24 | amd64, arm64, multi | | 96 vCPU, 192 GB | 600GB | $0.36 | amd64 | | 96 vCPU, 192 GB | 2TB | $0.52 | amd64 | | 192 vCPU, 384 GB | 600GB | $0.72 | amd64 | | 192 vCPU, 384 GB | 2TB | $0.88 | amd64 | arm64 and multi-arch profiles top out at 64 vCPU. The 96 vCPU and 192 vCPU sizes are amd64 only. The GitHub-hosted baseline is the 4-core Linux larger runner at $0.012 per minute, from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing), checked on 2026-08-13\. For a same-shape reference, `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for that runner: 33 percent lower list price. GitHub list price checked on 2026-08-13. ### [Monthly model, 600 builds](#monthly-model-600-builds)Copy Anchor Link | Option | Job minutes | Builder minutes | Monthly cost | | ------------------------------------------------------------------------- | --------------- | --------------- | ------------ | | A. GitHub 4-core larger runner, in-job buildx, no layer cache | 5,100 at $0.012 | 0 | $61.20 | | B. GitHub 4-core larger runner, in-job buildx, external cache backend | 3,378 at $0.012 | 0 | $40.54 | | C. warp-ubuntu-latest-x64-2x plus a 16 vCPU profile, 4 builds per session | 2,238 at $0.004 | 709.5 at $0.06 | $51.52 | | D. Same as C, 8 builds per session | 2,238 at $0.004 | 504.75 at $0.06 | $39.24 | Reading the table straight: on these assumptions options C and D cut the wall clock a pull request waits from 8.5 minutes to 3.2 minutes, because the warm path skips the base pull, the system dependencies, and `npm ci` entirely. On list price, C already beats the uncached option A by $9.68 per month, and the crossover against the externally cached option B lands near eight builds sharing a session. Since the model gives the 16 vCPU builder no credit for having more cores than the 4 vCPU job runner, a real crossover arrives earlier than eight. Two levers move that crossover. Session sharing is the first: concurrent jobs on one profile are billed as a single session from the first job's start to the last job's completion, so batching pull request builds onto one profile spreads the session cost. Disk size is the second: a 100 GB profile disk holds a 1.8 GB image's layers for many branches, and a profile serving several large images wants the 200 GB size before eviction starts costing you hit rate. The layer cache on a builder profile is billed inside the session rate; the separate storage rate of $0.20 per GB-month and $0.0001 per operation applies to the `WarpBuilds/cache` action used for dependency caching rather than to builder profile disks. That is enough to run this model against your own repository before you commit to it. ## [FAQ](#faq)Copy Anchor Link ### [Why does my Docker layer cache miss on every GitHub Actions run?](#why-does-my-docker-layer-cache-miss-on-every-github-actions-run)Copy Anchor Link A GitHub Actions job runs on a fresh machine, so the buildx instance created inside the job starts with an empty layer store. Unless the cache is exported to and imported from an external backend, or the build runs on a builder that outlives the job, every run rebuilds every layer. ### [Do I still need cache-from and cache-to with a WarpBuild builder profile?](#do-i-still-need-cache-from-and-cache-to-with-a-warpbuild-builder-profile)Copy Anchor Link No. The builder profile keeps its layer cache on the builder VM's local disk, so `cache-from` and `cache-to` are not required and should be removed. Leaving them in place adds an export and import round trip that the persistent cache already makes unnecessary. ### [Is the WarpBuild layer cache shared across repositories?](#is-the-warpbuild-layer-cache-shared-across-repositories)Copy Anchor Link The cache belongs to the builder profile rather than to a repository or to your organization as a whole. Every job that names the same `profile-name` reaches the same builder VM and the same cache, whichever repository it runs in. Two different profiles never share a cache. ### [How long does a builder profile keep its layer cache?](#how-long-does-a-builder-profile-keep-its-layer-cache)Copy Anchor Link The builder cache has a TTL of 10 days. A builder profile that goes unused for more than 10 days is reset automatically. You can also reset a profile's cache on demand through the WarpBuild API. --- # Dockerfile Changes That Cut Build Time | WarpBuild URL: https://www.warpbuild.com/guides/dockerfile-optimization-github-actions Description: Most Dockerfile build time on GitHub Actions is rebuilt work. Order instructions by change frequency, add BuildKit cache mounts, keep the cache on a builder. # Dockerfile Changes That Cut Build Time Most Dockerfile build time on GitHub Actions is rebuilt work. Order instructions by change frequency, add BuildKit cache mounts, keep the cache on a builder. Last verified: 2026-08-13 Most of the time a Dockerfile spends on GitHub Actions is spent rebuilding instructions whose cache key changed, usually because a source copy sits above the dependency install and invalidates everything below it. Two edits fix the common case: order instructions by how often their inputs change, and move package manager download directories into BuildKit cache mounts so a dependency change replays from local files instead of the network. Both edits only pay off when the layer cache and the mounts outlive the job. Per-minute rates for job runners and builder profiles are on the [pricing page](/pricing). This guide covers how to find the instruction that invalidates your cache, a before and after Dockerfile with the layers that survive a source change, the cache mount syntax per ecosystem, and a time and cost model with the builder rate applied. For the wider picture, start at [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link Run the build once with plain progress output and read it from the top: Copy Text ``` docker buildx build --progress=plain -t api:local . ``` [BuildKit](/glossary/buildkit) prints `#8 CACHED` for a reused instruction and `#8 DONE 12.3s` for one it executed. The first line without `CACHED` is the instruction that broke the chain, and every instruction after it in the same stage rebuilt because of it. Chasing the slowest step is the wrong move when a cheap step above it is the one that missed. Three shapes account for most of what you will find. **A source copy above the dependency install.** `COPY . .` hashes the whole build context, so any edited file changes its cache key and the install below it reruns. This is the single most common cause and the one the next section fixes. **A build context that carries files the image never uses.** Look for a `transferring context: 412.55MB` line near the start of the output. Without a `.dockerignore`, `.git`, virtualenvs, `node_modules`, and previous build output all enter the context, so they cost transfer time and they change the context hash on runs where nothing relevant moved. **A package manager that redownloads on every dependency change.** Even with correct ordering, one added library invalidates the dependency layer and the installer refetches every package from the network. Cache mounts are what stop that, and they are covered under Configuration. A fourth cause sits outside the Dockerfile: a fresh buildx instance per job starts with an empty layer store, so a correctly ordered Dockerfile still rebuilds from zero on every run. That is a builder problem rather than a file problem. ## [Fix](#fix)Copy Anchor Link Order instructions by how often their inputs change, least often first. System packages change monthly, dependency manifests change weekly, source changes on every commit, so they belong in that order top to bottom. Before, with the source copy at the top: Copy Text ``` FROM python:3.12-slim WORKDIR /app COPY . . RUN apt-get update && apt-get install -y build-essential RUN pip install -r requirements.txt RUN python -m compileall app CMD ["gunicorn", "app.main:app"] ``` After, with the copy split in two and cache mounts added: Copy Text ``` # syntax=docker/dockerfile:1.7 FROM python:3.12-slim WORKDIR /app RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \ rm -f /etc/apt/apt.conf.d/docker-clean && \ apt-get update && \ apt-get install -y --no-install-recommends build-essential COPY requirements.txt ./ RUN --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txt COPY . . RUN python -m compileall app CMD ["gunicorn", "app.main:app"] ``` Here is what each instruction does on a commit that edits one Python file: | Instruction | Before layout | After layout | Time returned | | ------------------------------- | ---------------- | ------------------- | ------------- | | apt-get install build-essential | rebuilds | CACHED | 0:55 | | COPY requirements.txt | part of COPY . . | CACHED | 0:01 | | pip install -r requirements.txt | rebuilds | CACHED | 2:20 | | COPY . . | rebuilds | rebuilds | 0:00 | | python -m compileall app | rebuilds | rebuilds | 0:00 | | Export and push changed layers | full set | changed layers only | 0:55 | The `.dockerignore` belongs with the change, since `COPY . .` is only as stable as the context it reads: Copy Text ``` .git .venv __pycache__ *.pyc node_modules dist ``` Apply the same split to every ecosystem: `package.json` and the lockfile before a Node tree, `go.mod` and `go.sum` before a Go tree, `Cargo.toml` and `Cargo.lock` before a Rust tree, `pom.xml` before a Java tree. When the ordering is right and the image is still large or still slow to push, the next levers are stage layout, covered in [multi stage Docker builds and layer reuse](/guides/multi-stage-docker-builds-github-actions), and the size of what you ship, covered in [container image size reduction](/guides/container-image-size-reduction-github-actions). ## [Configuration](#configuration)Copy Anchor Link ### [Cache mounts by package manager](#cache-mounts-by-package-manager)Copy Anchor Link A cache mount is attached for the duration of one `RUN` instruction and is never committed into the layer, so it speeds up the rebuild without adding image size. Use `sharing=locked` where the tool holds no lock of its own. | Ecosystem | Mount target | What the mount keeps between builds | | --------- | ----------------------------------------- | ---------------------------------------------------------------- | | npm | /root/.npm | Downloaded package tarballs, keyed by version and integrity hash | | pnpm | /root/.local/share/pnpm/store | The content-addressable store that every project links against | | pip | /root/.cache/pip | Downloaded and locally built wheels | | uv | /root/.cache/uv | Resolved wheels and the source distributions built from them | | Go | /root/.cache/go-build and /go/pkg/mod | Compiled package archives and downloaded modules | | Cargo | /usr/local/cargo/registry and /app/target | Crate sources and compiled artifacts for unchanged crates | | Maven | /root/.m2/repository | Resolved jars and their metadata | | Gradle | /root/.gradle/caches | Dependency jars and the build cache entries | | apt | /var/cache/apt and /var/lib/apt/lists | Downloaded .deb files and package index lists | The `# syntax=docker/dockerfile:1.7` directive at the top of the file pins the frontend that parses these mounts, which keeps behavior stable across builder versions. ### [Keeping the mounts between runs](#keeping-the-mounts-between-runs)Copy Anchor Link Cache mounts live on the builder disk, so they disappear with a per-job buildx instance and they are not carried by registry or GitHub Actions cache exports. Point the build at a builder profile instead, which is one dedicated build VM with persistent layer cache and mount storage, selected by name: Copy Text ``` name: docker on: pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/api:${{ github.sha }} profile-name: api-amd64 ``` The job runner stays small because the build runs elsewhere: checkout, registry login, and wait. Pick a Linux runner here, since macOS runners do not support nested virtualization and cannot run Docker. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the product surface. Builder profile inputs, session billing, and cache reset are documented in the [remote Docker builders documentation](/docs/ci/docker-builders). Dependency caching outside the Docker build uses a separate action, covered in the [caching documentation](/docs/ci/features/caching). Builder sizes and disks are listed on [remote Docker builders](/runners/docker-builders). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The model is arithmetic on stated assumptions rather than a measurement. Replace each row with the durations printed in your own build log before you act on the result. ### [Assumptions](#assumptions)Copy Anchor Link * The Python service above: `python:3.12-slim` base, 180 packages in `requirements.txt`, roughly 900 MB final image. * 400 image builds per month: 10 with a base image bump (cold builder), 40 with a `requirements.txt` change, 350 that touch source only. * 0.5 minutes of job overhead per build for checkout, registry login, and action setup. * Pull request builds arrive in bursts, four to a builder session, staggered by 0.5 minutes. * Job runner `warp-ubuntu-latest-x64-2x` at $0.004 per minute and a 16 vCPU, 32 GB, 100 GB disk builder profile at $0.06 per minute, both from the [pricing page](/pricing). ### [Three cache states on the reordered Dockerfile](#three-cache-states-on-the-reordered-dockerfile)Copy Anchor Link | Step | Cold builder | requirements.txt change | Source change | | ------------------------------- | ------------ | ----------------------- | ------------- | | Pull python:3.12-slim | 0:20 | 0:00 | 0:00 | | apt-get install build-essential | 0:55 | 0:00 | 0:00 | | COPY requirements.txt | 0:01 | 0:01 | 0:00 | | pip install -r requirements.txt | 2:20 | 0:50 | 0:00 | | COPY . . | 0:04 | 0:04 | 0:04 | | python -m compileall app | 0:35 | 0:35 | 0:35 | | Export and push changed layers | 1:20 | 0:50 | 0:25 | | Total | 5:35 | 2:20 | 1:04 | The middle column is what the cache mounts buy. The dependency layer is invalid, so `pip install` runs, and it resolves from wheels already on the builder disk rather than from the network: 0:50 instead of 2:20. With the before layout on the same builder, `COPY . .` invalidates everything under it, so every build runs apt, runs `pip install` over the network, and exports the full layer set: 5:14 per build whatever changed. ### [Monthly cost, 400 builds](#monthly-cost-400-builds)Copy Anchor Link | Layout | Builder minutes | Builder cost | Job minutes | Job cost | Total | | --------------------------------------------- | --------------- | ------------ | ----------------- | -------- | ------ | | Before: source copy on top, no cache mounts | 854.2 at $0.06 | $51.25 | 2,296.7 at $0.004 | $9.19 | $60.44 | | After: ordered instructions plus cache mounts | 373.7 at $0.06 | $22.42 | 722.5 at $0.004 | $2.89 | $25.31 | The wall clock a pull request waits moves from 5.73 minutes to 1.57 minutes on these numbers, and the monthly bill on the same 400 builds moves by $35.13\. Two levers move it further: more builds per session, since concurrent jobs on one profile are billed as a single session from the first job's start to the last job's completion, and a larger profile disk when several images share a profile and eviction starts costing hit rate. The job runner itself carries a list price edge on top of the builder savings: `warp-ubuntu-latest-x64-2x` costs $0.004 per minute against $0.006 per minute for GitHub-hosted `ubuntu-latest`, 33 percent lower list price, from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. ## [FAQ](#faq)Copy Anchor Link ### [Does RUN --mount=type=cache make the image bigger?](#does-run---mounttypecache-make-the-image-bigger)Copy Anchor Link No. A cache mount is attached for the duration of the `RUN` instruction and is never committed into the resulting layer, so the package manager download directory adds nothing to image size. The tradeoff is that the mount lives on the builder rather than in the image, so it only survives when the builder survives. ### [Why does my apt cache mount stay empty on Debian and Ubuntu images?](#why-does-my-apt-cache-mount-stay-empty-on-debian-and-ubuntu-images)Copy Anchor Link Those base images ship `/etc/apt/apt.conf.d/docker-clean`, which deletes downloaded .deb files after every install and sets `Keep-Downloaded-Packages` to false. Remove that file or write a `Keep-Downloaded-Packages true` config inside the same `RUN` instruction before `apt-get update`, otherwise the mount is emptied before the build ends. ### [Do cache mounts survive between GitHub Actions runs?](#do-cache-mounts-survive-between-github-actions-runs)Copy Anchor Link Only when the build runs on a builder that outlives the job. A buildx instance created inside a job is destroyed with the job, and registry or GitHub Actions cache exports carry layer blobs rather than cache mount contents, so the next run starts with an empty mount. --- # Parallel .NET Test Runs on Windows Runners | WarpBuild URL: https://www.warpbuild.com/guides/dotnet-test-parallelism-github-actions Description: dotnet test runs assemblies one at a time by default and leaves in-assembly parallelism to the framework. Turn on both levels, then size the Windows label. # Parallel .NET Test Runs on Windows Runners dotnet test runs assemblies one at a time by default and leaves in-assembly parallelism to the framework. Turn on both levels, then size the Windows label. Last verified: 2026-08-13 `dotnet test` runs test assemblies one process at a time by default, and whether the tests inside a single assembly run concurrently is decided by the test framework rather than by the .NET CLI. Parallel .NET test runs on GitHub Actions therefore need both levels turned on, and only after that does a larger Windows label such as `warp-windows-latest-x64-16x` at $0.064 per minute return anything for the extra cores. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. This guide covers the Windows side of a .NET test job: how to tell which parallelism level is capped, what to set at each level, how to tie those settings to the label size, and what one large runner costs against four small ones once both levels are correct. ## [Diagnosis](#diagnosis)Copy Anchor Link Start with peak CPU during the test step rather than with the total job duration. The Jobs section of the WarpBuild [Reports page](/docs/ci/features/reports) aggregates every run of a given repository, workflow, and job name and prints Duration P75 and P90, Queue Time P75 and P90, and CPU and Memory P75 and P90\. Those CPU percentiles turn the question into arithmetic. One fully busy core on a 16 vCPU runner reports about 6 percent peak CPU. One busy core on a 4 vCPU runner reports about 25 percent. So a `warp-windows-latest-x64-16x` job whose test step peaks near 6 percent is running a single-threaded test host on a machine renting 16 cores, and no label change fixes that. CPU and memory percentiles come from the runner agent telemetry that WarpBuild's CI observability collects, so enable observability on the runner before reading those columns. Four causes account for almost every capped .NET test run: * **Assembly-level parallelism is off.** The VSTest `RunConfiguration.MaxCpuCount` setting decides how many test host processes run at once, and it defaults to one. A solution with 12 test projects then executes 12 assemblies back to back. * **The runsettings file is never loaded.** A `.runsettings` file in the repository root does nothing unless `dotnet test` receives `--settings`, and a workflow that was copied between repositories often drops that argument. * **In-assembly parallelism is off or unsafe.** NUnit and MSTest run tests sequentially inside an assembly until you opt in with an assembly-level attribute, and xUnit parallelizes test collections but keeps everything inside one collection when the suite uses a single shared fixture. * **Thread count exceeds the label.** The opposite failure. Sixteen concurrent test host processes, each spawning worker threads sized to the machine, oversubscribe a 16 vCPU runner, and the extra context switching shows up as high CPU with a longer wall clock than a smaller setting produces. Parallel-unsafe tests read as flakiness rather than as slowness: tests that pass alone and fail together usually share a static field, a fixed TCP port, a file under the working directory, or one database. Reproduce those interactively with the Action Debugger against the same label instead of pushing commits to guess. ## [Fix](#fix)Copy Anchor Link Work down this order. The first two settings cost nothing per minute and usually return more wall clock than a bigger label does. **1\. Turn on in-assembly parallelism in the framework.** In xUnit, set `parallelizeTestCollections` and `maxParallelThreads` in [xunit.runner.json](https://xunit.net/docs/configuration-files). In NUnit, add `[assembly: Parallelizable(ParallelScope.Fixtures)]` and set `LevelOfParallelism`, documented on the [Parallelizable attribute page](https://docs.nunit.org/articles/nunit/writing-tests/attributes/parallelizable.html). In MSTest, add `[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)]`, where zero means the processor count, described in the [MSTest documentation](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-with-mstest). **2\. Turn on assembly-level parallelism in VSTest.** Set `MaxCpuCount` under `RunConfiguration` in a [.runsettings file](https://learn.microsoft.com/en-us/visualstudio/test/configure-unit-tests-by-using-a-dot-runsettings-file), or pass it inline through [dotnet test](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-test) as `-- RunConfiguration.MaxCpuCount=N`. Zero means every available core. Solutions that have moved to Microsoft Testing Platform configure this level through that platform's own options rather than through VSTest runsettings. **3\. Make the suite parallel-safe before you widen it.** Give each test class its own temporary directory, bind to port zero and read back the assigned port, and give each worker its own database name or schema. Until that work is done, raising either level converts a slow suite into a flaky one. **4\. Match the two levels to the vCPU count of the label.** Total concurrent test threads should land near the core count. The product of `MaxCpuCount` and the framework thread setting is the number to control, since each test host process runs its own in-assembly parallelism. **5\. Shard across runners only after both levels are set.** Sharding hides a serialized test host behind more machines and bills you for the disguise. Once the settings are right, sharding is a wall-clock decision covered in [sharding a test suite across GitHub Actions jobs](/guides/matrix-sharding-github-actions). ## [Configuration](#configuration)Copy Anchor Link ### [The two levels and what controls each](#the-two-levels-and-what-controls-each)Copy Anchor Link | Level | What runs in parallel | Where you set it | Default | | ------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | Test host processes | Test assemblies, one process per assembly | RunConfiguration.MaxCpuCount in .runsettings, or dotnet test -- RunConfiguration.MaxCpuCount=0 | 1, so assemblies run one after another | | Inside one assembly | Collections, fixtures, or methods within a single assembly | xUnit: parallelizeTestCollections and maxParallelThreads in xunit.runner.json. NUnit: \[assembly: Parallelizable\] plus LevelOfParallelism. MSTest: \[assembly: Parallelize(Workers, Scope)\] | xUnit parallelizes collections. NUnit and MSTest run sequentially until the attribute is added | ### [Settings by Windows label size](#settings-by-windows-label-size)Copy Anchor Link Windows labels run from 4 to 32 vCPU. These starting points keep the product of the two levels near the core count, leaving headroom for the operating system, Microsoft Defender, and the runner agent. | Label | vCPU | RAM | Per minute | MaxCpuCount | Framework threads per host | | --------------------------- | ---- | ------ | ---------- | ----------- | -------------------------- | | warp-windows-latest-x64-4x | 4 | 16 GB | $0.016 | 2 | 2 | | warp-windows-latest-x64-8x | 8 | 32 GB | $0.032 | 4 | 2 | | warp-windows-latest-x64-16x | 16 | 64 GB | $0.064 | 4 | 4 | | warp-windows-latest-x64-32x | 32 | 128 GB | $0.128 | 8 | 4 | Rates and shapes come from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The full Windows catalog with image families, aliases, and regions lives on [WarpBuild Windows runners for GitHub Actions](/runners/windows). ### [Configuration files](#configuration-files)Copy Anchor Link Copy Text ``` // xunit.runner.json, committed next to each test project { "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", "parallelizeAssembly": false, "parallelizeTestCollections": true, "maxParallelThreads": 4 } ``` Copy Text ``` 4 TestResults ``` ### [The workflow, one large runner](#the-workflow-one-large-runner)Copy Anchor Link `MaxCpuCount` is passed on the command line here so the value follows the label size through one matrix input rather than living in a file that every label shares. Copy Text ``` name: dotnet-tests on: pull_request: push: branches: [main] env: DOTNET_NOLOGO: "true" DOTNET_CLI_TELEMETRY_OPTOUT: "true" MSBUILDDISABLENODEREUSE: "1" NUGET_PACKAGES: C:\nuget jobs: test: runs-on: warp-windows-latest-x64-16x timeout-minutes: 30 steps: - uses: actions/checkout@v4 with: fetch-depth: 1 - uses: actions/setup-dotnet@v4 with: dotnet-version: "9.0.x" - uses: actions/cache@v4 with: path: C:\nuget key: nuget-windows-${{ hashFiles('**/packages.lock.json') }} restore-keys: | nuget-windows- - name: Build run: > dotnet build Contoso.sln --configuration Release -maxcpucount - name: Test with both parallelism levels on env: XUNIT_MAX_PARALLEL_THREADS: "4" run: > dotnet test Contoso.sln --configuration Release --no-build --settings ci.runsettings --logger trx -- RunConfiguration.MaxCpuCount=4 ``` ### [The workflow, four small runners](#the-workflow-four-small-runners)Copy Anchor Link The same suite split four ways. Each shard rents 4 vCPU, so the per-host settings drop to match. Copy Text ``` jobs: test: runs-on: warp-windows-latest-x64-4x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 with: fetch-depth: 1 - uses: actions/setup-dotnet@v4 with: dotnet-version: "9.0.x" - name: Test shard ${{ matrix.shard }} run: > dotnet test Contoso.sln --configuration Release --filter "Shard=${{ matrix.shard }}" --settings ci.runsettings --logger trx -- RunConfiguration.MaxCpuCount=2 ``` ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link WarpBuild Windows rates are linear in vCPU: every Windows vCPU-minute lists at $0.004, so the two layouts rent identical capacity at an identical price. | Layout | Labels | vCPU rented | Total per minute | Per vCPU-minute | | ------------------------------ | -------------------------------- | ----------- | ---------------- | --------------- | | One large runner | warp-windows-latest-x64-16x | 16 | $0.064 | $0.004 | | Four shards | 4 x warp-windows-latest-x64-4x | 16 | $0.064 | $0.004 | | One GitHub-hosted large runner | 16-core Windows larger runner | 16 | $0.082 | $0.005125 | | Four GitHub-hosted shards | 4 x 4-core Windows larger runner | 16 | $0.088 | $0.0055 | `warp-windows-latest-x64-16x` (16 vCPU, 64 GB) costs $0.064 per minute against $0.082 per minute for the 16-core Windows larger runner at the same 16 vCPU and 64 GB shape, a 22 percent lower list price, and `warp-windows-latest-x64-4x` (4 vCPU, 16 GB) costs $0.016 per minute against $0.022 per minute for the 4-core Windows larger runner at the same 4 vCPU and 16 GB shape, a 27 percent lower list price ([GitHub billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). The bottom two rows carry a second consequence. GitHub's larger-runner list prices are not linear in vCPU, so the sharded layout there rents the same 16 cores for $0.088 per minute against $0.082, and the layout choice moves the bill. On WarpBuild the layout is neutral on cost, which leaves the decision where it belongs: which arrangement keeps more cores busy. ### [A worked model](#a-worked-model)Copy Anchor Link The inputs below are assumptions to replace with numbers from your own run history. Nothing here is a measured result. * A suite of 12 test assemblies, 4 minutes of sequential work each, 48 minutes in total. * Fixed per-job overhead of 4 minutes for boot, checkout, SDK setup, cache restore, and build. * Billing at one-minute granularity, which is how WarpBuild bills GitHub Actions runners. * 600 test runs per month. | Layout and settings | Test minutes | Job minutes | Billed runner-minutes | Cost per run | Cost per month | | ----------------------------------- | ------------ | ----------- | --------------------- | ------------ | -------------- | | One 16x, both levels default | 48 | 52 | 52 | $3.328 | $1,996.80 | | Four 4x shards, both levels default | 12 | 16 | 64 | $1.024 | $614.40 | | One 16x, both levels set | 4 | 8 | 8 | $0.512 | $307.20 | | Four 4x shards, both levels set | 4 | 8 | 32 | $0.512 | $307.20 | Three readings come out of that table. Fixing the settings on the machine you already rent takes the monthly line from $1,996.80 to $307.20, which is the largest single move available. Sharding an unfixed suite takes it to $614.40, so it buys wall clock while still paying for idle cores. And once both levels are set, the two layouts land on the same cost and the same wall clock, because per-job overhead scales with the cores rented and cancels out at a flat $0.004 per vCPU-minute. The tie breaks on details the model leaves out. Four shards restore the NuGet cache four times, and on a 4 vCPU machine that restore runs slower than it does on 16 vCPU, so overhead per shard grows. One large runner keeps one warm process tree and one cache restore, and it fails as a single unit when a shard would have isolated the failure. Shards also let a suite whose longest assembly dominates finish in the time of the longest shard rather than the longest assembly. Current rates for every label are on the [pricing page](/pricing). The .NET toolchain decisions around this test step, including image family and NuGet handling, are covered in [running .NET builds on GitHub Actions](/solutions/dotnet-github-actions), and the short version of the parallelism question is answered in [can I run unit tests in parallel on Windows runners](/answers/can-i-run-unit-tests-in-parallel-on-windows-runners). ## [FAQ](#faq)Copy Anchor Link ### [Why does dotnet test still run one assembly at a time on a 16 vCPU Windows runner?](#why-does-dotnet-test-still-run-one-assembly-at-a-time-on-a-16-vcpu-windows-runner)Copy Anchor Link Because the VSTest setting that controls assembly-level parallelism defaults to one process. Pass `RunConfiguration.MaxCpuCount=0` on the command line, or set `MaxCpuCount` in a `.runsettings` file and point `dotnet test` at it with `--settings`. Confirm the file is actually loaded, since a runsettings file sitting in the repository root is ignored unless the workflow passes it. ### [Should I run one 16 vCPU Windows runner or four 4 vCPU runners?](#should-i-run-one-16-vcpu-windows-runner-or-four-4-vcpu-runners)Copy Anchor Link WarpBuild Windows rates are linear at $0.004 per vCPU-minute, so `warp-windows-latest-x64-16x` at $0.064 per minute and four `warp-windows-latest-x64-4x` runners at $0.016 each cost the same per minute for the same 16 vCPU. Choose on whether one test host layout keeps 16 cores busy. If a single machine plateaus below four times what one 4 vCPU shard achieves, shard. ### [How do I tell whether the runner is sitting idle during the test step?](#how-do-i-tell-whether-the-runner-is-sitting-idle-during-the-test-step)Copy Anchor Link Read the CPU P75 and P90 columns on the Jobs section of the WarpBuild Reports page. One busy core on a 16 vCPU runner reports around 6 percent peak CPU, and one busy core on a 4 vCPU runner reports around 25 percent. Peak CPU well under the core count during the test step means the parallelism settings are capping the run rather than the machine size. --- # What Pulling Images from ECR Costs Your Pipeline | WarpBuild URL: https://www.warpbuild.com/guides/ecr-pull-costs-github-actions Description: Pulls from ECR bill your AWS account rather than the GitHub invoice. Where registry charges land in a deploy pipeline, plus a worked monthly model. # What Pulling Images from ECR Costs Your Pipeline Pulls from ECR bill your AWS account rather than the GitHub invoice. Where registry charges land in a deploy pipeline, plus a worked monthly model. Last verified: 2026-08-13 Pulling a container image out of ECR inside a GitHub Actions job costs you twice: once for the bytes crossing a metered network path in your AWS account, and once for the storage holding every retained image version at $0.10 per GB-month ([Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/), checked on 2026-08-13). None of it appears on the GitHub invoice, which is why a registry line can grow for a year before anyone attributes it to a workflow. This guide shows which paths are metered, which pulls in a normal deploy pipeline are avoidable, and what the monthly total looks like with every assumption written down. ## [Diagnosis](#diagnosis)Copy Anchor Link Registry charges split into three network shapes and one storage shape. Which network shape you are paying depends entirely on where the runner sits relative to the registry, and none of that is visible from a workflow file. ### [Cross-availability-zone pulls](#cross-availability-zone-pulls)Copy Anchor Link Inside a single Region, a pull can still cross an Availability Zone boundary on its way from the registry path to the instance running the job. AWS prices data transferred across Availability Zones in the same Region at $0.01 per GB in each direction ([Amazon EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/), checked on 2026-08-13). That rate looks small until you multiply it by pull count. It is also the charge that appears when a self-hosted fleet spreads across three zones for instance availability while the resources it reads sit in one. Check which path your pulls take before you assume this line is zero. ### [Managed NAT data processing](#managed-nat-data-processing)Copy Anchor Link Runners that need a stable outbound IP address get placed in private subnets, and everything they move then leaves through a managed NAT service. In US East (N. Virginia), AWS charges $0.045 per hour for the service plus $0.045 per GB of data processed, in both directions ([Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13). This is the largest surprise on most bills, because it prices bytes that AWS would otherwise carry at $0.00 per GB. A 1.2 GB image pulled 105 times a day moves 126 GB a day through a meter that charges per gigabyte regardless of source or destination. AWS documents private connectivity options for registry traffic inside your own VPC; those belong to your AWS configuration and are priced on the AWS pages linked above. ### [Cross-region transfer](#cross-region-transfer)Copy Anchor Link When the runner sits outside the Region that holds the registry, or outside the account entirely, the pull leaves the Region. AWS states that data transferred between Amazon ECR and other services in different Regions is charged at internet data transfer rates on both sides of the transfer, and the worked example on that page uses $0.09 per GB (checked on 2026-08-13). The first 100 GB of internet data transfer out per month is free, aggregated across all AWS services and Regions, and production has usually spent that allowance before GitHub Actions asks for any. By the same pricing page, data transferred between Amazon ECR and other services in the same Region, including Amazon EC2, is free of charge at $0.00 per GB. The workflow file is identical in both cases. Machine placement is the whole difference. ### [Storage, including pull-through cache](#storage-including-pull-through-cache)Copy Anchor Link ECR bills $0.10 per GB-month for stored images in private repositories. Every tag your pipeline pushes per merge accumulates, and pull-through cache repositories add upstream public images to the same meter. Teams that adopt pull-through cache to escape upstream rate limits are trading a rate limit for a storage line, which is usually the right trade, and it still belongs in the model. ### [How many pulls a normal pipeline makes](#how-many-pulls-a-normal-pipeline-makes)Copy Anchor Link Count the jobs that actually pull, rather than the merges. A single-image pipeline with one build and a fan-out looks like this per merge: | Job in the pipeline | Image pulls per merge | Bytes at 1.2 GB per pull | | ----------------------------------- | --------------------- | ------------------------ | | build and push | 0, it pushes | 0 GB | | integration tests, 4 matrix legs | 4 | 4.8 GB | | container image scan | 1 | 1.2 GB | | smoke test against staging | 1 | 1.2 GB | | release promotion by pull and retag | 1 | 1.2 GB | | Total per merge | 7 | 8.4 GB | Generally available Linux and Windows runners do not have plan-level concurrency caps of WarpBuild runners and capacity adjusts dynamically, so nothing throttles this fan-out on your behalf. The byte count per merge is the number you control. ### [Confirming it on your own bill](#confirming-it-on-your-own-bill)Copy Anchor Link Open Cost Explorer, group by Usage Type, and filter to the account or tag that carries the runner fleet. Four usage types cover the shapes above: `NatGateway-Bytes`, `DataTransfer-Regional-Bytes`, `DataTransfer-Out-Bytes`, and the ECR `TimedStorage-ByteHrs` line. If the daily curve rises with merge volume and stays flat when end-user traffic moves, the pipeline owns that spend. ## [Fix](#fix)Copy Anchor Link Three levers, in the order that pays back fastest. The general egress picture across artifacts and caches is covered in [GitHub Actions egress costs](/guides/github-actions-egress-costs); this section stays on the registry. ### [Ship fewer bytes per pull](#ship-fewer-bytes-per-pull)Copy Anchor Link A multi-stage Dockerfile that copies only the compiled output into a slim runtime base cuts what every future job pulls, permanently. Pin base images by digest so an upstream retag stops invalidating layers across the fleet, and keep test fixtures and sample data out of the runtime image so the test legs stop paying to download them. Order layers so the volatile ones sit last. Pulls fetch only the layers the runner lacks, so stable base layers stay cached on a warm machine while the application layer is the only thing that moves. ### [Reuse layers instead of repulling them](#reuse-layers-instead-of-repulling-them)Copy Anchor Link Push the layer cache to a cache tag in the same registry as the image, so unchanged layers stop moving on every build. Remote Docker builders keep a persistent layer cache on the builder itself, which takes the cache traffic off the runner path entirely; the setup is in the [Docker builders documentation](/docs/ci/docker-builders), and WarpBuild also ships snapshot runners, GitHub Actions observability, an MCP server, and the Action Debugger around the same workflows. Then cut redundant pulls. Four matrix legs on four machines pull four copies of the same image; running the same suites in one job on a larger runner pulls once. Promotion is another free win, because a tag can be moved with the ECR API against the existing manifest rather than a pull followed by a push. More on the pull-time side of this in [how to reduce Docker image pull time in GitHub Actions](/answers/how-do-i-reduce-docker-image-pull-time-in-github-actions). ### [Put the registry and the runners in the same Region](#put-the-registry-and-the-runners-in-the-same-region)Copy Anchor Link This is the structural fix, and by AWS's own published rate it takes the transfer line to $0.00 per GB. WarpBuild BYOC runs runners inside your own AWS, GCP, or Azure account, and Terraform support exists for BYOC on AWS, so the placement lives in the same module as the rest of your infrastructure. The [AWS BYOC configuration guide](/docs/ci/byoc/aws/config) lists the VPC, subnet, security group, and quota prerequisites, plus the bucket the stack uses for cache and telemetry. Colocation leaves one residue: runners in private subnets still pay managed NAT data processing on everything they move, package registries and the GitHub API included. Keep the fleet in public subnets unless an allowlisted static IP is a hard requirement for that workflow. Teams that want registry pull charges off the bill entirely qualify on the enterprise tier, where runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you, whether your runners live in your cloud or ours; the scope is on [zero egress for the enterprise tier](/enterprise/zero-egress). ## [Configuration](#configuration)Copy Anchor Link ### [Build once, push once](#build-once-push-once)Copy Anchor Link The build job below pushes through a remote Docker builder profile and exports the digest for downstream jobs, so nothing after it needs to resolve a tag. Copy Text ``` name: deploy on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x permissions: id-token: write contents: read outputs: digest: ${{ steps.push.outputs.digest }} steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - uses: Warpbuilds/build-push-action@v6 id: push with: context: . push: true profile-name: api-builder tags: ${{ steps.ecr.outputs.registry }}/api:${{ github.sha }} ``` ### [Collapse four pulls into one](#collapse-four-pulls-into-one)Copy Anchor Link Four matrix legs pull 4.8 GB per merge at a 1.2 GB image. The same suites in one job on an 8 vCPU runner pull 1.2 GB and finish with the same coverage. Copy Text ``` integration: needs: build runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - run: docker pull "${{ steps.ecr.outputs.registry }}/api@${{ needs.build.outputs.digest }}" - run: ./scripts/integration.sh --suites api,worker,web,billing ``` ### [Promote a release without pulling it](#promote-a-release-without-pulling-it)Copy Anchor Link The promotion job moves a tag against the manifest already in ECR, so it transfers kilobytes instead of gigabytes. Copy Text ``` promote: needs: [build, integration] runs-on: warp-ubuntu-latest-x64-2x steps: - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy aws-region: us-east-1 - name: Retag in place run: | MANIFEST=$(aws ecr batch-get-image \ --repository-name api \ --image-ids imageDigest=${{ needs.build.outputs.digest }} \ --query 'images[0].imageManifest' --output text) aws ecr put-image \ --repository-name api \ --image-tag release \ --image-manifest "$MANIFEST" ``` ### [Move the same jobs into your own account](#move-the-same-jobs-into-your-own-account)Copy Anchor Link BYOC runners register with a `warp-custom-` prefix followed by the runner name chosen in the dashboard. Setting the stack Region to the Region that holds the registry is what turns the pull into same-Region traffic. Copy Text ``` integration: runs-on: warp-custom-deploy-use1 ``` ### [Labels and rates used above](#labels-and-rates-used-above)Copy Anchor Link Linux x64 labels from the WarpBuild catalog, with per-minute rates from the [pricing page](/pricing): | Runner label | OS | vCPU | RAM | Storage | Price per minute | | -------------------------- | ------------ | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.064 | The full label set is in the [cloud runners documentation](/docs/ci/cloud-runners). BYOC runners are billed at $0.002 per minute in WarpBuild fees on Linux, with the compute billed by your own cloud account. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Assumptions](#assumptions)Copy Anchor Link Every input is stated so you can substitute your own. AWS rates are list prices for US East (N. Virginia), checked on 2026-08-13. | Input | Value | Source | | ------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------- | | Merges to main per weekday | 15 | Your workflow run history | | Weekdays per month | 22 | Calendar | | Image pulls per merge | 7 | The pipeline table above | | Compressed image size | 1.2 GB | Your registry metrics | | Pulling job duration | 7 minutes | Your workflow run history | | Retained image storage in ECR | 40 GB | Your registry metrics | | ECR storage rate | $0.10 per GB-month | [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/) | | ECR to same-Region compute | $0.00 per GB | [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/) | | Cross-Region and internet transfer | $0.09 per GB | [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/) | | Free internet transfer allowance | 100 GB per month, all services | [Amazon EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/) | | Cross-Availability-Zone transfer | $0.01 per GB each direction | [Amazon EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/) | | Managed NAT data processing | $0.045 per GB | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/) | | Managed NAT hourly charge | $0.045 per hour | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/) | | GitHub-hosted ubuntu-latest, private repositories | $0.006 per minute | [GitHub Actions billing](https://docs.github.com/en/billing/reference/actions-minute-multipliers) | ### [The arithmetic](#the-arithmetic)Copy Anchor Link Monthly pulls: 15 merges times 7 pulls times 22 weekdays equals 2,310. Monthly bytes pulled: 2,310 times 1.2 equals 2,772 GB. Runner minutes on the pulling jobs: 2,310 times 7 equals 16,170 minutes. Now price the same 2,772 GB by placement. | Runner placement | Monthly pull charge | Fixed charge | Monthly total | | ---------------------------------------------------- | -------------------------------------------- | ------------------------------------ | ------------- | | Outside the Region, over the internet path | 2,672 GB billable times $0.09 equals $240.48 | $0.00 | $240.48 | | Same Region, private subnets behind managed NAT | 2,772 GB times $0.045 equals $124.74 | 730 hours times $0.045 equals $32.85 | $157.59 | | Same Region, crossing one Availability Zone boundary | 2,772 GB times $0.01 equals $27.72 | $0.00 | $27.72 | | Same Region, same zone, public subnets | 2,772 GB times $0.00 equals $0.00 | $0.00 | $0.00 | Row one credits the 100 GB monthly allowance once. If production already consumed it, add $9.00. Storage sits on top of every row: 40 GB times $0.10 equals $4.00 per month. ### [What the two fixes are worth](#what-the-two-fixes-are-worth)Copy Anchor Link Apply the levers from the Fix section to the managed NAT row, which is the common case for fleets with static IPs. | Change | Pulls per merge | GB per month | Managed NAT monthly total | | --------------------------------------------- | --------------- | ------------ | ------------------------- | | Starting point, 1.2 GB image | 7 | 2,772 | $157.59 | | Collapse 4 matrix legs, retag without pulling | 3 | 1,188 | $86.31 | | Also cut the image to 0.4 GB | 3 | 396 | $50.67 | The image size lever and the pull count lever multiply. Getting from $157.59 to $50.67 needed no change of provider, no new tooling, and no reduction in test coverage. ### [The minute line next to it](#the-minute-line-next-to-it)Copy Anchor Link At 16,170 minutes a month, GitHub's published rate of $0.006 per minute for `ubuntu-latest` on private repositories is $97.02 ([GitHub Actions billing](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). The same minutes on `warp-ubuntu-latest-x64-2x` at $0.004 per minute are $64.68 ([pricing](/pricing)). Read those two numbers against the starting registry line of $157.59\. The bytes cost more than the compute in this pipeline, and no dashboard inside GitHub shows the byte side at all. ### [Where WarpBuild fits](#where-warpbuild-fits)Copy Anchor Link WarpBuild pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the runner side of the model is the per-minute rates in the label table. Signup includes $10 free credits, which covers about 350 runs of the 7 minute integration job on `warp-ubuntu-latest-x64-2x` while you compare Cost Explorer before and after. Use the [pricing page](/pricing) for the full rate table, and measure both job duration and ECR transfer charges before projecting a fleet total. For the registry side, BYOC puts the runners in the account and Region that already holds ECR, so pulls become same-Region traffic priced by your cloud provider. The object storage half of the same problem is covered in [S3 transfer costs from GitHub Actions](/guides/s3-transfer-costs-github-actions). Cloud list prices change. Re-check the three AWS links and the GitHub billing reference before quoting these totals internally, and re-run the arithmetic with your own image size and pull count. ## [FAQ](#faq)Copy Anchor Link **Does GitHub bill us for pulling images from ECR?** No. GitHub bills GitHub Actions minutes and artifact storage. The gigabytes a job pulls out of ECR are metered by AWS, so the charge lands on your AWS bill as data transfer or as managed NAT data processing. **Why is our ECR line larger than the image size suggests?** Two charges stack. Storage is billed at $0.10 per GB-month for every retained image version, and every pull is billed again on whichever network path it takes. A pipeline that pulls one image in seven jobs per merge moves seven copies of it. **Do runners in the same Region as ECR pull for free?** AWS prices data transferred between Amazon ECR and other services in the same Region at $0.00 per GB, checked on 2026-08-13\. Runners placed in private subnets still pay managed NAT data processing at $0.045 per GB in US East (N. Virginia) on everything that leaves through that path. **Can registry pull charges reach zero?** On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you, and that applies whether your runners live in your cloud or ours. The scope is on [zero egress for the enterprise tier](/enterprise/zero-egress). --- # Speeding Up Git Checkout in GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/faster-git-checkout-github-actions Description: Git checkout is slow in GitHub Actions when every job clones the whole repository from GitHub. WarpBuilds/checkout@v7 seeds the tree from a cached mirror. # Speeding Up Git Checkout in GitHub Actions Git checkout is slow in GitHub Actions when every job clones the whole repository from GitHub. WarpBuilds/checkout@v7 seeds the tree from a cached mirror. Last verified: 2026-08-13 Git checkout is slow in GitHub Actions because every job downloads the repository from GitHub again, and the size of that download tracks history depth, submodules, and LFS objects rather than the code the pull request touched. On WarpBuild runners you shrink the download by swapping `actions/checkout` for `WarpBuilds/checkout@v7`, which seeds the working tree from a cached git mirror so the fetch from GitHub covers only the new commits. This page covers how to measure what your checkout actually costs, how the mirror works, the exact workflow change, and the arithmetic that turns seconds of checkout per job into weekly runner minutes. ## [Diagnosis](#diagnosis)Copy Anchor Link Checkout is two operations wearing one name. The action fetches git objects over the network, then writes a working tree to disk. Disk writes scale with the size of the tree you check out. Network time scales with the size of the object download, and that is where the minutes go. Five inputs decide the size of that download. Measure each one on a fresh clone of your own repository before changing anything. | Driver | What it adds to the download | How to measure it | | ----------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | Pack volume | The compressed object store for the history you request | git count-objects -vH and read size-pack | | Fetch depth | fetch-depth: 0 replaces a one-commit fetch with the entire history | git rev-list --count HEAD for commit count, then grep workflows for fetch-depth | | Submodules | One additional fetch per module, each with its own handshake and auth | git config --file .gitmodules --get-regexp url | | Git LFS | Binary objects stored outside the pack and pulled separately | git lfs ls-files -s | | Job fan-out | Repeats all of the above once per job in the run | Count the jobs a single workflow run creates | `actions/checkout` defaults to `fetch-depth: 1`, so a plain workflow pulls one commit and stays cheap. The expensive workflows are the ones that had a reason to change that default. Changed-file detection, `git describe` version stamping, monorepo affected-target calculation, and coverage tools that diff against the merge base all want history, and the usual fix is `fetch-depth: 0`. That one line converts a small fetch into the whole pack on every job that carries it. Submodules behave the same way at a smaller scale each and a larger scale in aggregate. `submodules: recursive` makes checkout walk the module graph and fetch each entry, so a repository with eight vendored modules performs nine fetches rather than one, each paying its own connection setup. LFS is separate again. `lfs: true` pulls the binary payloads that the pack only points at, and on repositories that store fixtures, models, or game assets that payload is often larger than the entire git history. The fifth driver is the one teams miss, because it is invisible in a single job log. Fan-out multiplies every driver above. A workflow with a 4-way test shard, a lint job, a type-check job, and two build jobs is eight checkouts of the same commit, issued within seconds of each other, against the same repository on GitHub. Run that workflow on 30 open pull requests in an afternoon and it is 240 full fetches of a tree that changed by a handful of files. WarpBuild adds capacity to that shape rather than throttling it: run as many jobs as your workflows need, since generally available Linux and Windows runners do not have plan-level concurrency caps. Wide fan-out is the correct answer for wall-clock time, and it is exactly what makes per-job checkout cost worth attacking. To get your own number, read the Checkout step duration in a job log across ten recent runs and take the median rather than the best case. Then compare a pull request job against a job on the default branch. If the two differ sharply, the difference is usually branch-specific fetch work rather than the base history. ## [Fix](#fix)Copy Anchor Link Swap the action. Copy Text ``` - - uses: actions/checkout@v4 + - uses: WarpBuilds/checkout@v7 ``` [WarpBuilds/checkout](https://github.com/WarpBuilds/checkout) is a fork of `actions/checkout` that adds git-mirror caching on WarpBuild runners. It shipped on July 14, 2026 and is recorded in the [WarpBuild changelog](/docs/ci/changelog). The mechanism is a cache in front of GitHub rather than a change to git itself: * The mirror holds a per-repo `base` bundle carrying full history, built once, plus a small per-branch delta bundle. * The action seeds that mirror onto the runner before checkout runs, then fetches from GitHub only the commits that arrived after the mirror was built. * The seed arrives over parallel ranged reads from object storage rather than a single stream from GitHub. * The remaining fetch against GitHub is small, which drops the request load your fan-out places on GitHub. Three properties make the swap low risk. It takes no new inputs. Every `actions/checkout` input keeps working unchanged: `repository`, `ref`, `token`, `ssh-key`, `fetch-depth`, `sparse-checkout`, `lfs`, and the rest. Workflows that pin `fetch-depth: 0` or `submodules: recursive` keep those lines exactly as they are. It adds one output, `cache-hit`, so you can tell from the log whether a run was seeded from the mirror or fell through to a plain fetch. It fails open. Any cache error degrades to stock `actions/checkout` behavior, so a cold mirror or an unreachable cache produces a slower checkout instead of a failed job. No fallback logic belongs in your workflow. The mirror lives on WarpBuild runners, so the job has to be running on a `warp-` label for it to apply. The labels and rates for the Linux fleet are in the [cloud runners catalog](/docs/ci/cloud-runners) and repeated in the cost model below. The changelog entry publishes no measurement, so this page attaches no speed number to the swap. Take the median Checkout duration you collected in the diagnosis step, run ten more jobs after the change, and compare the two medians on your own repository. ## [Configuration](#configuration)Copy Anchor Link Here is a complete workflow. It runs a 4-way shard on `warp-ubuntu-latest-x64-4x`, a 4 vCPU, 16 GB Ubuntu 24.04 runner, keeps full history for changed-file detection, and logs the mirror result. Copy Text ``` name: ci on: pull_request: merge_group: push: branches: [main] jobs: test: runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - name: Checkout id: checkout uses: WarpBuilds/checkout@v7 with: fetch-depth: 0 - name: Record mirror result run: echo "checkout cache-hit=${{ steps.checkout.outputs.cache-hit }}" - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/4 ``` Repositories that carry vendored modules or binary fixtures pass those inputs through unchanged: Copy Text ``` - uses: WarpBuilds/checkout@v7 with: submodules: recursive lfs: true fetch-depth: 0 ``` Two behaviors shipped in July 2026 matter for real pipelines, and both are in the [changelog](/docs/ci/changelog). **Shallow clones, July 14, 2026.** A workflow that leaves `fetch-depth` at its default of 1 wants one commit, and downloading a full base mirror to serve it would be a bad trade. The action now handles that case separately: it seeds a small per-branch shallow snapshot, carrying the tip's pack rather than the whole history. Once that snapshot is seeded and anchored, the fetch from GitHub covers only the commits since it, and the result is a normal shallow checkout that matches what upstream `actions/checkout` produces. **Merge queues, July 27, 2026.** A `merge_group` event checks out a temporary queue ref that exists only while that entry sits in the queue, so it has no per-branch delta of its own and nothing to anchor against. The action now uses the base branch for merge queue runs, which is why the workflow above lists `merge_group` alongside `pull_request` without any extra configuration. Merge queue jobs sit directly on the merge path, so they are usually where checkout time is worth the most. Two more configuration notes. `allow-unsafe-pr-checkout` defaults to `false`, keeping the upstream safety behavior for pull requests from forks; leave it alone unless you have already reasoned through fork-PR checkout. And `sparse-checkout` reduces what gets written to disk without reducing the history you asked for, so it pairs with a fetch-depth decision rather than replacing one. For rollout across many repositories, use your repository automation to apply and verify the same one-line workflow change. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Checkout minutes bill at the same per-minute rate as every other minute in the job. WarpBuild's Linux rates, from the [pricing page](/pricing): | Runner label | vCPU | RAM | Price per minute | | ---------------------------- | ---- | ------ | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | | warp-ubuntu-latest-arm64-16x | 16 | 64 GB | $0.024 | Rates checked on 2026-08-13 against the WarpBuild pricing page. Now the conversion. One second of checkout removed from one job is worth `jobs_per_week x seconds / 60` minutes per week. Fill in your own median from the diagnosis step; the table gives the shape at three job volumes. | Seconds removed per job | 300 jobs per week | 1,200 jobs per week | 5,000 jobs per week | | ----------------------- | ----------------- | ------------------- | ------------------- | | 10 | 50 minutes | 200 minutes | 833 minutes | | 20 | 100 minutes | 400 minutes | 1,667 minutes | | 30 | 150 minutes | 600 minutes | 2,500 minutes | | 45 | 225 minutes | 900 minutes | 3,750 minutes | | 60 | 300 minutes | 1,200 minutes | 5,000 minutes | Priced at $0.008 per minute for `warp-ubuntu-latest-x64-4x`, the 1,200-jobs-per-week column runs from $1.60 per week at 10 seconds removed to $9.60 per week at 60 seconds. Over a month at 4.33 weeks, 30 seconds per job across 1,200 jobs per week is 2,598 minutes and $20.78. The money is the smaller half of the result. The larger half is lead time, and it does not scale with job count at all. Shards run in parallel, so checkout sits once on the critical path of each run. Thirty seconds removed from a run that merges 60 times a day is 1,800 seconds, or 30 minutes a day of waiting spread across the team. On a merge queue that number compounds, because every entry in the queue pays it in sequence. If checkout dominates your job time because the repository itself is large, read the [large repository guide](/guides/large-repository-github-actions) next. If the cost is concentrated in fan-out across many packages, the [monorepo setup](/solutions/monorepo-github-actions) is the closer fit. If the seconds disappear before the first step runs rather than during checkout, the problem is provisioning, and the [cold starts guide](/guides/github-actions-cold-starts) is the right page. ## [FAQ](#faq)Copy Anchor Link ### [Why is git checkout slow in GitHub Actions?](#why-is-git-checkout-slow-in-github-actions)Copy Anchor Link Because every job downloads the repository from GitHub again. Five inputs set the size of that download: the pack volume of the repository, the fetch depth the workflow asks for, submodules, Git LFS objects, and the number of jobs in the run that each repeat the whole fetch. ### [Is WarpBuilds/checkout a drop-in replacement for actions/checkout?](#is-warpbuildscheckout-a-drop-in-replacement-for-actionscheckout)Copy Anchor Link Yes. It is a fork of `actions/checkout` that adds git-mirror caching on WarpBuild runners. It takes no new inputs, adds one output named `cache-hit`, and falls open to stock `actions/checkout` behavior if the cache is unavailable, so a workflow switches by changing one `uses` line. ### [Does WarpBuild Checkout work with shallow clones and merge queues?](#does-warpbuild-checkout-work-with-shallow-clones-and-merge-queues)Copy Anchor Link Yes. Shallow-clone handling shipped on July 14, 2026 and seeds a small per-branch shallow snapshot rather than the full base mirror, producing a normal shallow checkout. Merge queue support shipped on July 27, 2026 and anchors `merge_group` runs on the base branch. ### [Does fetch-depth 0 still work?](#does-fetch-depth-0-still-work)Copy Anchor Link Yes. The mirror holds a per-repo base bundle carrying full history, built once, plus a small per-branch delta bundle. A workflow that needs full history for changed-file detection or version tagging keeps `fetch-depth: 0`, and the fetch from GitHub still covers only the commits that arrived since the mirror was built. --- # Speeding Up Windows Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/faster-windows-github-actions Description: Windows GitHub Actions jobs stall on file-heavy checkouts, Defender scans, and NuGet restores. Diagnose each cause, then size the WarpBuild runner. # Speeding Up Windows Builds on GitHub Actions Windows GitHub Actions jobs stall on file-heavy checkouts, Defender scans, and NuGet restores. Diagnose each cause, then size the WarpBuild runner. Last verified: 2026-08-13 A slow Windows job on GitHub Actions almost always comes down to five causes: NTFS file-copy overhead on a large checkout, Microsoft Defender scanning every file the job writes, NuGet restore going to the network on every run, MSBuild compiling one project at a time, and a runner too small for the project graph. Fix them in that order, then move the job to a larger WarpBuild Windows label such as `warp-windows-2025-x64-8x`, which lists at $0.032 per minute against $0.042 per minute for the 8-core GitHub-hosted Windows larger runner at the same 8 vCPU and 32 GB shape, a 24 percent lower list price ([GitHub billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. This guide covers the Windows fleet: how to find the real cause of a slow Windows job, what to change in the workflow, which image and size to land on, and what the change does to the monthly bill. ## [Diagnosis](#diagnosis)Copy Anchor Link Before changing a label, find out where the minutes go. Open a recent Windows run, expand the job, and read the per-step durations that GitHub prints next to every step name. Sort them mentally into four buckets: queue time before the job starts, checkout, dependency restore, and compile plus test. Each bucket has a different fix, and applying the wrong one wastes a week. If the wait happens before any step logs appear, the problem is queue time rather than build time, and the fix lives in a different place. Read [diagnosing GitHub Actions queue times](/guides/github-actions-queue-times) first and come back here once jobs start promptly. WarpBuild's CI observability collects OpenTelemetry system metrics from the runner agent and correlates them with GitHub Actions job logs. That correlation is how you separate a CPU-bound compile from an IO-bound restore without guessing: a step pinned at 100 percent on one core with idle disk is a serialized MSBuild, while a step with heavy disk activity and low CPU is file churn or scanning. ### [NTFS file-copy overhead on large checkouts](#ntfs-file-copy-overhead-on-large-checkouts)Copy Anchor Link `actions/checkout` writes every tracked file to disk. On NTFS, each file creation carries more metadata work than the equivalent operation on ext4, so a repository with 40,000 tracked files costs meaningfully more wall-clock time on Windows than on Linux for the identical clone. Line-ending conversion adds to it when `core.autocrlf` is left at the Git for Windows default, because Git rewrites file contents on the way to disk. Three signals point at this bucket: the checkout step is among the top three steps by duration, the repository has a large working tree or deep history, and the same checkout on an Ubuntu job in the same workflow finishes much sooner. ### [Microsoft Defender real-time scanning](#microsoft-defender-real-time-scanning)Copy Anchor Link Windows Server images ship Defender with real-time protection on. Every file that checkout writes, every package that NuGet extracts, and every assembly that MSBuild emits gets inspected as it lands. A build that produces tens of thousands of files pays that inspection tens of thousands of times. This one is invisible in step names. It shows up as uniform slowness spread across checkout, restore, and build rather than a single slow step, and as high system CPU that does not belong to `dotnet.exe` or `MSBuild.exe`. ### [NuGet restore over the network](#nuget-restore-over-the-network)Copy Anchor Link WarpBuild runners are ephemeral. The virtual machine is fresh at the start of the job and destroyed at the end, so the global packages folder under the runner user profile is empty on every run unless you restore it from a cache. A solution with 300 package references downloads and extracts all 300 every time, and the extraction step lands back in the Defender bucket above. Two more restore-side costs are easy to miss. Restore evaluates the project graph, and on a large solution the non-static-graph evaluation walks projects repeatedly. Floating versions, meaning any package reference written with a wildcard, force NuGet to contact the feed to resolve the newest match even when a cached copy would have served. ### [MSBuild building one project at a time](#msbuild-building-one-project-at-a-time)Copy Anchor Link `msbuild.exe` runs a single node unless you pass `-m`. A 60-project solution on a 16 vCPU runner then compiles one project at a time while 15 cores idle. The same trap applies when a build script pins `-maxcpucount:1`, and when node reuse is left on, since reused MSBuild worker processes are pointless on a machine that gets destroyed at the end of the job. Test execution has its own version of the problem. `dotnet test` on a solution runs test assemblies in sequence by default, so a suite split across 12 assemblies serializes 12 times even though the machine has cores to spare. ### [An undersized runner](#an-undersized-runner)Copy Anchor Link Windows itself consumes a share of the machine that Linux does not: the operating system services, Defender, and the GitHub Actions runner agent all want CPU and RAM before your build gets any. On a small runner that overhead is a large fraction of the total. WarpBuild removed Windows 2vCPU runners on June 8, 2026, so the smallest Windows label available today is 4 vCPU with 16 GB of RAM, and heavy Visual Studio solutions want more than that. ## [Fix](#fix)Copy Anchor Link Work down this list in order. The first three cost nothing per minute and often return more time than the runner change does. The fourth and fifth cost money, so apply them once the cheap fixes are in. The platform-neutral versions of these steps, such as job graph shape and artifact handling, are covered in the [general guide to speeding up GitHub Actions](/guides/speed-up-github-actions); everything below is Windows-specific. **1\. Trim the checkout.** Set `fetch-depth: 1` so the job clones one commit instead of the full history. If the build only touches part of a monorepo, add a sparse checkout in cone mode so the runner never writes the directories the build ignores. Set `core.autocrlf` to `false` in the job when the repository does not need conversion, so Git writes bytes straight through. **2\. Exclude the work directory from Defender real-time scanning.** Add exclusions as the first step of the job, before checkout, so nothing that follows gets inspected. Exclude the workspace path, the NuGet packages folder, and the compiler processes. WarpBuild Windows runners run under the `runneradmin` user, the same user GitHub's Windows runners use, so the `Add-MpPreference` cmdlet works without an extra elevation step. The runner is ephemeral and destroyed after the job, so the exclusions never outlive the run. **3\. Cache the NuGet packages folder.** Point `NUGET_PACKAGES` at a short path such as `C:\nuget`, key an `actions/cache` entry on the hash of your `packages.lock.json` files, and restore it before `dotnet restore`. Lock files are what make the key stable, so turn on `RestorePackagesWithLockFile` and run restore with `--locked-mode` in the workflow so a drifted lock file fails the job instead of silently re-resolving. Add `-p:RestoreUseStaticGraphEvaluation=true` so restore evaluates the project graph once. Two constraints matter here. WarpBuild caching is enabled by default on Linux runners and is documented as unsupported on Windows runners, so the cache you use on Windows is GitHub's Actions cache service through `actions/cache`. That action works from a WarpBuild runner with no extra configuration, since the runner receives the same cache service credentials any GitHub Actions job receives. **4\. Turn on MSBuild parallelism and turn off the parts that do not help.** Pass `-maxcpucount` explicitly rather than relying on an SDK default that changes between versions. Set `MSBUILDDISABLENODEREUSE=1`, because worker reuse buys nothing on a machine that is about to be destroyed and occasionally holds file locks that break later steps. Split restore and build into separate steps and pass `--no-restore` to build so the graph is evaluated once. Add `RunConfiguration.MaxCpuCount=0` to `dotnet test` so the test host uses every available core. **5\. Size the runner to the project graph, then shard.** Move up one size at a time and read the observability metrics after each move. When the build stops scaling with cores, stop paying for cores and split the work across jobs instead: a test matrix of four shards runs four 8 vCPU runners in parallel rather than one 32 vCPU runner that a serialized test host cannot fill. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. **6\. Keep Linux work on Linux.** Cross-platform .NET projects often run their entire pipeline on Windows out of habit. Push the parts that do not need Windows toolsets onto `warp-ubuntu-latest-x64-8x` at $0.016 per minute and keep Windows for the Windows-only targets. The toolchain detail for .NET pipelines lives in [running .NET builds on GitHub Actions](/solutions/dotnet-github-actions). ## [Configuration](#configuration)Copy Anchor Link ### [Choosing the image](#choosing-the-image)Copy Anchor Link WarpBuild publishes three Windows image families. All three carry the same tooling as the equivalent GitHub-hosted runner image, documented on the [preinstalled software page](/docs/ci/preinstalled-software), plus Tailscale, which stays inactive until networking is configured. | Image family | Labels | Visual Studio | Use it when | | -------------------------------- | ------------------------------------------------------------------ | ------------------ | --------------------------------------------------------------------------------- | | Windows Server 2022 | warp-windows-latest-x64-, alias warp-windows-2022-x64- | Visual Studio 2022 | The pipeline currently targets windows-2022 and you want a like-for-like move | | Windows Server 2025 | warp-windows-2025-x64- | Visual Studio 2022 | You want the newer Windows Server base with the toolset you already build against | | Windows Server 2025 with VS 2026 | warp-windows-2025-vs2026-x64- | Visual Studio 2026 | The solution needs Visual Studio 2026 toolsets or SDKs | The `vs2026` labels are transitional. They use the same Windows Server 2025 base image and differ only in the installed Visual Studio version, and Visual Studio 2026 may become the default on the `warp-windows-2025-x64` labels in a later update, which tracks [GitHub's own rollout of the Windows Server 2025 with Visual Studio 2026 image](https://github.com/actions/runner-images/issues/14016). Pin the explicit version label rather than `latest` when the build depends on a specific toolset. `warp-windows-latest-x64-8x` follows Windows Server 2022 today, and a floating label that moves under a pinned MSBuild version is a bad trade for a release pipeline. ### [The Windows catalog](#the-windows-catalog)Copy Anchor Link | Label | OS | vCPU | RAM | Storage | Price per minute | | -------------------------------- | ----------------------------- | ---- | ------ | --------- | ---------------- | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16 GB | 256GB SSD | $0.016 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64 GB | 256GB SSD | $0.064 | | warp-windows-latest-x64-32x | Windows Server 2022 | 32 | 128 GB | 256GB SSD | $0.128 | | warp-windows-2025-x64-4x | Windows Server 2025 | 4 | 16 GB | 256GB SSD | $0.016 | | warp-windows-2025-x64-8x | Windows Server 2025 | 8 | 32 GB | 256GB SSD | $0.032 | | warp-windows-2025-x64-16x | Windows Server 2025 | 16 | 64 GB | 256GB SSD | $0.064 | | warp-windows-2025-x64-32x | Windows Server 2025 | 32 | 128 GB | 256GB SSD | $0.128 | | warp-windows-2025-vs2026-x64-4x | Windows Server 2025 (VS 2026) | 4 | 16 GB | 256GB SSD | $0.016 | | warp-windows-2025-vs2026-x64-8x | Windows Server 2025 (VS 2026) | 8 | 32 GB | 256GB SSD | $0.032 | | warp-windows-2025-vs2026-x64-16x | Windows Server 2025 (VS 2026) | 16 | 64 GB | 256GB SSD | $0.064 | | warp-windows-2025-vs2026-x64-32x | Windows Server 2025 (VS 2026) | 32 | 128 GB | 256GB SSD | $0.128 | Rates and shapes come from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The full catalog with regions and aliases lives on [WarpBuild Windows runners for GitHub Actions](/runners/windows). ### [The workflow](#the-workflow)Copy Anchor Link This is the whole fix expressed as a workflow. It applies the Defender exclusions first, restores the NuGet folder from the Actions cache, and builds and tests with explicit parallelism. Copy Text ``` name: windows-build on: pull_request: push: branches: [main] env: DOTNET_NOLOGO: "true" DOTNET_CLI_TELEMETRY_OPTOUT: "true" DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "true" MSBUILDDISABLENODEREUSE: "1" NUGET_PACKAGES: C:\nuget jobs: build: runs-on: warp-windows-2025-x64-8x timeout-minutes: 30 steps: - name: Exclude build paths from Defender real-time scanning shell: powershell run: | Add-MpPreference -ExclusionPath "${{ github.workspace }}" Add-MpPreference -ExclusionPath "C:\nuget" Add-MpPreference -ExclusionProcess "MSBuild.exe" Add-MpPreference -ExclusionProcess "dotnet.exe" Add-MpPreference -ExclusionProcess "VBCSCompiler.exe" - name: Check out one commit uses: actions/checkout@v4 with: fetch-depth: 1 sparse-checkout: | src tests sparse-checkout-cone-mode: true - name: Keep line endings as committed shell: powershell run: git config --global core.autocrlf false - uses: actions/setup-dotnet@v4 with: dotnet-version: "9.0.x" - name: Restore the NuGet packages folder from the Actions cache uses: actions/cache@v4 with: path: C:\nuget key: nuget-windows-${{ hashFiles('**/packages.lock.json') }} restore-keys: | nuget-windows- - name: Restore run: > dotnet restore Contoso.sln --locked-mode -p:RestoreUseStaticGraphEvaluation=true - name: Build run: > dotnet build Contoso.sln --configuration Release --no-restore -maxcpucount - name: Test run: > dotnet test Contoso.sln --configuration Release --no-build --logger trx -- RunConfiguration.MaxCpuCount=0 ``` Two details in that file are easy to get wrong. The Defender step runs before `actions/checkout`, because an exclusion added after the files land does nothing for the files already scanned. And `NUGET_PACKAGES` is set at the workflow level so the cache path, the restore, and the build all agree on one folder. ### [Sharding the test suite](#sharding-the-test-suite)Copy Anchor Link Once the build is fast and the tests are the tail, split them. Four 8 vCPU runners cost the same per minute in total as one 32 vCPU runner at $0.128, and a serialized test host fills four small machines far better than one large one. Copy Text ``` jobs: test: runs-on: warp-windows-2025-x64-8x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 with: fetch-depth: 1 - uses: actions/setup-dotnet@v4 with: dotnet-version: "9.0.x" - name: Test shard ${{ matrix.shard }} run: > dotnet test Contoso.sln --configuration Release --filter "Shard=${{ matrix.shard }}" --logger trx -- RunConfiguration.MaxCpuCount=0 ``` ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Windows runner rates are linear in vCPU on WarpBuild: every Windows vCPU-minute lists at $0.004, so a 4 vCPU minute is $0.016 and a 32 vCPU minute is $0.128\. That linearity is what makes the sizing decision arithmetic instead of opinion. ### [List price at identical shapes](#list-price-at-identical-shapes)Copy Anchor Link | WarpBuild label | Shape | WarpBuild per minute | GitHub-hosted larger runner | GitHub per minute | Lower list price | | --------------------------- | --------------- | -------------------- | ----------------------------- | ----------------- | ---------------- | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016 | 4-core Windows larger runner | $0.022 | 27 percent | | warp-windows-latest-x64-8x | 8 vCPU, 32 GB | $0.032 | 8-core Windows larger runner | $0.042 | 24 percent | | warp-windows-latest-x64-16x | 16 vCPU, 64 GB | $0.064 | 16-core Windows larger runner | $0.082 | 22 percent | | warp-windows-latest-x64-32x | 32 vCPU, 128 GB | $0.128 | 32-core Windows larger runner | $0.162 | 21 percent | WarpBuild rates come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing). GitHub rates come from GitHub's published per-minute prices for Windows larger runners on the [Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and [github.com/pricing](https://github.com/pricing), both checked on 2026-08-13. ### [A worked model](#a-worked-model)Copy Anchor Link Assumptions, all of them inputs you should replace with numbers from your own run history rather than measured results published here: * 1,000 Windows jobs per month, roughly 45 pull request and merge builds per weekday. * A baseline job that takes 20 minutes on a 4 vCPU Windows runner. * Billing at one minute of granularity, which is how WarpBuild bills GitHub Actions runners. **Step one, same shape, lower rate.** 1,000 jobs times 20 minutes is 20,000 Windows minutes per month. At $0.016 that is $320\. The same 20,000 minutes on the 4-core GitHub-hosted Windows larger runner at $0.022 is $440\. Nothing about the build changed, and the monthly line is $120 lower. **Step two, the free fixes.** Suppose the Defender exclusions, the NuGet cache, and `-maxcpucount` take the job from 20 minutes to 15\. That is 15,000 minutes, or $240 per month on the 4 vCPU label. Minutes are the multiplier on every rate in the table above, so this step improves whichever runner you are on. **Step three, decide whether a bigger runner pays.** An 8 vCPU minute costs $0.032, exactly twice a 4 vCPU minute. So the break-even is simple: the job has to finish in under half the time to cost less. Break-even minutes equal 15 times 0.016 divided by 0.032, which is 7.5 minutes. If the 8 vCPU job lands at 10 minutes, the bill goes from $240 to $320 per month and buys 5 minutes off every run. Price that time. 1,000 jobs times 5 minutes is 5,000 minutes, or roughly 83 hours per month of engineers waiting on a pull request check. $80 per month is a cheap trade for 83 hours, which is the point: a bigger Windows runner is a wall-clock purchase, and the list price gap is what keeps the purchase small. If your Windows fleet is large enough that compute economics dominate, BYOC runs on AWS, GCP, and Azure, with Windows BYOC runners listed at $0.002 per minute in WarpBuild fees on AWS and Azure and the compute billed by your own cloud account. ## [FAQ](#faq)Copy Anchor Link ### [Why is the same repository slower to build on Windows than on Linux in GitHub Actions?](#why-is-the-same-repository-slower-to-build-on-windows-than-on-linux-in-github-actions)Copy Anchor Link Windows pays more per file operation. A checkout, a NuGet package extraction, and a build output tree each write thousands of small files, every one of those writes passes through Microsoft Defender real-time scanning, and the Windows image carries a larger operating system working set than the Ubuntu image. Trim the checkout, exclude the work directory and the package folder from real-time scanning, and cache the NuGet packages folder before you touch the runner size. ### [Which WarpBuild Windows runner label should I start with?](#which-warpbuild-windows-runner-label-should-i-start-with)Copy Anchor Link Start on `warp-windows-2025-x64-8x` at $0.032 per minute, which gives the job 8 vCPU and 32 GB. Move up to `warp-windows-2025-x64-16x` only after the job logs show the build saturating 8 vCPU. The smallest Windows label is 4 vCPU because Windows 2vCPU runners were removed on June 8, 2026. ### [Does WarpBuild caching work on Windows runners?](#does-warpbuild-caching-work-on-windows-runners)Copy Anchor Link No. WarpBuild caching is enabled by default on Linux runners and is documented as unsupported on Windows runners. On Windows, cache the NuGet packages folder with `actions/cache`, which stores entries in GitHub's own Actions cache service and works from a WarpBuild runner without extra configuration. ### [When should I use the vs2026 Windows labels?](#when-should-i-use-the-vs2026-windows-labels)Copy Anchor Link Use `warp-windows-2025-vs2026-x64-4x` and its larger siblings when the solution needs Visual Studio 2026 toolsets. Those labels are transitional. They carry Visual Studio 2026 on the same Windows Server 2025 base image, while the `warp-windows-2025-x64` labels still carry Visual Studio 2022. --- # Finding the Slow Step in a GitHub Actions Workflow | WarpBuild URL: https://www.warpbuild.com/guides/find-the-slow-step-github-actions Description: Find the slow step in a GitHub Actions workflow by splitting queue wait from execution, ranking step timestamps from the job log, then reading runner metrics. # Finding the Slow Step in a GitHub Actions Workflow Find the slow step in a GitHub Actions workflow by splitting queue wait from execution, ranking step timestamps from the job log, then reading runner metrics. Last verified: 2026-08-13 The slow step in a GitHub Actions job is the one with the largest gap between its `started_at` and `completed_at` timestamps, and those timestamps are available for every step of every run through the GitHub REST API. Before you rank them, split the job's wall clock into queue wait and execution time, because a pipeline that grew in the queue has no slow step to find. This guide covers the order of operations: separate queue time from execution time, attribute execution time to individual steps, then use runner utilization to name the resource holding the slow step. The last section prices the change so you know what a size bump or a cache fix does to the bill before you merge it. ## [Diagnosis](#diagnosis)Copy Anchor Link Three numbers answer three different questions, and each one lives in a different place. Reading them out of order is how teams end up buying a bigger runner for a job that was waiting in a queue. | Question | Where the number lives | What it settles | | --------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------- | | How long did the job wait for a runner? | Queue Timings, per runner label and stack, with run count and queue time p75 and p90 | Whether the growth happened before the job started | | How long did the job execute? | Jobs report, per repository, workflow, and job name, with duration p75 and p90 | Which job is worth profiling | | What was the machine doing while it executed? | Observability Usage view, per runner instance, with the GitHub Actions logs in the same view | Which resource holds the slow step | Start with Queue Timings. It aggregates per runner label and stack, with a daily bar chart of average queue time alongside the job count for that day. If end to end pipeline time grew while duration p90 in the Jobs report stayed flat, the growth is queue wait and no step changed. Move to the Jobs report second. It aggregates per unique repository, workflow, and job name combination and reports run count, success rate, and p75 and p90 for duration, queue time, CPU, and memory. Sort by duration p90 descending and take the top job. Percentiles matter here because averages hide the run shape you actually care about: a job with a 4 minute average and a 19 minute p90 has a step that is sometimes slow, which is a different investigation from a step that is always slow. Only then attribute execution time to steps. A single representative run gives a timeline like this one, where the job executed for 15 minutes: | Step | Duration | Share of execution | | --------------------------- | -------- | ------------------ | | Set up job | 0:09 | 1 percent | | actions/checkout@v4 | 1:12 | 8 percent | | Restore dependency cache | 3:41 | 25 percent | | npm ci | 1:57 | 13 percent | | npm run build | 5:36 | 37 percent | | npm test | 1:48 | 12 percent | | Post steps and Complete job | 0:37 | 4 percent | Shares are rounded to whole percent. Two steps own 62 percent of the run, and they have nothing else in common, so they need separate diagnoses. That is where utilization comes in. WarpBuild runners carry an agent that records CPU, memory, filesystem, disk I/O, and network utilization per instance over OpenTelemetry, and the Recommendations view filters and highlights the instances that cross fixed thresholds. | Metric | Threshold that names a bottleneck | Label shown | | -------------------------- | -------------------------------------------- | --------------------- | | Max sustained CPU | 80 percent or higher | High CPU Usage | | Max memory utilization | 80 percent or higher | High Memory Usage | | Max filesystem utilization | 80 percent or higher | High Filesystem Usage | | Max disk I/O | 80 percent or higher of supported throughput | High Disk IO | Those labels highlight instances in the UI so you can filter to them. They do not send notifications, so the review is something you schedule rather than something that pages you. Network utilization is recorded per instance as well and carries no threshold, which is why a download bound step is identified by reading the chart against the step boundary rather than by waiting for a label to appear. Two collection limits are worth knowing before you go hunting. Metrics and logs are collected for jobs longer than about one minute, so a 40 second step inside a 40 second job produces no chart. Collection can also be paused, which stops system logs and GitHub Actions logs along with the metrics. ## [Fix](#fix)Copy Anchor Link The sequence below turns one slow step into one change. **Step 1: confirm the time is in execution.** Compare queue time p90 from Queue Timings against duration p90 from the Jobs report for the same window. If queue wait moved, stop here and work the capacity path instead. **Step 2: pick the job by duration p90, then pick the run.** Take the top job by duration p90 and pull a recent run whose duration sits near that percentile. Profiling the fastest run of the week teaches you nothing about the p90. **Step 3: rank the steps.** Subtract `started_at` from `completed_at` for each step of that run and sort descending. The two heaviest steps are the whole investigation. **Step 4: line the metrics up with the step boundary.** Open the Usage view for that runner instance. The metrics chart and the GitHub Actions logs sit in the same view, so a plateau in CPU or disk I/O can be matched to the step that was running when it started. A step at 85 percent memory for 20 seconds is a different problem from a step that holds 85 percent for 9 minutes. **Step 5: choose the lever the metric points at.** Sustained CPU at or above 80 percent for the duration of the step means the step is core bound and runner size is the lever. Memory at or above 80 percent means the size change should be about RAM, and it usually explains test workers that die without a stack trace. Filesystem at or above 80 percent means the working set outgrew the volume. Disk I/O at or above 80 percent of supported throughput while CPU sits low means the step is moving bytes, and the cache path is the lever. **Step 6: change one thing and re-measure.** Run count in the Jobs report tells you how long to wait. A job with 40 runs a week needs a full week before its new p90 means anything. Here is the worked case for the timeline above. The 3:41 restore step ran at 22 percent CPU and 31 percent memory with disk I/O at 84 percent of supported throughput, so cores would not have moved it. Switching that step to the WarpBuild cache action and the cache enabled `setup-node` fork brought it to 0:52\. The 5:36 build step ran at 94 percent sustained CPU on a 4 vCPU runner, which is the size signal, so the job moved to `warp-ubuntu-latest-x64-8x` and the step came back at 3:20. Execution time for the job goes from 15:00 to 9:55\. The cache change alone accounts for 2:49 of that and the size change for 2:16\. The next section prices both. Every runner WarpBuild operates carries the same agent, and BYOC runs on AWS, GCP, and Azure, so metrics for a runner inside your own cloud account land in the same reports as a hosted one. The step attribution method is identical on all four. ## [Configuration](#configuration)Copy Anchor Link Step durations come straight from the GitHub REST API. This lists every step of every job in a run with its duration in seconds, sorted longest first: Copy Text ``` gh api "repos/OWNER/REPO/actions/runs/$RUN_ID/jobs" --paginate \ --jq '.jobs[] | .name as $job | .steps[] | select(.started_at != null and .completed_at != null) | [((.completed_at | fromdateiso8601) - (.started_at | fromdateiso8601)), $job, .name] | @tsv' \ | sort -rn | head -20 ``` Pick `RUN_ID` from a run whose duration sits near the job's p90 rather than a random recent run. The Jobs report API exposes that percentile, so the ranking can be scripted with an API key carrying the `ci` scope: Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/reports/jobs' \ -H 'Authorization: Bearer wkey-xxxx' \ --data-urlencode 'start_date=2026-07-13T00:00:00Z' \ --data-urlencode 'end_date=2026-08-13T00:00:00Z' \ --data-urlencode 'sort_by=duration_p90' \ --data-urlencode 'sort_order=desc' \ | jq -r '.table.items[] | [.repo, .workflow_name, .job_name, .run_count, .duration_p90, .queue_time_p90, .cpu_p90, .memory_p90] | @tsv' ``` Reading `duration_p90` and `queue_time_p90` side by side in that output is the step 1 check in a single command. Add `--data-urlencode 'format=csv'` when you want the rows in a spreadsheet or a ticket. The workflow below is the state after both fixes. The build job carries the larger label, the restore step uses the WarpBuild cache path, and the test job stays on the smaller size because its metrics never crossed a threshold: Copy Text ``` name: ci on: pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci - run: npm run build test: needs: build runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/4 ``` Splitting a slow test step across shards adds jobs rather than minutes per job. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a 4 way shard becomes an 8 way shard without a queue penalty appearing somewhere else. The telemetry agent uses port `33931`. It is open by default on WarpBuild operated runners, and on BYOC runners you confirm egress on that port from the subnet the runners launch in, otherwise the Usage view stays empty while jobs run normally. Field definitions for every metric live in the [Observability documentation](/docs/ci/features/observability), and the report tabs are documented in the [Reports documentation](/docs/ci/features/reports). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Runner size prices in a straight line, so a size change is arithmetic. Linux x64 rates from the WarpBuild [pricing page](/pricing), billed per minute: | Label | vCPU | RAM | Storage | USD per minute | | -------------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | **Assumptions.** The job from the diagnosis section runs 700 times a month. Execution time is the 15:00 measured above, which is 15.0 minutes. The post fix step timings of 0:52 and 3:20 are the observed replacements described in the fix section. Queue time is excluded because it is not billed, and billing is per minute of runner time. **Baseline.** 700 runs at 15.0 minutes on `warp-ubuntu-latest-x64-4x` is 10,500 minutes at $0.008, which is $84.00 a month. **Change one, the cache path only.** Execution drops to 12.2 minutes on the same 4 vCPU label. That is 8,540 minutes at $0.008, which is $68.32 a month, and it returns 1,960 minutes of wall clock a month to the people waiting on the pull request. This change lowers both numbers, which is why the cache lever goes first whenever disk I/O rather than CPU crossed the threshold. **Change two, the size bump on top.** Execution drops to 9.92 minutes on `warp-ubuntu-latest-x64-8x`. That is 6,944 minutes at $0.016, which is $111.10 a month. Against the $84.00 baseline it costs $27.10 more and returns 3,556 minutes of wall clock a month. The dollar break even is the number to keep: at 700 runs, the 8 vCPU label matches the old bill when execution reaches 7.5 minutes per run, so this job is paying $27.10 for the 2.4 minutes it did not get there. For a list price baseline, GitHub publishes these per minute prices for GitHub-hosted Linux runners, checked on 2026-08-13 in the [GitHub billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers): | GitHub-hosted runner | Shape | USD per minute | | ------------------------------------- | ------------- | -------------- | | ubuntu-latest on private repositories | 2 vCPU, 8 GB | $0.006 | | 4-core Linux larger runner | 4 vCPU, 16 GB | $0.012 | | 8-core Linux larger runner | 8 vCPU, 32 GB | $0.022 | The same 6,944 minutes on the 8-core GitHub-hosted larger runner costs $152.77 a month against $111.10 on `warp-ubuntu-latest-x64-8x`. That is the same 8 vCPU and 32 GB shape at $0.016 per minute against $0.022, which is 27 percent lower list price, checked on 2026-08-13. For the percentile reading that picks the run to profile, see [read GitHub Actions build duration percentiles](/guides/github-actions-build-duration-percentiles). For the per instance metric definitions behind step 4, see [runner level metrics for GitHub Actions jobs](/guides/github-actions-runner-metrics). For the estate wide view, start at [GitHub Actions observability with WarpBuild](/solutions/github-actions-observability), and for the broader set of levers once the slow step is named, see [speed up GitHub Actions workflows](/guides/speed-up-github-actions). ## [FAQ](#faq)Copy Anchor Link **How do I find which step in a GitHub Actions job is slow?** Pull the job through the GitHub REST API and subtract each step's started\_at from its completed\_at. That gives every step's duration for one run, sorted. Rank jobs first by duration p90 in the Jobs report so you profile a run that is representative rather than the one you happened to open. **Why does the job total not match the sum of its step durations?** Queue wait sits outside the steps and is reported separately in Queue Timings. Inside the job, Set up job, Post steps, and Complete job carry real time that is easy to skip when reading the log. Billed time is also reported separately from execution time in the CI billing table, so reconcile against billed time when you compare with an invoice. **Can the runner tell me why a step is slow rather than only how long it took?** Yes. WarpBuild runners run an agent that records CPU, memory, filesystem, disk I/O, and network utilization per instance and shows it beside the GitHub Actions logs for the same job, so you can line a metric plateau up with a step boundary. Metrics and logs are collected for jobs longer than about one minute. **Should I move a slow step to a bigger runner or fix caching first?** Let the metric decide. Sustained CPU at or above 80 percent points at runner size. A slow restore or download step at low CPU and high disk I/O points at the cache path, which is usually cheaper to fix and lowers the bill instead of raising it. --- # Finding and Fixing Flaky GitHub Actions Jobs | WarpBuild URL: https://www.warpbuild.com/guides/flaky-github-actions-jobs Description: A flaky GitHub Actions job passes and fails on one commit. Rank jobs by success rate, read CPU and memory percentiles, then confirm on the runner. # Finding and Fixing Flaky GitHub Actions Jobs A flaky GitHub Actions job passes and fails on one commit. Rank jobs by success rate, read CPU and memory percentiles, then confirm on the runner. Last verified: 2026-08-13 A flaky GitHub Actions job passes and fails on the same commit with no code change in between, and the fix depends on whether the cause sits in the test suite or in the machine under it. Rank jobs by success rate per repository, workflow, and job name in the WarpBuild Jobs report, read the CPU and memory percentiles on the same row, and those two cases separate before anyone opens a stack trace. This guide covers the four flake sources worth separating, the report columns that tell resource starvation apart from genuine nondeterminism, the workflow change that opens a shell on the runner that produced the failure, and a weekly cost model for living with a flake against hunting it down. ## [Diagnosis](#diagnosis)Copy Anchor Link Start from a ranked list rather than from the last complaint in Slack. A repository with 400 distinct workflow jobs usually has under ten that are genuinely unstable, and the loudest ones are often stable jobs failing for a real reason. ### [The four sources worth separating](#the-four-sources-worth-separating)Copy Anchor Link | Flake source | What the job log shows | What confirms it | | ------------------------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | Shared state between tests | Failures move between test names on re-run, and a single test passes when run alone | The suite passes with \--runInBand, one worker, or a fixed seed, and fails when sharded or shuffled | | Timing dependence | Assertion on an element, file, or row that exists a moment later; sleeps already in the code | The failure rate tracks machine load, and the same test passes with a longer wait | | Resource exhaustion on an undersized runner | Exit code 137, Killed, no space left on device, output that stops mid-step | CPU P90 or memory P90 at or above 80 percent on the Jobs report row | | Network calls to third parties | HTTP 429, 502, connection reset, DNS failure against a hostname you do not own | The failing request targets an external host, and failures cluster in time rather than by test | **Shared state between tests.** A fixture, a database row, a temp file, a global mock, or an environment variable survives one test and changes the next. The signature is that failures move around: the same commit fails on `checkout.spec` one run and `billing.spec` the next. Parallel workers make this worse because two tests now touch the same row at once instead of in sequence. **Timing dependence.** The test asserts on something that becomes true a few hundred milliseconds after it looks. Any suite carrying `sleep 2` or a fixed wait already contains this bug; the sleep hides it on a fast machine and exposes it on a loaded one. This source is the one most often blamed on the runner, because a busy machine widens every window. **Resource exhaustion on an undersized runner.** The job is deterministic and the machine runs out of memory, disk, or CPU headroom part way through, so the result depends on how much other work landed on the same box. A Jest suite spawning workers from `os.cpus()` behaves differently on a 2 vCPU, 8 GB runner than on a 16 vCPU, 64 GB one. Retry logic in the test framework leaves this case exactly where it was, because the suite is deterministic and the machine is the part that varies. **Network calls to third parties.** Package registries, container registries, SaaS APIs, and OAuth endpoints all return 429 and 502 under their own load, on their own schedule. The tell is clustering: several unrelated jobs go red inside the same ten minutes, then everything recovers. ### [Rank by success rate before touching anything](#rank-by-success-rate-before-touching-anything)Copy Anchor Link The [WarpBuild Reports page](/docs/ci/features/reports) aggregates every job into one row per unique combination of repository, workflow, and job name. Each row carries run count, success rate, duration P75 and P90, queue time P75 and P90, CPU P75 and P90, and memory P75 and P90 over the selected date range. Two moves turn that table into a flake list: * Sort by success rate ascending and ignore every row with a run count under 20\. A job that ran three times and failed once tells you nothing yet. * Filter by repository, workflow, job name, runner label, or stack to keep one team's surface in view, then export the filtered rows to CSV. The export carries every row matching the current filters and sort order rather than the visible page. Success rate alone still mixes real bugs into the list, so a job that started failing on every run after a merge shows up next to a job that fails one run in fifteen. The time-series chart separates them: pick a metric and a percentile, and each job on the current table page draws its own line, so a step change on a single date reads differently from a flat noisy band. ### [Correlate with CPU and memory percentiles](#correlate-with-cpu-and-memory-percentiles)Copy Anchor Link The CPU and memory columns are what make this report a flake tool rather than a dashboard. WarpBuild flags an instance as under-provisioned when max sustained CPU, memory, or filesystem utilization reaches 80 percent, and the P90 columns on the Jobs row surface that same pressure aggregated across runs. | Success rate | CPU P90 and memory P90 | Reading | | ---------------- | ------------------------------ | ----------------------------------------------------------------------------------------------- | | 100 percent | Any | Stable. Move on. | | Under 98 percent | Either at or above 80 percent | Starvation first. Resize the runner and re-measure before editing tests. | | Under 98 percent | Both under 60 percent | Genuine nondeterminism. Look at shared state, timing, and third-party calls. | | Under 98 percent | Both between 60 and 80 percent | Ambiguous. Resize one size up as a cheap experiment, then re-read the row. | | Under 98 percent | Shown as a dash | Telemetry is missing. Enable observability on the organization and wait a day for rows to fill. | A dash in those columns means the job ran without telemetry, so CPU and memory require observability to be enabled before this correlation works at all. ## [Fix](#fix)Copy Anchor Link Confirm the classification before changing code, because a fix applied to the wrong source leaves the flake in place and adds a retry that hides it. ### [Confirm by repeating one commit](#confirm-by-repeating-one-commit)Copy Anchor Link Run the suspect job against a single commit thirty times. Thirty repetitions catch a failure that appears once in twenty runs with margin, and a failure that survives thirty green runs is rare enough that the next step is instrumentation instead of a fix. Two settings make the repeat useful. `fail-fast: false` keeps the remaining repetitions running after the first red one, which is the evidence you are collecting. And there are no hard concurrency caps on any WarpBuild tier, with capacity that adjusts dynamically, so thirty repetitions start together instead of draining through a fixed pool over an afternoon. ### [Fix by source](#fix-by-source)Copy Anchor Link Shared state is fixed in the suite: give each test its own database schema or transaction, generate unique fixture keys per test, reset global mocks in an `afterEach`, and run the suite with a shuffled seed in GitHub Actions so ordering assumptions fail early and loudly rather than once a month. Timing dependence is fixed by replacing every fixed sleep with a wait on the actual condition, and by giving those waits a timeout long enough to survive a loaded machine. A wait that polls for the condition costs nothing when the condition is already true. Resource exhaustion is fixed by moving `runs-on` to a larger label, or by capping worker counts so the suite stops sizing itself from the machine. Both are one-line changes and both should be verified by re-reading the CPU and memory percentiles after a day of runs. Third-party network calls are fixed by removing them from the test path. Mock the external service, pin and cache dependency downloads so the registry is touched once, and keep genuine integration checks in a separate scheduled workflow where a 429 does not block a pull request. ### [Get on the runner that produced the failure](#get-on-the-runner-that-produced-the-failure)Copy Anchor Link When repetition reproduces the failure but the logs do not explain it, stop guessing and take a shell. The [Action Debugger](/docs/ci/tools/action-debugger) is a free, open-source GitHub Action that pauses a workflow and opens an SSH session on the runner machine. Add it to the job guarded by `failure()`: Copy Text ``` - name: Setup interactive ssh session if: ${{ failure() }} uses: Warpbuilds/action-debugger@v1.3 with: limit-access-to-actor: true timeout-minutes: 25 ``` Execution pauses as soon as that step is invoked. The SSH URL is written to the action logs and posted as a check on the corresponding GitHub run, and the workflow stays paused until someone connects and exits the session. Two details matter more here than on an ordinary failure. First, security: by default, if the GitHub user who triggered the run has SSH keys on their account, only that user can connect, and if they have no keys on the account, anyone who has or guesses the generated SSH URL can connect to the session. Setting `limit-access-to-actor: true` forces the restriction. Deterministic URLs through named sessions require an API key from WarpBuild support and also require `limit-access-to-actor: true`. Second, posting the URL as a check needs write permission on the checks scope. Under repository Settings, Actions, General, set Workflow permissions to read and write permissions. On many organizations that setting has to change at the organization level, because the organization value overrides the repository one. Once connected, the questions worth asking are the ones a re-run cannot answer: `free -m` and `df -h` for headroom at the moment of failure, `ps aux --sort=-%mem | head` for the process that grew, the test database state left behind by the previous test, and the failing command re-run by hand with one flag changed at a time. ## [Configuration](#configuration)Copy Anchor Link Here is the full flake-hunting workflow. It repeats one commit thirty times, keeps every repetition alive, and drops into a shell on whichever repetition goes red. Copy Text ``` name: flake-hunt on: workflow_dispatch: inputs: test_filter: description: Test name pattern to repeat required: false default: "" jobs: repeat: strategy: fail-fast: false matrix: run: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30] runs-on: warp-ubuntu-latest-x64-4x permissions: contents: read checks: write steps: - uses: actions/checkout@v4 - run: npm ci - name: Run suite with shuffled order run: npm test -- --shard=1/1 --randomize --testNamePattern "${{ inputs.test_filter }}" - name: Setup interactive ssh session if: ${{ failure() }} uses: Warpbuilds/action-debugger@v1.3 with: limit-access-to-actor: true timeout-minutes: 25 ``` Sizing is the fix for the starvation case, so keep the Linux x64 catalog in front of you when you change `runs-on`: | Runner label | vCPU | RAM | Storage | USD per minute | | -------------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Rates come from the [WarpBuild pricing page](/pricing) and are billed per minute, checked on 2026-08-13. Which matters when a flake only appears on one of them. Swap the single `runs-on` above for a platform axis and the thirty repetitions become a platform sweep across `warp-ubuntu-latest-x64-4x`, `warp-ubuntu-latest-arm64-4x`, `warp-macos-15-arm64-6x`, and `warp-windows-latest-x64-4x`, which narrows an intermittent failure to one platform in a single dispatch. If your runners live in your own cloud account, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS, so the instance shape behind a starving job is described in code you can diff against the shape you intended. Access to the reports follows your WarpBuild organization membership, and SSO is available for a flat $250 per month, whatever the user count. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Flakes are usually argued about in dollars and decided by engineer hours. Here is both, on one job. Assumptions, stated so you can substitute your own: * The job runs 200 times per week on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, from the [WarpBuild pricing page](/pricing), checked on 2026-08-13. * Each run bills 12 minutes. * The Jobs report shows a 92 percent success rate, so 16 runs per week go red without a code cause. * Every spurious failure is re-run once, and one of those re-runs fails again. * A human loses 12 minutes of waiting plus 5 minutes of context switching per spurious failure. | Path | Billed minutes per week | Runner cost per week | Engineer minutes per week | | ------------------------------------------------------- | ----------------------- | -------------------- | ------------------------- | | Live with the flake | 2,604 | $20.83 | 272 | | Hunt it once: 30 repetitions plus one 25 minute session | 385 one time | $3.08 one time | 45 one time | | After the fix | 2,400 | $19.20 | 0 | The runner-cost column moves by $1.63 per week, which pays back the $3.08 investigation in two weeks and then stops being interesting. The column that decides it is the last one: 272 engineer minutes per week is about 4.5 hours, and that is one job. Ten such jobs across a repository is a full engineering week spent watching re-runs. The starvation case has its own arithmetic, and it does not always come out cheaper. Moving a job from `warp-ubuntu-latest-x64-2x` at $0.004 per minute to `warp-ubuntu-latest-x64-4x` at $0.008 per minute doubles the per-minute rate, so 2,400 billed minutes move from $9.60 to $19.20 per week. What you buy is the 204 minutes of re-run time and the 272 engineer minutes above, which is the trade most teams take. Two costs are easy to forget on the debugging side. Runner minutes bill for as long as a debug session stays open, so a 25 minute session on `warp-ubuntu-latest-x64-4x` costs $0.20 and an abandoned one runs until GitHub kills the workflow after 6 hours, at $2.88\. Put `timeout-minutes` on every debugger step. And the thirty-repetition matrix bills all thirty jobs, which is the $2.88 line in the hunt row above, so run it against one narrowed test pattern rather than the whole suite. For the failure classes that are not flakes at all, start at [how to debug a failed GitHub Actions job](/guides/debug-github-actions-failures). If the complaint is duration rather than instability, the same report columns answer it: see [GitHub Actions build duration percentiles](/guides/github-actions-build-duration-percentiles). For the shell access itself, see [can I SSH into a GitHub Actions runner](/answers/can-i-ssh-into-a-github-actions-runner). Browser suites carry their own flake profile, covered in [running Playwright tests on GitHub Actions](/solutions/playwright-github-actions). ## [FAQ](#faq)Copy Anchor Link **How do I tell a flaky test from an undersized runner?** Open the Jobs report, find the row for the repository, workflow, and job name, and read success rate beside CPU P90 and memory P90\. A job with a success rate under 98 percent and a memory P90 at or above 80 percent is starving, and a larger runner label fixes it. The same success rate with CPU and memory P90 both low points at shared state, timing, or a third-party network call. **How many times should I re-run a commit to confirm a flake?** Thirty repetitions of the same commit catches a failure that shows up once in twenty runs with room to spare, and a `fail-fast: false` matrix keeps every repetition running after the first red one. There are no hard concurrency caps on any WarpBuild tier, so the thirty jobs start together instead of draining through a fixed pool. **Can I SSH into the runner that produced the flaky failure?** Yes. Add a `Warpbuilds/action-debugger@v1.3` step guarded by `if: ${{ failure() }}` and the workflow pauses on the failing run, prints an SSH URL in the job logs, and posts it as a check. Set `limit-access-to-actor: true`, because without it and without SSH keys on the triggering account, anyone holding the generated URL can connect. **Does an open debug session keep billing runner minutes?** Yes. Runner minutes are billed for as long as the session stays open and GitHub kills workflows after 6 hours by default, so put `timeout-minutes` on the debugger step. At the `warp-ubuntu-latest-x64-4x` rate of $0.008 per minute from the [WarpBuild pricing page](/pricing), checked on 2026-08-13, a 25 minute session costs $0.20 and a forgotten 6 hour one costs $2.88. --- # Speeding Up Git LFS Checkouts in GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/git-lfs-checkout-github-actions Description: Git LFS is slow in GitHub Actions because lfs true pulls every object the ref points at. Fetch only the paths the job reads and price the difference. # Speeding Up Git LFS Checkouts in GitHub Actions Git LFS is slow in GitHub Actions because lfs true pulls every object the ref points at. Fetch only the paths the job reads and price the difference. Git LFS checkouts are slow in GitHub Actions because `lfs: true` downloads every LFS object the checked-out ref points at, on every job in the run, regardless of which files that job opens. The fix is to check out pointer files, then pull only the paths the job reads with `git lfs pull --include`, which turns a whole-repository transfer into a subset transfer. This page covers where the time goes in an LFS checkout, the workflow change that scopes the download, the configuration that keeps the scope stable across jobs, and a time model that prices a full LFS checkout against a selective fetch at a stated repository size. It sits under the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link A plain checkout moves one thing: packfiles of git objects. An LFS checkout moves two, and the two scale on different axes. **Pointer resolution.** Every LFS-tracked path is stored in git as a small text pointer carrying an object ID and a size. Turning pointers into real files runs the LFS smudge filter across the working tree and issues batch requests to the LFS server to resolve download URLs for those object IDs. This half scales with the count of tracked paths. A repository with 41,000 tracked assets pays 41,000 pointer resolutions whether the objects behind them are 4 KB or 400 MB. **Object download.** The second half moves bytes, in parallel streams governed by `lfs.concurrenttransfers`, which [defaults to 8](https://github.com/git-lfs/git-lfs/blob/main/docs/man/git-lfs-config.adoc). This half scales with total object volume and with the throughput between the runner and the LFS server. The scoping rule underneath both is where the minutes come from. `git lfs fetch` works per ref. It downloads the objects the checked-out ref points at, so a one-line shader change pulls the same object set as a branch that rewrote every asset in the tree. The `lfs` input on `actions/checkout` is documented as "Whether to download Git-LFS files" and [defaults to false](https://github.com/actions/checkout), so a workflow only pays this once someone sets it to true, usually because one job needed one binary fixture. Two multipliers sit on top. Fan-out repeats the whole transfer per job, so a run with a lint job, a type-check job, and a six-way test shard pays it eight times for one commit. And `git lfs fetch --all` widens the scope from the tip to every object in history, which is the version of this problem that shows up as a job timeout rather than a slow step. The download also meters against the repository owner. GitHub counts every LFS download against the owner's bandwidth allowance, documented as 10 GiB of bandwidth and 10 GiB of storage on Free, Pro, and Free for organizations, and 250 GiB of each on Team and Enterprise Cloud ([GitHub storage and bandwidth docs](https://docs.github.com/en/repositories/working-with-files/managing-large-files/about-storage-and-bandwidth-usage), checked on 2026-08-13). Fan-out spends that allowance at the same rate it spends runner minutes. Measure four numbers on your own repository before changing a workflow line. | Question | How to get it | What it decides | | ---------------------------- | ---------------------------------------------------------- | --------------------------------------- | | Total LFS bytes at the tip | git lfs ls-files -s and sum the size column | The download half of the cost | | Count of tracked paths | git lfs ls-files and count the lines | The pointer resolution half | | Paths the job actually reads | git lfs ls-files -n cross-checked against the build inputs | The include list in the fix below | | Time inside the LFS step | Step duration in the job log, median of ten runs | The baseline the model gets compared to | ## [Fix](#fix)Copy Anchor Link **1\. Stop the smudge filter from downloading during checkout.** Leave `lfs` at its default of `false`, or set `GIT_LFS_SKIP_SMUDGE=1` for the job when something else in the pipeline turns it on. The working tree lands as pointer files and checkout finishes at plain-checkout speed. **2\. Pull the subset the job reads.** `git lfs pull --include="" --exclude=""` fetches and checks out only the matching objects. The `--include` and `--exclude` options set [lfs.fetchinclude and lfs.fetchexclude](https://github.com/git-lfs/git-lfs/blob/main/docs/man/git-lfs-pull.adoc) for that invocation, and patterns match with gitignore wildcard rules. Passing an empty `--exclude` clears any exclude list inherited from config, so the include list is the whole filter. **3\. Give each job its own include list.** A test shard that reads audio fixtures and a build job that reads textures want different subsets. Scoping per job is where the arithmetic in the cost model comes from, because the union of the subsets is usually a small share of the tip. **4\. Raise the transfer concurrency when the objects are many and small.** `git config lfs.concurrenttransfers 16` doubles the default of 8\. This helps a subset made of thousands of small objects, where request round trips dominate, and does little for a subset made of a few large ones. **5\. Cache the object directory between runs.** `.git/lfs/objects` is content addressed, so a run that changes no assets can restore instead of download. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` and is enabled by default on WarpBuild runners; the restore and save mechanics are in the [caching documentation](/docs/ci/features/caching). **6\. Move to machine state when the subset itself stops fitting a transfer.** Once the objects a job needs run to tens of gigabytes, the restore stops paying and the working set belongs on the disk the job boots with. Snapshot runners boot a later job from a captured VM image with the objects already in place, and the disk shape per label is on the [cloud runners catalog](/docs/ci/cloud-runners) and the [local SSD runner page](/runners/local-ssd). ## [Configuration](#configuration)Copy Anchor Link The workflow below checks out pointers, restores the object directory, pulls two asset trees, and saves on the default branch. Copy Text ``` name: ci on: pull_request: push: branches: [main] env: GIT_LFS_SKIP_SMUDGE: 1 jobs: test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 with: lfs: false fetch-depth: 1 - name: Hash the LFS object IDs for this ref id: lfs run: | git lfs ls-files -l | cut -d' ' -f1 | sort > .lfs-oids echo "key=$(sha256sum .lfs-oids | cut -c1-16)" >> "$GITHUB_OUTPUT" - name: Restore LFS objects uses: WarpBuilds/cache/restore@v1 with: path: .git/lfs/objects key: lfs-${{ runner.os }}-${{ steps.lfs.outputs.key }} restore-keys: | lfs-${{ runner.os }}- - name: Fetch only the objects this job reads run: | git config lfs.concurrenttransfers 16 git lfs pull --include="assets/textures/**,assets/audio/**" --exclude="" - run: ./run-tests.sh - name: Save LFS objects if: success() && github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: .git/lfs/objects key: lfs-${{ runner.os }}-${{ steps.lfs.outputs.key }} ``` Three details carry the design. The cache key hashes the object IDs the ref points at, so an asset change misses the key and `restore-keys` falls back to the newest prefix match, leaving `git lfs pull` to fetch the delta. The save is gated on success and on the default branch, so a failed run never seeds later ones. And `--exclude=""` keeps a repository-level exclude list from silently widening or narrowing what the job pulls. Where every job wants the same subset, move the pattern into a `.lfsconfig` file at the repository root and drop the flags: Copy Text ``` [lfs] fetchinclude = assets/textures/**,assets/audio/** fetchexclude = archive/**,docs/media/** concurrenttransfers = 16 ``` One distinction is worth keeping straight. The `filter` input on `actions/checkout` performs a [partial clone](https://github.com/actions/checkout) and controls which git blobs arrive with the history. LFS objects live outside the packfiles, so `filter: blob:none` shrinks the git side of the transfer while leaving the LFS side untouched. Repositories that carry both a deep history and a large object store need both levers, and the git side is covered in [speeding up git checkout in GitHub Actions](/guides/faster-git-checkout-github-actions). Platform matters for asset pipelines that build on more than Linux. The same include list can serve a Linux test shard and a macOS packaging job under one workflow. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Model inputs, all replaceable with numbers from your own run history: * 62 GB of LFS objects at the tip, across 41,000 tracked paths. * A test job that reads 4.5 GB of them, the textures and audio trees. * 220 MB per second of sustained LFS transfer on the runner. * 1.2 minutes for the git fetch and tree write, identical in both columns. * 6.0 minutes of build and test after checkout. * 8 jobs per run, 900 runs per month, so 7,200 jobs per month. * `warp-ubuntu-latest-x64-8x` at $0.016 per minute, from the [pricing page](/pricing), checked on 2026-08-13. | Line | lfs: true on every job | Selective pull | | ------------------------ | ---------------------- | -------------- | | LFS bytes per job | 62.0 GB | 4.5 GB | | LFS transfer time | 4.70 min | 0.34 min | | Git fetch and tree write | 1.20 min | 1.20 min | | Build and test | 6.00 min | 6.00 min | | Job wall clock | 11.90 min | 7.54 min | | Cost per job | $0.19 | $0.12 | | 7,200 jobs per month | $1,370.88 | $868.61 | | LFS bandwidth per month | 446.4 TB | 32.4 TB | The runner bill moves by $502.27 per month in this model. The bandwidth line moves further, from 446.4 TB to 32.4 TB against an allowance that tops out at 250 GiB before overage on Team and Enterprise Cloud, which is often the reason this work gets scheduled at all. Disk is the other constraint the numbers expose. Every WarpBuild Linux label carries 150GB SSD, so a 62 GB object set plus history plus build output sits close to that ceiling on a full pull and comfortably inside it on a 4.5 GB subset. Sizes and rates for the labels worth testing: | Runner label | vCPU | RAM | Disk | Price per minute | | --------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | 150GB SSD | $0.012 | Rates from the [pricing page](/pricing), checked on 2026-08-13\. Moving up a size buys vCPU and RAM while the disk stays at 150GB, so a job bounded by object transfer gains little from a larger label until the include list has already been narrowed. For finding where the time actually goes, CI observability correlates runner system metrics with GitHub Actions job logs, which separates a job saturating its cores from a job sitting in a network transfer, and the Action Debugger opens a session on the live runner so you can run `git lfs ls-files -s` in place. If the object store is only one part of a repository that is large in every direction, read the [large repository guide](/guides/large-repository-github-actions) next; if the assets belong to a game project, [Unity builds on GitHub Actions](/solutions/unity-github-actions) covers the pipeline around them. ## [FAQ](#faq)Copy Anchor Link ### [Why is Git LFS slow in GitHub Actions?](#why-is-git-lfs-slow-in-github-actions)Copy Anchor Link Because an LFS checkout pays two costs on top of a plain one. Pointer resolution runs the smudge filter across every LFS-tracked path in the tree and resolves those object IDs against the LFS server, which scales with the number of tracked files. Object download then moves the bytes themselves, scoped to every object the checked-out ref points at rather than to the files the pull request changed. ### [How do I fetch only some Git LFS files in a workflow?](#how-do-i-fetch-only-some-git-lfs-files-in-a-workflow)Copy Anchor Link Leave the checkout action's `lfs` input at its default of `false` so the tree lands as pointer files, then run `git lfs pull --include="" --exclude=""` for the paths the job reads. The include and exclude options set `lfs.fetchinclude` and `lfs.fetchexclude` for that one invocation, and path matching follows gitignore wildcard rules. ### [Does caching the LFS object directory between runs help?](#does-caching-the-lfs-object-directory-between-runs-help)Copy Anchor Link Yes, when the include list is stable and most objects survive from run to run. Cache `.git/lfs/objects` keyed on a hash of the object IDs that `git lfs ls-files` reports for the ref, so a run that changes no assets restores instead of downloading. The keyed entry misses whenever an asset changes, and `restore-keys` falls back to the newest prefix match. --- # Tracking Build Duration Percentiles on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-build-duration-percentiles Description: GitHub Actions gives you one duration per run and no percentiles. The WarpBuild Jobs report shows duration P75 and P90 per repository, workflow, and job. # Tracking Build Duration Percentiles on GitHub Actions GitHub Actions gives you one duration per run and no percentiles. The WarpBuild Jobs report shows duration P75 and P90 per repository, workflow, and job. Last verified: 2026-08-13 GitHub Actions records a duration for each job run and stops there, so a percentile view of a workflow has to be assembled somewhere outside the run log. The WarpBuild Jobs report assembles it for you: every unique repository, workflow, and job name combination carries run count, success rate, duration P75 and P90, and queue time P75 and P90, with CPU and memory percentiles added when Observability is enabled. This guide covers why the average build time hides the problem you are chasing, what a widening gap between duration P75 and P90 tells you about capacity and flakiness, the exact path to those numbers in the Jobs report, a repeatable way to track them week over week through CSV export, and a time model that converts a P90 reduction into engineer wait time at a stated job volume. ## [Diagnosis](#diagnosis)Copy Anchor Link The average build time is the number most teams put on a dashboard, and it is the number least likely to move when something breaks. Take a test job with 400 runs a month. Suppose 360 of those runs finish in 8 minutes and 40 of them finish in 24 minutes because they retry a flaky integration step. The mean is 9.6 minutes. Fix nothing, let the flaky step degrade until those 40 runs take 27 minutes, and the mean moves to 9.9 minutes. A 3 minute regression on the runs that hurt the most shows up as an 18 second wiggle on the chart that leadership reads. Percentiles keep the tail attached to the number. Duration P90 in that same example moves from 24 to 27 minutes, which is the size of the regression, reported at the size it actually is. The Jobs report exposes two duration percentiles, P75 and P90, and the relationship between them is the diagnostic. P75 describes the run a developer expects. P90 describes the run that makes someone open Slack and ask whether the build is stuck. Read the gap. | What the two percentiles do | What it usually means | Where to look next | | ---------------------------------- | ------------------------------------------------- | ------------------------------------------------- | | P75 flat, P90 climbing | A tail is forming on a minority of runs | Success rate on the same row, then queue time P90 | | P75 and P90 climbing together | The job itself got heavier for every run | Step timing, dependency install, test count | | P75 climbing, P90 flat | The floor moved and the ceiling was already there | Runner size and cache hit behavior | | Both flat, end to end time growing | The growth is outside execution | Queue time P75 and P90 per runner label | A widening P75 to P90 gap has two common causes, and they are separable with one more column each. **Capacity contention.** Waiting for a runner is counted separately from running on one. When queue time P90 climbs while duration P90 stays put, the job held its speed and the fleet ran out of room. Queue Timings reports P75 and P90 wait per runner label and stack, so a single label saturating shows up in isolation rather than smeared across the whole estate. On WarpBuild runners this shape has a different cause than a fixed cap: run as many jobs as your workflows need, since generally available Linux and Windows runners do not have plan-level concurrency caps. **Flakiness and retries.** Success rate sits on the same row as the duration percentiles for a reason. A job at 94 percent success with duration P90 well above P75 is describing runs that failed partway, retried, and paid for the work twice. Averages absorb that. P90 does not. One more property matters when you start tracking these numbers seriously. Percentiles do not add up. The duration P90 of a workflow is not the sum of the duration P90 of its jobs, because the slowest 10 percent of one job and the slowest 10 percent of another are usually different runs. Track percentiles per job, and use end to end run time separately when you want the pipeline number. ## [Fix](#fix)Copy Anchor Link The measurement path is short. Everything below lives in the [Reports documentation](/docs/ci/features/reports) and the [Observability documentation](/docs/ci/features/observability). **Step 1: open the Jobs report and set the window.** The Jobs section aggregates per unique repository, workflow, and job name combination. Each row is one job across all of its runs in the selected period, and the window is the first thing to fix, because a 7 day window and a 30 day window produce different percentiles from the same data. **Step 2: read the row.** These are the columns available per row. | Column | What it reports | | -------------------- | -------------------------------------------------- | | Run Count | Total executions in the selected period | | Success Rate | Percentage of runs that succeeded | | Duration P75 / P90 | 75th and 90th percentile execution time | | Queue Time P75 / P90 | 75th and 90th percentile time waiting in the queue | | CPU P75 / P90 | 75th and 90th percentile peak CPU utilization | | Memory P75 / P90 | 75th and 90th percentile peak memory utilization | CPU and memory require Observability to be enabled for the runner, and Observability collects metrics only for jobs longer than about one minute. Jobs without telemetry render a dash in those two pairs of columns and still report everything else. **Step 3: sort by duration P90 descending and filter by run count.** A job with 4 runs in the window has a P90 that means nothing. Sorting by the tail and reading run count next to it is how you separate a real regression from an artifact of a job that barely ran. **Step 4: put the number on a line.** The time-series chart plots one selected metric at one selected percentile over time, with a line per job from the current table page. Set the metric to duration and the percentile to P90, keep the same filters, and a regression is a line that bends. Filters available on the section are repository, workflow, job name, runner label, and stack. **Step 5: cross-check the machine.** If duration P90 is climbing and CPU P90 is sitting near the ceiling, the job is core bound and the size is the lever. If duration P90 is climbing while CPU P90 sits in the low tens, the time is going somewhere other than compute, and the [slow step walkthrough](/guides/find-the-slow-step-github-actions) is the faster path than buying cores. BYOC runs on AWS, GCP, and Azure, so a job running on a runner inside your own cloud account reports the same percentiles in the same table as a hosted one. ## [Configuration](#configuration)Copy Anchor Link Percentile tracking depends on stable aggregation keys. The key is repository, workflow, and job name, so a renamed job starts a new row and loses its history. Name jobs for what they do and leave the names alone. Copy Text ``` name: ci on: pull_request: jobs: lint: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run lint unit-tests: runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/4 integration-tests: needs: unit-tests runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - run: make integration ``` Three stable job names, three sizes from the catalog. Filtering the Jobs report by the `warp-ubuntu-latest-x64-8x` runner label then isolates every job on that size across every repository, which is the view to use when you want to know whether a size is carrying more than it should. ### [Tracking the numbers week over week](#tracking-the-numbers-week-over-week)Copy Anchor Link The dashboard answers what happened. A file on disk answers what changed. Every report tab exports CSV, and the export contains all rows matching the current filters and sort order rather than the visible page, which is what makes the export comparable between weeks. The procedure is four fixed choices and one habit. 1. Fix the window. Use a rolling 7 day window, taken on the same weekday every time. 2. Fix the filters. One repository, or one runner label, whichever unit you plan to defend in a review. 3. Fix the sort. Duration P90 descending, so the file leads with the tail. 4. Export and keep the file, named by the date it was taken. Copy Text ``` # each Monday, same window, same filters, same sort mkdir -p metrics && mv ~/Downloads/jobs.csv metrics/jobs-2026-08-13.csv # compare duration P90 between two weekly exports, keyed on repo + workflow + job # set COL to the duration P90 column index in your export COL=6 awk -F, -v col="$COL" 'NR>1 {print $1"|"$2"|"$3"\t"$col}' metrics/jobs-2026-08-06.csv | sort > /tmp/prev awk -F, -v col="$COL" 'NR>1 {print $1"|"$2"|"$3"\t"$col}' metrics/jobs-2026-08-13.csv | sort > /tmp/curr join -t $'\t' /tmp/prev /tmp/curr | awk -F'\t' '{d=$3-$2; if (d>1) printf "%s +%.1f min\n", $1, d}' ``` Anything printing a positive delta above a minute is a job whose tail grew in a week. That list is short, it is specific, and it names the repository, workflow, and job to open. The [metrics API guide](/guides/github-actions-metrics-api) covers the endpoints, the query parameters, and how to schedule the pull instead of clicking the download button. For row-level data behind the percentiles, the CI billing tab in Reports is the source: one row per job execution with repository, job name, runner label, stack, execution time, and billed time. Use billed time when you reconcile against an invoice, and execution time when you are studying duration. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link A percentile improvement is a wait-time result before it is a billing result, so model the time first. **Assumptions.** One `integration-tests` job, 1,200 runs a month, currently on `warp-ubuntu-latest-x64-4x`. Duration P75 is 16.0 minutes and duration P90 is 22.0 minutes, a 6.0 minute gap. After moving the job to `warp-ubuntu-latest-x64-8x`, P75 reads 12.0 minutes and P90 reads 13.0 minutes. Rates are the published per minute rates below. Queue time is excluded, because queued minutes are not billed. **The tail-only bound.** P90 describes the slowest 10 percent of runs. At 1,200 runs a month that is 120 runs, each 9.0 minutes shorter: 1,080 minutes, or 18.0 hours a month of engineer wait removed from the runs that were hurting most. **The whole-distribution bound.** P75 also moved, by 4.0 minutes. If every run improves by at least that much, 1,200 runs times 4.0 minutes is 4,800 minutes, or 80.0 hours a month. The real figure sits between 18.0 and 80.0 hours. Settle it by re-exporting after four weeks and reading the two percentiles again rather than by arguing about the model. **Now the bill.** Percentiles do not multiply into an invoice, so the cost side needs a mean. Assume the mean duration is 14.0 minutes on the 4x size and 9.0 minutes on the 8x size. Linux x64 rates from the WarpBuild [pricing page](/pricing), billed per minute: | Label | vCPU | RAM | Storage | USD per minute | | -------------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Current spend is 1,200 x 14.0 = 16,800 minutes at $0.008, which is $134.40 a month. After the move, 1,200 x 9.0 = 10,800 minutes at $0.016, which is $172.80 a month. The change costs $38.40 a month and returns somewhere between 18.0 and 80.0 hours of waiting. The break-even mean on the 8x size is 7.0 minutes: below that, the upgrade lowers the bill as well as the clock. For a list-price baseline on the same shape, GitHub publishes $0.022 per minute for its 8-core Linux larger runner at 8 vCPU and 32 GB in the [GitHub billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. The same 10,800 minutes at that rate is $237.60 a month, against $172.80 on `warp-ubuntu-latest-x64-8x` at $0.016 per minute for 8 vCPU and 32 GB. If the number that grew is queue time rather than duration, [cut GitHub Actions queue times](/guides/github-actions-queue-times) works that path. For the estate-wide view of what gets reported across repositories, start at [GitHub Actions observability with WarpBuild](/solutions/github-actions-observability). ## [FAQ](#faq)Copy Anchor Link **Which duration percentiles does the WarpBuild Jobs report expose?** Duration P75 and P90, reported per unique repository, workflow, and job name combination, alongside queue time P75 and P90\. The time-series chart plots either percentile for the jobs on the current table page, so a regression shows up as a line bending upward. **Why is average build time a bad target to optimize against?** The mean folds the slow runs into the fast ones and moves slowly. A job with 400 runs a month can add three minutes to its slowest 40 runs and shift the mean by 18 seconds. Percentiles keep the tail visible, which is where retries, cold caches, and queue contention live. **What does a widening gap between duration P75 and P90 mean?** A tail is forming while the typical run holds steady. Check the queue time P75 and P90 in the same row to separate waiting from running, then check success rate, because retried and flaky runs land in the tail before they land in the mean. **Why do the CPU and memory columns show a dash for some jobs?** Those columns need Observability enabled for the runner, and Observability collects metrics only for jobs longer than about one minute. Rows without telemetry still report run count, success rate, duration percentiles, and queue time percentiles. --- # The GitHub Actions Cache Size Limit, Explained | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-cache-size-limit Description: GitHub Actions caps all caches in a repository at 10 GB by default and evicts least recently used entries first. How the ceiling works, how to remove it. # The GitHub Actions Cache Size Limit, Explained GitHub Actions caps all caches in a repository at 10 GB by default and evicts least recently used entries first. How the ceiling works, how to remove it. GitHub Actions limits the combined size of all caches in a repository to 10 GB by default, and once the total passes that ceiling GitHub deletes entries in least recently used order until the repository fits again. That eviction is why a cache key that hit yesterday can miss this morning with no change to your workflow. WarpBuild removes the ceiling: the [WarpBuild cache](/docs/ci/features/caching) is a drop-in replacement for `actions/cache@v4` with no repository size limit, and switching to it is a one-line change. ## [Diagnosis](#diagnosis)Copy Anchor Link Three rules decide how long a GitHub Actions cache entry survives. All three come from [GitHub's caching documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows), checked on 2026-08-13: * A repository can hold any number of cache entries, but GitHub caps their combined size at 10 GB by default. * When the total exceeds the cap, GitHub evicts caches in order of least recent access until the repository is back under the limit. * Any entry that goes unused for 7 days is deleted, whatever the total size. GitHub has since layered administrative controls on top of the default: organization admins can adjust the cache size limits and the expiration policy under the organization's Actions settings. Most repositories still run with the 10 GB default, and everything below assumes it. Start by measuring instead of guessing. The Caches view under the repository's Actions tab lists every entry with its size, its branch scope, and when it was last used. The GitHub CLI returns the same data sorted by weight: Copy Text ``` gh cache list --sort size_in_bytes --limit 30 ``` The REST endpoint `GET /repos/{owner}/{repo}/actions/cache/usage` returns the active entry count and the total size in bytes, which is the exact number the 10 GB ceiling applies to. If that total sits near the cap, eviction is already happening; the last-accessed timestamps tell you whether an entry disappeared because it aged past 7 days or because something newer pushed it out. Four failure modes account for most reports that read "the cache was there yesterday". ### [Cache evicted between runs](#cache-evicted-between-runs)Copy Anchor Link The ceiling applies to the whole repository, so unrelated workflows and branches compete for the same 10 GB. A 3 GB dependency cache per active branch across four branches is 12 GB, already over the cap before any other workflow stores a byte. GitHub then evicts whichever entry was accessed longest ago, and that is often the default branch entry that every pull request branch falls back to. The symptom is a restore step logging "Cache not found for input keys" on a key that hit the previous day, with no lockfile change in between. A busy afternoon of pull requests can evict the morning's caches; a nightly scheduled workflow that writes large entries can evict everything else while the team sleeps. ### [Key churn on lockfile hashes](#key-churn-on-lockfile-hashes)Copy Anchor Link A key such as `${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}` writes a brand new full-size entry every time the lockfile changes. The superseded entries linger for up to 7 days before the age rule collects them. A week of daily dependency bumps leaves seven near-identical copies of the same dependency tree in the store, and at 3 GB per copy that is 21 GB of dead weight pressed against a 10 GB ceiling. The entries you still need get evicted to make room for entries nobody will restore again. ### [restore-keys fallback returning a stale entry](#restore-keys-fallback-returning-a-stale-entry)Copy Anchor Link `restore-keys` performs a prefix match and restores the most recent entry whose key matches. That keeps builds warm across lockfile changes, but it has two sharp edges. First, the `cache-hit` output is only `true` on an exact primary key match, so a step guarded with `if: steps.cache.outputs.cache-hit != 'true'` still runs after a fallback restore; teams that instead skip installs on any restore ship stale dependencies. Second, saving on top of a stale restore compounds. The restored directory keeps packages the lockfile dropped, the save step writes them back under the new key, and entries grow a little with every generation. Growing entries fill the ceiling faster, which accelerates the eviction described above. ### [Per-branch cache scoping](#per-branch-cache-scoping)Copy Anchor Link A workflow run can restore caches created on its own branch or on the base branch, including the default branch. Sibling feature branches cannot read each other's entries. Each branch therefore writes its own copy of a mostly identical dependency cache, and five active branches at 2.5 GB each hold 12.5 GB. The scoping rule is a sensible security boundary, and it also means the effective budget per branch is far smaller than 10 GB on any repository with real parallel activity. ## [Fix](#fix)Copy Anchor Link Replace `actions/cache@v4` with `WarpBuilds/cache@v1`. The action is a drop-in replacement: the same `path`, `key`, and `restore-keys` inputs, the same `cache-hit` output, and matching `restore` and `save` subactions. The storage behind it has no repository ceiling, so entries stop competing for space and size-based eviction stops happening. Copy Text ``` - uses: actions/cache@v4 + uses: WarpBuilds/cache@v1 ``` Here is a complete workflow with the key and restore-keys shape most Node repositories use: Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Restore npm cache id: npm-cache uses: WarpBuilds/cache@v1 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - run: npm ci - run: npm test ``` One placement condition applies. The cache is available on Linux-based runners, both x64 and ARM64, where it is enabled by default, and WarpBuild caches are not supported for Windows-based runners. The workflow above runs on `warp-ubuntu-latest-x64-4x`, a 4 vCPU, 16 GB Ubuntu runner; the `runs-on` label is the only thing that moves a job onto WarpBuild. Expect the first run after the swap to be cold, because the WarpBuild store starts empty. From the second run on, the same keys resolve against storage that never evicts for space. Retention becomes purely time-based: entries expire 7 days after their last use, the same sliding window GitHub applies, so an entry restored every weekday stays warm indefinitely. Entries can be removed early with the `delete-cache` input or from the WarpBuild console. For toolchain and package manager caching there is a shorter path than writing keys by hand. WarpBuild maintains [drop-in setup actions](/docs/ci/features/caching/setup-actions) for Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise. `WarpBuilds/setup-node@v6` accepts the same inputs as `actions/setup-node`, and its `cache: npm` input routes dependency caching to WarpBuild storage automatically. Know the boundary of what a cache action can hold. It stores files and directories you name by path. Machine state that lives outside a cacheable path, such as a warmed Docker daemon or a fully hydrated monorepo working directory, belongs to [snapshot runners](/runners/snapshot), which boot later jobs from a VM image captured mid-workflow. The [persistent caches guide](/guides/persistent-caches-github-actions) covers which mechanism fits which kind of state. ## [Configuration](#configuration)Copy Anchor Link Key design carries over unchanged, because `WarpBuilds/cache` scopes entries to key, version, and branch exactly as GitHub does. The pattern that behaves best under lockfile churn is an exact key on the hash plus a prefix fallback: Copy Text ``` key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- ``` An exact hit restores the current dependency set. A fallback hit restores the newest earlier entry, and `npm ci` reconciles the difference. Keep install steps unconditional unless you gate them on an exact hit, since `cache-hit` stays `false` on fallback restores. When the save should happen regardless of whether later steps fail, split the action into its `restore` and `save` halves: Copy Text ``` - name: Restore dependencies id: deps-restore uses: WarpBuilds/cache/restore@v1 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} - run: npm ci - name: Save dependencies if: always() uses: WarpBuilds/cache/save@v1 with: path: ~/.npm key: ${{ steps.deps-restore.outputs.cache-primary-key }} ``` Branch scoping still applies, so seed shared entries from the default branch. A push-triggered workflow on `main` writes the entry once, and every pull request branch reaches it through `restore-keys`. That single change removes most duplicate per-branch copies, which mattered enormously under a 10 GB ceiling and still keeps storage costs tidy without one. The action also carries the operational inputs from upstream: `fail-on-cache-miss: true` turns a missing entry into a hard failure for jobs that must never build cold, `lookup-only: true` checks for existence without downloading, and `enableCrossOsArchive` allows entries saved on one operating system to restore on another. Three details from the [caching documentation](/docs/ci/features/caching) save debugging time: * Inside a container, the action needs `wget` and `zstd` installed, and the `WARPBUILD_RUNNER_VERIFICATION_TOKEN` environment variable passed through to the container to authenticate against the cache service. * Cache versions hash the compression tool and the cached paths. An entry saved on a macOS runner will never restore on a Linux runner; they are distinct caches by construction. * Docker layer caching runs through the same backend. Point buildx at `type=gha,url=http://127.0.0.1:49160/,version=1` in `cache-from` and `cache-to`, with `mode=max` to cache intermediate layers. For image-heavy pipelines, remote Docker builders keep a persistent layer cache on a dedicated build VM instead, which avoids moving layers in and out of cache storage on every run; the [Docker layer caching guide](/guides/docker-layer-caching-github-actions) compares the two setups. Spending stays visible after the switch: cache usage appears as its own line in the Cache Billing report, split into storage and operation costs, so growth shows up in a chart rather than as mystery misses. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Prices first. Swapping the cache action does not require swapping runners, so the worked model below prices runner minutes at WarpBuild's own rate for the workflow's runner and isolates miss count as the only variable. WarpBuild's Linux runner rates, from the [pricing page](/pricing): | Runner label | vCPU | RAM | Price per minute | | ---------------------------- | ---- | ------ | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 | | warp-ubuntu-latest-arm64-2x | 2 | 8 GB | $0.003 | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | | warp-ubuntu-latest-arm64-16x | 16 | 64 GB | $0.024 | | warp-ubuntu-latest-arm64-32x | 32 | 128 GB | $0.048 | Cache add-on rates: | Metric | Hosted runners | BYOC | | ---------------------- | --------------------- | ---- | | Cache storage | $0.20 per GB-month | Free | | Cache write or restore | $0.0001 per operation | Free | Now the model. Assumptions, stated so you can substitute your own numbers: * 40 workflow runs per weekday, 22 weekdays per month. * A cold dependency install takes 6 minutes; restoring a warm cache takes 1 minute. Each miss therefore costs 5 extra minutes. * The dependency cache entry is 3 GB, four branches stay active, and lockfile churn keeps roughly 18 GB of entries live at any moment. * Under the 10 GB ceiling, eviction, key churn, and branch scoping together produce 12 misses per day, a 30 percent miss rate, which is a realistic shape once a repository's cache working set is about double its ceiling. * Without a ceiling, misses happen only when a lockfile actually changes: 2 per day. Time cost. Twelve misses at 5 extra minutes is 60 minutes of added pipeline time every day, spread across the day's runs. Over a month that is 22 hours of runner time spent rebuilding artifacts that were already built and then evicted. Money cost. Both columns below price runner minutes at $0.008, the `warp-ubuntu-latest-x64-4x` list rate from the table above, because the runner does not change between the two scenarios; only the cache backend does. The difference comes from miss counts alone. | Line | 10 GB ceiling, GitHub cache | No ceiling, WarpBuild cache | | ------------------------------- | --------------------------- | ------------------------------------- | | Cache misses per day | 12 of 40 runs | 2 of 40 runs | | Extra runner minutes per month | 1,320 | 220 | | Runner cost of misses per month | $10.56 | $1.76 | | Cache storage per month | $0 within the cap | $3.60 for 18 GB at $0.20 per GB-month | | Cache operations per month | $0 | $0.18 for 1,760 at $0.0001 each | The storage and operation charges land on the WarpBuild side and total $3.78 per month for this workload; on BYOC both are free. The trade is explicit: pay for the gigabytes your caches actually occupy, and stop paying rebuild minutes for entries that eviction destroyed. On larger runners, on bigger caches, or on teams with more branches, the rebuild column grows much faster than the storage column, which is worth checking against your own `gh cache list` output before you decide. Two structural notes belong in any cost comparison. Cache behavior is one lever among several; the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions) covers runner sizing and the rest. On the outcome side, watch the cache hit rate in the Cache Billing report after the switch: it is usually the first number that moves once the ceiling stops applying. ## [FAQ](#faq)Copy Anchor Link ### [What is the GitHub Actions cache size limit?](#what-is-the-github-actions-cache-size-limit)Copy Anchor Link GitHub caps the combined size of all caches in a repository at 10 GB by default. Once the total passes the cap, GitHub evicts entries in least recently used order until the repository is back under the limit, and any entry unused for 7 days is deleted regardless of the total. Checked on 2026-08-13 against GitHub's caching documentation. ### [Does the WarpBuild cache have a size limit?](#does-the-warpbuild-cache-have-a-size-limit)Copy Anchor Link No. The WarpBuild cache has no repository size ceiling, so entries are never evicted to make room for newer ones. Entries expire 7 days after their last use. Storage is billed at $0.20 per GB-month and operations at $0.0001 each on hosted runners; both are free on BYOC. ### [Is WarpBuilds/cache compatible with actions/cache@v4?](#is-warpbuildscache-compatible-with-actionscachev4)Copy Anchor Link Yes. WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4\. It accepts the same path, key, and restore-keys inputs, produces the same cache-hit output, and ships matching restore and save subactions, so a workflow switches by changing one line. ### [Does the WarpBuild cache work on Windows runners?](#does-the-warpbuild-cache-work-on-windows-runners)Copy Anchor Link No. WarpBuild caches are not supported for Windows-based runners. The cache is available on Linux-based runners, both x64 and ARM64, where it is enabled by default. --- # Removing Cold Starts from GitHub Actions Jobs | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-cold-starts Description: A GitHub Actions cold start is the setup time before your first real step runs. Split it into five parts, then cut it with standby disks and snapshots. # Removing Cold Starts from GitHub Actions Jobs A GitHub Actions cold start is the setup time before your first real step runs. Split it into five parts, then cut it with standby disks and snapshots. Last verified: 2026-08-13 A GitHub Actions cold start is the stretch between a job entering the queue and the first line of your own work running, and it is five separate costs stacked on top of each other: runner allocation, image boot, toolchain install, dependency restore, and first-run compilation. Two documented levers cut different parts of that stack: standby disks let a BYOC runner start a job in about 15 seconds, and snapshot runners boot from saved VM state in 45 to 60 seconds with the toolchain and dependencies already on disk. This guide splits the stack so you can measure each part from the job log, matches each lever to the parts it actually removes, gives the configuration for both, and converts per-run setup seconds into weekly wait at a stated job volume. ## [Diagnosis](#diagnosis)Copy Anchor Link Treating cold start as one number hides which part you are paying for, and the five parts respond to completely different fixes. Get a duration for each before changing anything. **Runner allocation.** The gap between the job appearing in the queue and a machine being assigned to it. On a fixed self-hosted pool this is wait for a free runner. On a per-job provisioner it is the time to select an instance type, launch a VM, and register the runner agent with GitHub. Nothing of yours runs during this window and nothing is billed as runner minutes, so it disappears from invoices and shows up only as people waiting. If this part dominates, read the guide to [GitHub Actions queue times](/guides/github-actions-queue-times) first, since allocation and queueing share most of their causes. **Image boot.** The machine exists and now has to become usable: kernel boot, cloud-init or equivalent, disk attach, network configuration, container runtime start, and the runner agent handshake. In the log this is folded into the `Set up job` step along with allocation, which is why the two are usually measured together. **Toolchain install.** Every `setup-*` action, `apt-get install`, `brew install`, SDK download, and container image pull that happens before your build command. A job that installs a language runtime, a package manager, a linter, and two CLIs pays for four network round trips and four extractions on every single run. These steps each carry their own duration in the GitHub Actions log, so this part is the easiest of the five to quantify. **Dependency restore.** `npm ci`, `bundle install`, `go mod download`, `pip install`, or a cache restore action pulling a tarball and unpacking it. Cache restore is faster than a fresh install and still costs a download plus an extraction proportional to the size of the tree. A 1.5 GB `node_modules` restores slowly whatever the cache hit rate. **First-run compilation.** The compiler starts with empty incremental state, so it rebuilds output that the previous run already produced. Rust rebuilds the dependency graph into `target/`, Gradle rebuilds without a warm daemon or build cache, TypeScript rebuilds without `.tsbuildinfo`, and a Docker build without a warm layer cache re-executes every layer. This part is invisible in the step list because it hides inside your build command, so it shows up as the build step taking longer on a fresh machine than it does locally. ### [Getting numbers per part](#getting-numbers-per-part)Copy Anchor Link Three passes give you the split without new tooling. 1. Read `Set up job` in the raw log. Expand it and take the timestamp difference between the job start and the first line of the first real step. That single number covers allocation and image boot together. 2. Read the step durations printed next to each step name. Toolchain install and dependency restore are already broken out if each `setup-*` action and each install command is its own step. If they are bundled into one shell script, split them for a week. 3. Isolate first-run compilation by running the same commit twice against a warm cache and taking the difference in the build step. What remains after the warm run is your steady-state build time, and the gap is the cold penalty. CI observability adds the system-level view on top of the durations. The runner agent reports OpenTelemetry system metrics correlated with the job logs, so a dependency restore that is bound by disk throughput looks different from one bound by network. ## [Fix](#fix)Copy Anchor Link The two levers cut different parts of the stack, and neither one covers all five. Match the lever to the part that dominates your split. ### [Standby disks cut allocation and boot on BYOC](#standby-disks-cut-allocation-and-boot-on-byoc)Copy Anchor Link A standby disk is a VM of the same instance type as the runner, booted and then immediately shut down. The boot has already initialized the VM, set up networking, and applied the rest of the machine configuration, so when GitHub requests a runner the control plane powers a waiting disk back on rather than building a machine from scratch. That takes the runner from request to a job starting in about 15 seconds, per the [standby disks documentation](/docs/ci/byoc/standby-disks). Four facts decide whether this lands: * The pool is maintained by the WarpBuild control plane, and reconciliation runs within about a minute. A burst that drains the pool is refilled on that timescale, so size the pool for concurrent jobs of that runner type rather than for daily volume. * Spot instances are not supported for standby disks. A spot instance can be reclaimed at any time, so a standby instance cannot be guaranteed. * If no standby disk is available, a new VM is created and allocated to the job. The job still runs; it starts at the slower time. * The instance type of a standby disk matches the runner type. Where fallback instance types are configured, the standby disk is one of those types. Standby disks are a BYOC feature. BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. ### [Snapshot runners cut toolchain, dependencies, and first-run compilation](#snapshot-runners-cut-toolchain-dependencies-and-first-run-compilation)Copy Anchor Link A snapshot runner boots from a saved VM state captured mid-workflow instead of rebuilding that state on every run. Boot takes 45 to 60 seconds, which is slower than a default runner boot, and the trade is deliberate: whatever the snapshot captured is already on disk when the job starts. If the snapshot was taken after `npm ci` and a full build, then toolchain install, dependency restore, and first-run compilation are all done before your first step executes. The scope rules matter more than the feature does: * Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. * BYOC, Windows, and macOS runners are unsupported, and a snapshot label on those runner types is silently ignored while the job runs normally. Nothing fails, so the missing speedup is easy to miss. * Snapshots are temporary and are deleted after 15 days. * The `/tmp` directory does not persist state, because it is cleaned on reboot. Full behavior is in the [snapshot runners documentation](/docs/ci/features/snapshot-runners). ### [Which lever cuts which part](#which-lever-cuts-which-part)Copy Anchor Link | Cold start part | Standby disks (BYOC) | Snapshot runners (Cloud Ubuntu) | | --------------------- | --------------------------------------- | ------------------------------------- | | Runner allocation | Cut to about 15 seconds with image boot | Unchanged | | Image boot | Cut to about 15 seconds with allocation | 45 to 60 seconds from saved state | | Toolchain install | Unchanged | Removed when captured in the snapshot | | Dependency restore | Unchanged | Removed when captured in the snapshot | | First-run compilation | Unchanged | Removed when captured in the snapshot | Provisions a machine per job on all of them, so the allocation part has no shared pool to saturate on any platform. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That matters for standby disk sizing: the pool changes how fast a job starts, never how many jobs may run at once. ## [Configuration](#configuration)Copy Anchor Link ### [Standby disk count per custom runner on BYOC](#standby-disk-count-per-custom-runner-on-byoc)Copy Anchor Link The number of standby disks is configurable per custom runner. Set it in the custom runners view of the dashboard for the runner type you want to start quickly, and choose the count from the number of jobs you expect to run concurrently on that runner type. A repository whose pull request workflow fans out to eight shards on one runner type wants at least eight, or it gets fast starts for the first few shards and normal starts for the rest. Reference the runner in the workflow by its Runner ID, which is the runner name prefixed with `warp-custom-`: Copy Text ``` name: test on: pull_request: branches: [main] jobs: test: runs-on: warp-custom-ubuntu-x64-8x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npx jest --shard=${{ matrix.shard }}/8 ``` The `runs-on` value is the only WarpBuild-specific line. Standby disks change the start time of that job without any workflow change at all, which makes the before and after comparison clean: hold the workflow constant, change the pool count, and read the `Set up job` duration across a week. ### [The snapshot.key label on Cloud Ubuntu runners](#the-snapshotkey-label-on-cloud-ubuntu-runners)Copy Anchor Link Snapshots attach to the `runs-on` label with a semicolon. Two forms exist: * `snapshot.enabled=true` turns the feature on and always boots from the base image. Pair it with the `snapshot-save` action to capture state at the point you choose. * `snapshot.key=` turns the feature on and boots from an existing snapshot for that alias when one exists. With no snapshot yet, the runner boots from the base image. The pattern that fits most repositories is to build the snapshot on `main` and consume it on pull requests: Copy Text ``` name: build on: pull_request: branches: [main] push: branches: [main] jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=web-app-warm' }} steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run build - run: npm test - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot uses: WarpBuilds/snapshot-save@v1 if: github.ref == 'refs/heads/main' with: alias: "web-app-warm" fail-on-error: true wait-timeout-minutes: 60 ``` Three configuration notes carry real weight here. **Clean up before you save.** The snapshot keeps whatever is on disk, including credentials written by earlier steps. Run `rm -rf $HOME/.ssh $HOME/.aws` and `git clean -ffdx` before the save step. On public repositories this is mandatory, since anyone can boot the snapshot by referencing the alias in a pull request workflow. On private repositories, runners are provisioned at the organization level and GitHub may hand a runner to a different job in the same organization, so the same cleanup applies. **Keep the alias stable and the content fresh.** A job booted from a snapshot exposes `WARPBUILD_SNAPSHOT_KEY` set to the alias, which is a cheap assertion to add to a step if you want the log to record whether the boot was warm. Snapshots are deleted after 15 days, so an alias that is only refreshed on release days eventually goes cold and every consumer quietly falls back to a base image boot. **Do not point snapshot labels at unsupported runner types.** A `snapshot.key` on a `warp-custom-` BYOC label or a Windows or macOS label is ignored without an error. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Per-run setup seconds](#per-run-setup-seconds)Copy Anchor Link The profile below is an example shape for a Node service with a moderate dependency tree. Substitute your own numbers from the three measurement passes in the diagnosis section. Allocation and image boot are merged into one row because the documented 15 second standby figure covers both together. | Cold start part | Baseline seconds | BYOC with standby disks | Cloud Ubuntu with snapshot.key | | -------------------------------- | ---------------- | ----------------------- | ------------------------------ | | Runner allocation and image boot | 65 | 15 | 60 | | Toolchain install | 35 | 35 | 0 | | Dependency restore | 60 | 60 | 10 | | First-run compilation | 45 | 45 | 15 | | Total setup per run | 205 | 155 | 85 | The snapshot column keeps 10 seconds of dependency restore and 15 seconds of compilation, because a snapshot taken yesterday still has to pick up whatever moved since. Modelling either as zero overstates the result. ### [Weekly wait at a stated job volume](#weekly-wait-at-a-stated-job-volume)Copy Anchor Link Assumptions, stated so you can replace them: * 1,200 GitHub Actions jobs per week, which is 240 per working day across 5 days. * Every job pays the setup profile above. * 1 job in 4 gates a person who is actively waiting, such as a pull request check before review. The arithmetic: * Baseline: 205 seconds x 1,200 = 246,000 seconds, or 4,100 minutes, or 68.3 hours of setup per week. * Standby disks: 155 x 1,200 = 186,000 seconds, or 3,100 minutes, or 51.7 hours. * Snapshot runners: 85 x 1,200 = 102,000 seconds, or 1,700 minutes, or 28.3 hours. That is 1,000 minutes per week returned by standby disks and 2,400 minutes per week returned by snapshots, or 16.7 and 40.0 hours. At the 1-in-4 watched ratio, the snapshot configuration hands about 10.0 engineer hours per week back to people who were sitting on a check. ### [What each lever costs](#what-each-lever-costs)Copy Anchor Link **Standby disks.** WarpBuild does not charge for standby disks. The cost lands on your own cloud bill in two pieces: the VM is billable for about 90 seconds while the standby disk initializes, after which it is shut down, and the network disk stays billable while the VM is shut down. Multiply your cloud provider's disk price by the pool size for the standing line item, and count 90 seconds of instance time each time a disk in the pool is created. **Snapshot runners.** Snapshot restore is $0.04 per job and snapshot storage is $0.025 per snapshot-hour, from the [pricing page](/pricing), checked on 2026-08-13\. The restore fee is worth covering against runner minutes removed, and the breakeven depends entirely on the size of the runner: | Runner label | Rate per minute | Job minutes that cover the $0.04 restore | | -------------------------- | --------------- | ---------------------------------------- | | warp-ubuntu-latest-x64-2x | $0.004 | 10.0 | | warp-ubuntu-latest-x64-4x | $0.008 | 5.0 | | warp-ubuntu-latest-x64-8x | $0.016 | 2.5 | | warp-ubuntu-latest-x64-16x | $0.032 | 1.25 | | warp-ubuntu-latest-x64-32x | $0.064 | 0.625 | Apply that to the profile above. The billed part of a job starts once the machine exists, so the snapshot removes 35 + 50 + 30 = 115 seconds of billed time per run, which is 1.92 minutes. On `warp-ubuntu-latest-x64-16x` at $0.032 per minute that is $0.061 of runner time against a $0.04 restore, so the fee is covered. On `warp-ubuntu-latest-x64-8x` at $0.016 it is $0.031 against $0.04, so the invoice moves slightly the other way and the wall clock is the reason to keep it. Storage is the smaller line. One alias held continuously for a week is 168 hours x $0.025 = $4.20, and a snapshot that is never refreshed reaches the 15-day deletion point at 360 hours x $0.025 = $9.00 across its whole life. ### [The rest of the pricing picture](#the-rest-of-the-pricing-picture)Copy Anchor Link Two follow-ons. The catalog, labels, and behavior for the snapshot feature are on [WarpBuild snapshot runners for GitHub Actions](/runners/snapshot), and the pool sizing detail for BYOC is on [standby disks for BYOC runners](/byoc/standby-disks). Once the setup profile is flat and the build command itself is the remaining cost, continue with [incremental builds on GitHub Actions](/guides/incremental-builds-github-actions). ## [FAQ](#faq)Copy Anchor Link **What counts as a cold start on a GitHub Actions job?** Everything between the job entering the queue and the first line of your own work, covering runner allocation, image boot, toolchain install, dependency restore, and first-run compilation. The `Set up job` step in the log covers allocation and boot, and the steps that follow carry the other three. Measure the five parts separately, because each one has a different fix. **Do standby disks work with spot instances?** No. Spot instances are not supported for standby disks, because a spot instance can be reclaimed at any time and a standby instance cannot then be guaranteed. Run standby disks on on-demand instance types and keep spot for the runner types where a slower start is acceptable. **Why do snapshot runners boot in 45 to 60 seconds when standby disks start a job in about 15 seconds?** They cut different parts of the stack. A snapshot runner boots from saved VM state, which takes 45 to 60 seconds and is slower than a default runner boot, and the payoff is that the toolchain, dependencies, and prior build output are already on disk when the job starts. Standby disks cut allocation and boot on BYOC and leave the three later parts untouched. **Can I use snapshot runners on BYOC, Windows, or macOS runners?** No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. A `snapshot.enabled` or `snapshot.key` label on a BYOC, Windows, or macOS runner is silently ignored and the job runs normally, which makes the failure hard to spot. On those runner types, use standby disks on BYOC and step-level caching everywhere else. --- # GitHub Actions Concurrency Limits, Explained | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-concurrency-limits Description: GitHub Actions concurrency limits come from the workflow concurrency key, plan-level job ceilings, and runner pool capacity. Match the symptom to the limit. # GitHub Actions Concurrency Limits, Explained GitHub Actions concurrency limits come from the workflow concurrency key, plan-level job ceilings, and runner pool capacity. Match the symptom to the limit. The phrase "GitHub Actions concurrency limits" covers three separate mechanisms: the `concurrency` key in workflow YAML, the concurrent job ceiling attached to your GitHub plan, and the capacity of the runner pool behind your `runs-on` labels. Each one queues work in a different way, and each one has a different fix. This guide separates the three, maps each symptom to the mechanism behind it, and shows the configuration that clears each one, including how teams remove the pool as a constraint by moving jobs to WarpBuild runners. ## [Diagnosis](#diagnosis)Copy Anchor Link When an engineer says "we are hitting concurrency limits", they mean one of three things. Fixing the wrong one burns a sprint, so identify the mechanism before touching YAML. ### [1\. The workflow-level concurrency key](#1-the-workflow-level-concurrency-key)Copy Anchor Link The `concurrency` key is a throttle you write yourself. It can sit at the top of a workflow, where it governs whole runs, or inside a job, where it governs that job across runs. Every run or job carrying the same group name shares one lane. A concurrency group holds one running item and one pending item. When a new run enters a group that already has a pending run, GitHub cancels the older pending run and parks the newer one. With `cancel-in-progress: true`, the new arrival also cancels the item that is currently running. GitHub documents this behavior in the workflow syntax reference. The key never makes anything start sooner. It cancels work or parks it. So the symptoms it produces are pending runs and canceled runs rather than a long queue of jobs. ### [2\. Plan-level concurrent job ceilings](#2-plan-level-concurrent-job-ceilings)Copy Anchor Link Standard GitHub-hosted runners draw from shared pools with a fixed ceiling on concurrent jobs. The ceiling depends on your GitHub plan, and macOS has its own lower sub-ceiling. Figures below are from [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13. | Plan | Concurrent jobs | Concurrent macOS jobs | | ---------- | --------------- | --------------------- | | Free | 20 | 5 | | Pro | 40 | 5 | | Team | 60 | 5 | | Enterprise | 500 | 50 | Jobs above the ceiling queue until a slot frees. The ceiling is account wide, so a large matrix fan-out in one repository can starve every other repository's workflows. Larger GitHub-hosted runners carry separate limits that GitHub documents alongside these. The macOS sub-ceiling explains a pattern many iOS teams recognize: Linux jobs start immediately while macOS jobs sit queued, because five macOS slots serve the whole account on Free, Pro, and Team plans. ### [3\. Runner pool capacity](#3-runner-pool-capacity)Copy Anchor Link Jobs that target self-hosted or managed labels sit outside the plan ceilings. Their queue is governed by how many matching runners can come online. For a static self-hosted fleet, that is the number of registered machines. For an autoscaled fleet, it is the controller's maximum, such as the max runners setting on an ARC scale set. For a managed provider, it is whatever capacity the provider holds behind the label. A job whose labels match zero online runners queues forever, which looks like a capacity problem and is actually misrouting. The [common issues doc](/docs/ci/common-issues) walks through the registration and permission checks that separate the two. ### [Which limit is yours](#which-limit-is-yours)Copy Anchor Link | Symptom | Mechanism | | --------------------------------------------------------------------- | ----------------------------- | | A run shows a "Waiting" banner naming a concurrency group | Workflow concurrency key | | A queued run flips to "Canceled" when a newer commit lands | Workflow concurrency key | | Running job count plateaus at 20, 40, 60, or 500 while the rest queue | Plan ceiling | | macOS jobs queue while Linux jobs on the same plan start | Plan ceiling, macOS sub-limit | | Jobs on one label queue while jobs on other labels start | Pool capacity for that label | | Jobs on a label never start at all | Misrouting or permissions | Measure before changing anything. For jobs on WarpBuild runners, the Queue Timings report in the dashboard breaks queue wait down per runner label and stack, with P75 and P90 percentiles, a daily chart, and CSV export. The Jobs report adds per-job queue time percentiles next to duration, CPU, and memory. Both are part of WarpBuild's CI observability surface, documented in the [reports doc](/docs/ci/features/reports). A P90 that spikes during business hours points at ceiling contention. A P90 that stays high on one label around the clock points at pool capacity for that label. For jobs on GitHub-hosted runners, subtract each job's queued timestamp from its started timestamp in the REST API output. If queue wait is high on every label at every hour, the problem is queue mechanics rather than a ceiling; the guide to [GitHub Actions queue times](/guides/github-actions-queue-times) covers that cluster. ## [Fix](#fix)Copy Anchor Link Fix the mechanism you diagnosed, in this order of cheapness. ### [Fix the concurrency key](#fix-the-concurrency-key)Copy Anchor Link Most concurrency key problems are scoping accidents. The standard shape scopes the group to one workflow on one ref: * Build the group name from `${{ github.workflow }}-${{ github.ref }}` so two branches never share a lane. * Set `cancel-in-progress` with an expression such as `${{ github.ref != 'refs/heads/main' }}` so pull request pushes cancel stale runs while main always finishes. * Give deploy jobs their own job-level group per environment without cancellation, so deploys serialize instead of canceling each other mid-release. * Audit for a static group name shared across workflows. A bare `concurrency: production` pasted into three workflows forces all three into one lane, which reads as a mysterious queue. ### [Fix the plan ceiling](#fix-the-plan-ceiling)Copy Anchor Link The ceiling on standard GitHub-hosted runners has no configuration knob. You have three options. First, reduce peak demand: path filters so documentation changes skip the test matrix, and a merge queue so batches share runs. Second, upgrade the plan, which moves the ceiling from 20 to 40, 60, or 500 concurrent jobs. Third, change `runs-on`. The ceiling follows the runner pool rather than the workflow, so relabeling a job removes it from the count entirely. ### [Fix pool capacity](#fix-pool-capacity)Copy Anchor Link For a self-hosted fleet, add machines or raise the autoscaler maximum, and budget for the idle cost that headroom implies. For a managed pool, pick one sized so your fan-out clears. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Capacity adjusts dynamically as workflows fan out. That statement carries the same scope as the [runner docs](/docs/ci/cloud-runners): features that are Generally Available support unlimited concurrency on Linux and Windows runners, while features in beta may have limits. If your use case requires high concurrency on macOS runners, contact [support@warpbuild.com](mailto:support@warpbuild.com). Teams that need pool capacity inside their own cloud account can bring their own cloud. BYOC runs on AWS, GCP, and Azure. Pool size then follows your cloud quotas instead of a vendor pool. Moving a matrix-heavy suite off a capped pool is a one-line change per job: relabel `runs-on` and the ceiling no longer applies. For the broader speed work that usually follows, see the guide to [speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Configuration](#configuration)Copy Anchor Link The workflow below combines both halves of concurrency control: a `concurrency` block that throttles runs per branch, and a matrix that fans one test suite out across twelve parallel jobs on three runner labels. Copy Text ``` name: test-suite on: pull_request: push: branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: test: strategy: fail-fast: false matrix: runner: - warp-ubuntu-latest-x64-4x - warp-ubuntu-latest-arm64-4x - warp-windows-latest-x64-4x shard: [1, 2, 3, 4] runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/4 ``` What each piece does: * The `group` expression keeps every branch in its own lane, so a push to a feature branch never parks a run on main. * The `cancel-in-progress` expression cancels superseded runs on pull request branches and lets main runs finish, which keeps deploy gates trustworthy. * `fail-fast: false` lets the other eleven shards finish when one fails, so a flaky shard reports alongside real results instead of masking them. * The matrix produces 3 runner labels x 4 shards = 12 jobs per run. GitHub caps a matrix at 256 jobs per workflow run, checked on 2026-08-13 against [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits). * If a downstream dependency rate-limits you, add `max-parallel` under `strategy` to cap the fan-out below the matrix size. The three labels resolve to these machines, from the [WarpBuild runner catalog](/docs/ci/cloud-runners): | runs-on label | OS | vCPU | RAM | Rate per minute | | --------------------------- | ------------------- | ---- | ---- | --------------- | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16GB | $0.008 | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16GB | $0.006 | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16GB | $0.016 | Sizes run from 2 to 32 vCPU per platform. The full Windows lineup, including Windows Server 2025 and the Visual Studio 2026 labels, is cataloged on the [Windows runners page](/runners/windows). For deploys, move the group to the job level and drop cancellation, so releases queue behind each other instead of killing an in-flight rollout: Copy Text ``` deploy: needs: test runs-on: warp-ubuntu-latest-x64-4x concurrency: group: deploy-production steps: - run: ./scripts/deploy.sh ``` A label typo queues a job forever with no logs. If a job never starts, work through the [common issues checklist](/docs/ci/common-issues) before assuming a capacity problem. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Concurrency changes wall clock time and leaves billed minutes alone. Here is the arithmetic for a realistic suite. Assumptions, stated up front: a test suite split into 24 shards; each shard takes exactly 8 minutes; shards are uniform, with no retries and no queue-to-start delay once a slot is free; billing is per minute. Total compute is 24 x 8 = 192 job-minutes per run in every scenario. **Serialized.** The suite runs on standard GitHub-hosted runners in an account on the Free plan, where other workflows leave 6 of the 20 slots free. The 24 shards move through 6 slots in 4 waves of 8 minutes each. **Fanned out.** The same suite targets a runner pool without a hard cap. All 24 shards start together. | Scenario | Available slots | Waves | Wall clock | Billed minutes | | ---------------------------------- | --------------- | ----- | ---------- | -------------- | | Serialized behind a shared ceiling | 6 | 4 | 32 min | 192 | | Fanned out on uncapped labels | 24 | 1 | 8 min | 192 | The fan-out returns 24 minutes of wall clock per run. At 30 runs a day, that is 720 minutes of engineer waiting removed daily, from a change that costs zero extra billed minutes. Now price those 192 minutes. The standard GitHub-hosted Linux runner for private repositories, the 2 vCPU, 8GB `ubuntu-latest` shape, bills at $0.006 per minute, from [GitHub's pricing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. Note that GitHub rounds each job up to the next full minute, per the [GitHub Actions billing docs](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions); the model uses whole 8-minute shards, so rounding adds nothing here. | Runner | Rate per minute | Billed minutes | Cost per run | | ---------------------------------------------------------- | --------------- | -------------- | ------------ | | GitHub-hosted Linux, standard, private repos (2 vCPU, 8GB) | $0.006 | 192 | $1.15 | | warp-ubuntu-latest-x64-2x (2 vCPU, 8GB) | $0.004 | 192 | $0.77 | | warp-ubuntu-latest-x64-4x (4 vCPU, 16GB) | $0.008 | 192 | $1.54 | | warp-ubuntu-latest-arm64-4x (4 vCPU, 16GB) | $0.006 | 192 | $1.15 | Two readings of that table. Fan-out is free in dollars: the serialized and fanned-out scenarios cost the same on any given runner, so there is no budget reason to accept a 32-minute wall clock. And matched shape for shape, the runner rate sets the whole bill: warp-ubuntu-latest-x64-2x (2 vCPU, 8GB) costs $0.004 per minute against $0.006 per minute for GitHub-hosted `ubuntu-latest`, the same 2 vCPU, 8GB shape, a 33 percent lower list price, GitHub pricing checked on 2026-08-13. Full rates by runner type are on the [WarpBuild pricing page](/pricing). If individual shards are also slower than they should be, the answer page on [why GitHub Actions are slow](/answers/why-are-github-actions-slow) covers the runner-side causes. ## [FAQ](#faq)Copy Anchor Link **Does the concurrency key limit how many jobs run in parallel?** No. The concurrency key throttles workflow runs, or single jobs when set at the job level, within a named group to one running plus one pending. Parallel jobs inside a single run are governed by your plan's job ceiling and by runner pool capacity. **Do GitHub's concurrency limits apply to self-hosted runners?** The per-plan concurrent job ceilings apply to GitHub-hosted runners only. Jobs that target self-hosted or managed labels queue on pool capacity instead. Repository-level limits, such as the 256-job matrix cap, still apply. **How many jobs can a single matrix create?** A matrix generates at most 256 jobs per workflow run. Beyond that, split the suite into multiple workflows or collapse a matrix dimension. **Does WarpBuild limit concurrency?** Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. The docs scope unlimited concurrency to GA features on Linux and Windows runners. For high concurrency on macOS, contact [support@warpbuild.com](mailto:support@warpbuild.com). --- # Attributing GitHub Actions Cost per Repository and Team | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-cost-attribution Description: GitHub Actions bills minutes as account level SKU totals. Attribute cost per repository and team with per job billing rows, runner labels and BYOC tags. # Attributing GitHub Actions Cost per Repository and Team GitHub Actions bills minutes as account level SKU totals. Attribute cost per repository and team with per job billing rows, runner labels and BYOC tags. Last verified: 2026-08-13 GitHub Actions cost per repository is not a number the Actions bill hands over: minutes arrive as SKU totals for the whole account, with no repository, workflow, or runner label attached to them. Per repository and per team attribution needs a per job record, which on WarpBuild is the CI tab of the Billing report, where every row is one job execution carrying its repository, job name, runner label, stack, snapshot usage, execution time, billed time and a runner plus snapshot cost split. This guide covers why an account level total stalls the conversation, which columns turn a bill into a chargeback table, how BYOC pushes the same attribution into your own cloud bill through stack tags, and a worked monthly model for three teams sharing one account. ## [Diagnosis](#diagnosis)Copy Anchor Link An Actions invoice is a short list of numbers. GitHub's usage report, downloadable from billing settings, is organized around SKUs: standard Linux minutes, Windows minutes, macOS minutes, and a line for each larger runner tier. That tells you the account spent more this month than last. It does not tell you which team should change something. **The unit is minutes by SKU.** A tier total is the sum of every workflow in the account that touched that tier. When the 8-core Linux line grows by 40,000 minutes, the number is real and the owner is unknown, so the follow-up meeting produces a request for everyone to be careful rather than a change to one workflow. The usual suspects behind a jump are covered in [why is my GitHub Actions bill so high](/answers/why-is-my-github-actions-bill-so-high). **Monorepos defeat repository level attribution.** Plenty of organizations run six teams out of one repository. Even a report that names the repository stops one level above the unit that matters, because the mobile team and the data team share the repository and share the line. **Re-runs and matrix fan-out disappear into the aggregate.** A matrix that expands to 24 legs bills 24 jobs. A flaky suite that forces whole workflow re-runs bills each attempt in full. Both land inside the same monthly minutes number as the work that shipped, so waste and output are indistinguishable at the SKU level. **Chargeback needs a stable key and a fixed rate.** To cross-charge a team you need a row you can point at, a key that survives a workflow rename, and a per minute price that does not move between months. Pricing on WarpBuild is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the rate on the chargeback line is the published per minute rate for the label the job ran on, and nothing gets allocated by seat count. ## [Fix](#fix)Copy Anchor Link Attribution comes from three surfaces plus an export step. Most teams use the first two together. ### [Read cost per job in the CI billing report](#read-cost-per-job-in-the-ci-billing-report)Copy Anchor Link The [Reports page](/docs/ci/features/reports) splits billing into CI, Docker Builder and Cache tabs. The CI tab carries the per job detail: * A daily stacked bar chart of cost, grouped by repository or by runner label. * Summary cards for total cost, runner cost, snapshot cost and total jobs across the selected period. * A table where each row is a single job execution: repository, job name, runner label, stack, snapshot usage, execution time, billed time and the cost split between runner and snapshot. * Filters on repository, runner label, stack, job name and snapshot usage. The split between execution time and billed time is what makes the row auditable, and the split between runner cost and snapshot cost keeps snapshot restores visible instead of folding them into a machine rate. The Docker Builder and Cache tabs attribute their own usage per session and per cache entry. The Jobs section aggregates per unique repository, workflow and job name combination, with run count, success rate, duration at P75 and P90, queue time at P75 and P90, and CPU and memory at P75 and P90\. That view answers the second question a chargeback conversation raises, which is whether the expensive team is expensive because of volume or because of size. ### [Make the runner label and stack the chargeback key](#make-the-runner-label-and-stack-the-chargeback-key)Copy Anchor Link Every job records the label it ran on. Giving each team its own label, or its own stack on BYOC, converts the label column into the chargeback dimension, and it survives workflow renames and directory moves in a way that path based heuristics do not. Splitting labels by team is a bookkeeping decision with no capacity cost attached. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a per team label does not carve a fixed pool into smaller pieces. ### [Attribute BYOC spend inside your own cloud bill](#attribute-byoc-spend-inside-your-own-cloud-bill)Copy Anchor Link BYOC runs on AWS, GCP, and Azure, and there the compute lands on your own cloud invoice, so attribution has to happen in the cloud provider's cost tooling. Stacks accept custom tags, and tags set on the stack are applied to every resource the stack creates, which is what cost allocation reports group by. WarpBuild also adds `managed-by: warpbuild` to provisioned resources, usable for IAM tag based access control. Custom resource tags are available on AWS today. The GCP and Azure BYOC configuration docs list resource tagging under coming soon, so plan cloud side attribution on those clouds around account or project boundaries for now. The Terraform provider covers BYOC on AWS, currently in beta for runner images and custom runner sets, so a per team runner set can be defined in code next to the rest of your infrastructure. ### [Export it on a schedule](#export-it-on-a-schedule)Copy Anchor Link Every report tab exports CSV containing all rows matching the current filters and sort order rather than the visible page, which is the shape a finance system wants. The reports API lets the same export run as a scheduled job and write into the chargeback ledger. The endpoints and API key scopes behind that export are covered in [pulling GitHub Actions metrics from the API](/guides/github-actions-metrics-api). ## [Configuration](#configuration)Copy Anchor Link Two changes make the billing rows attributable. Give each team a distinct label, and give each job a name that reads correctly in a CSV row with no workflow context around it. Copy Text ``` name: platform-ci on: pull_request: push: branches: [main] concurrency: group: platform-ci-${{ github.ref }} cancel-in-progress: true jobs: platform-unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: make test platform-integration-tests: runs-on: warp-ubuntu-latest-x64-16x steps: - uses: actions/checkout@v4 - run: make integration web-unit-tests: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - run: npm ci && npm test ``` Three jobs, three sizes, three rows per run in the CI billing table, each with the team prefix in the job name and the size in the label. Filtering that table on job name `platform-` returns exactly the platform team's minutes and cost. On BYOC, the attribution keys live in the cloud tags instead. These keys are added by default to every resource a stack creates, per the [AWS BYOC configuration docs](/docs/ci/byoc/aws/config): | Tag key | Value | | ----------------------- | ------------------------------------- | | warpbuild-managed-by | warpbuild | | Name | {runner-id} | | warpbuild-github-org | {github-org} | | warpbuild-runner-labels | {runner-label1}, {runner-label2}, ... | | warpbuild-runner-id | {runner-id} | | warpbuild-stack-id | {stack-id} | | warpbuild-stack-name | {stack-name} | Custom tags you add on the stack sit alongside these. A `cost-center` or `team` tag activated as a cost allocation tag in your cloud account gives the same breakdown on the cloud invoice that the runner label gives inside the CI billing table. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Three teams share one account. The job mix below is the input; every rate comes from the runner catalog on the [pricing page](/pricing). | Team | Label | Jobs per month | Avg billed minutes | Minutes | Rate | Runner cost | | -------- | -------------------------- | -------------- | ------------------ | ------- | ------ | ----------- | | Platform | warp-ubuntu-latest-x64-4x | 4,000 | 6 | 24,000 | $0.008 | $192.00 | | Web | warp-ubuntu-latest-x64-2x | 9,000 | 5 | 45,000 | $0.004 | $180.00 | | Data | warp-ubuntu-latest-x64-16x | 600 | 18 | 10,800 | $0.032 | $345.60 | | Total | 13,600 | 79,800 | $717.60 | | | | The data team restores a snapshot on each of its 600 jobs at $0.04 per restore, adding $24.00 that the billing table reports as the snapshot half of its cost split. The chargeback table for the month: | Team | Runner cost | Snapshot cost | Total | Share of bill | | -------- | ----------- | ------------- | ------- | ------------- | | Platform | $192.00 | $0.00 | $192.00 | 26 percent | | Web | $180.00 | $0.00 | $180.00 | 24 percent | | Data | $345.60 | $24.00 | $369.60 | 50 percent | | Total | $717.60 | $24.00 | $741.60 | 100 percent | The result is the useful part. The web team runs 9,000 of the 13,600 jobs and owns 24 percent of the bill, while the data team runs 600 jobs and owns half of it, because one data job bills $0.576 and one web job bills $0.02\. That shape is invisible in a SKU total and obvious in a per job export, and it says where to send the right-sizing work described in [cutting GitHub Actions costs without rewrites](/guides/reduce-github-actions-costs). For the same job mix on GitHub-hosted runners at list prices, from GitHub's [minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13: | WarpBuild label | Rate | GitHub-hosted equivalent | GitHub list rate | Difference | | -------------------------- | ------ | --------------------------------------------- | ---------------- | --------------------------- | | warp-ubuntu-latest-x64-2x | $0.004 | ubuntu-latest, 2 vCPU on private repositories | $0.006 | 33 percent lower list price | | warp-ubuntu-latest-x64-4x | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent lower list price | | warp-ubuntu-latest-x64-8x | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent lower list price | | warp-ubuntu-latest-x64-16x | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent lower list price | | warp-ubuntu-latest-x64-32x | $0.064 | 32-core Linux larger runner | $0.082 | 22 percent lower list price | Applying those list prices to the same minutes gives $288.00 for the platform team, $270.00 for the web team and $453.60 for the data team, or $1,011.60 against the $717.60 of runner cost above, a difference of $294.00 per month at an identical job mix and identical machine shapes. The table above compares rates alone. To estimate a fleet total, combine current [per-size rates](/pricing) with measured job durations. The [hosted versus BYOC guide](/guides/hosted-vs-byoc-runners) covers the additional cloud-cost inputs for a BYOC fleet. Two line items stay outside the per job model. SSO is available for a flat $250 per month, whatever the user count, and cache storage bills at $0.20 per GB month, which spans jobs rather than belonging to any one of them. Everything else on the invoice traces to a row in the CI billing table. Signup includes $10 free credits, which is enough to run a week of one team's jobs through the report and check the numbers against your current bill before committing. ## [FAQ](#faq)Copy Anchor Link ### [Can I get GitHub Actions cost per repository without changing anything about my runners?](#can-i-get-github-actions-cost-per-repository-without-changing-anything-about-my-runners)Copy Anchor Link Only down to the SKU. A minutes total for standard Linux or for an 8-core tier covers every workflow in the account that used it, so the finest unit you can defend is the SKU rather than the repository. Cost per repository needs a per job record: the WarpBuild CI billing table writes one row per job execution with repository, job name, runner label, stack, snapshot usage, execution time, billed time and the runner plus snapshot cost split. ### [How do I split cost between two teams that share one monorepo?](#how-do-i-split-cost-between-two-teams-that-share-one-monorepo)Copy Anchor Link Filter below the repository. The CI billing table filters by repository, runner label, stack, job name and snapshot usage, so a job name prefix per team, or a distinct runner label per team, turns into the chargeback key. Export the filtered view as CSV and the sum of the cost column is that team's line for the month. ### [Does cost attribution work on BYOC runners?](#does-cost-attribution-work-on-byoc-runners)Copy Anchor Link Yes, and it moves into your own cloud bill. Tags set on a stack are applied to every resource the stack creates, on top of the default keys such as warpbuild-stack-name, warpbuild-runner-labels and warpbuild-github-org, so cost allocation reports in your cloud account group runner spend by whatever dimension you tagged. Custom resource tags are available on AWS today; GCP and Azure list resource tagging as coming soon. ### [What appears on a WarpBuild bill that cannot be attributed to a single job?](#what-appears-on-a-warpbuild-bill-that-cannot-be-attributed-to-a-single-job)Copy Anchor Link Very little. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, and runners bill per minute against the job that used them. The one flat line is SSO, available for a flat $250 per month whatever the user count. Cache storage bills per GB month, which spans jobs rather than belonging to one. --- # GitHub Actions Egress Costs, Explained | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-egress-costs Description: Egress charges from GitHub Actions land on your cloud bill instead of GitHub's invoice. Where they come from, how to cut them, and a worked AWS model. # GitHub Actions Egress Costs, Explained Egress charges from GitHub Actions land on your cloud bill instead of GitHub's invoice. Where they come from, how to cut them, and a worked AWS model. Last verified: 2026-08-13 Egress charges from GitHub Actions never appear on the GitHub invoice. They land on your cloud bill, because every deploy job pulls container images out of ECR, artifacts out of S3, and caches out of object storage across a metered path, and the meter counts gigabytes rather than jobs. This guide shows where those charges sit on an AWS bill, which workflow changes cut them this week, and what the WarpBuild enterprise tier removes for teams that want the line gone entirely. ## [Diagnosis](#diagnosis)Copy Anchor Link Start by separating the two invoices. GitHub bills GitHub Actions minutes and artifact storage. Your cloud provider bills the bytes that cross its network boundary. A team that reads only the GitHub invoice concludes that GitHub Actions is cheap, then finds a data transfer line on the cloud bill that grew every quarter for reasons nobody attributed to a workflow. Four charge shapes cover most of it. ### [Image pulls from ECR on every deploy job](#image-pulls-from-ecr-on-every-deploy-job)Copy Anchor Link A deploy job authenticates to ECR, pulls the application image, and runs it or promotes it. When the runner sits inside your account and Region, AWS states that data transferred between Amazon ECR and other services in the same Region, including Amazon EC2, AWS Lambda, AWS App Runner, and AWS Fargate, is free of charge at $0.00 per GB ([Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/), checked on 2026-08-13). When the runner sits outside your account, that same pull leaves the Region. AWS states that data transferred between Amazon ECR and other services in different Regions is charged at internet data transfer rates on both sides of the transfer, and its own worked example on that page uses $0.09 per GB (checked on 2026-08-13). Nothing about the workflow changed. The placement of the machine changed, and a free pull became a metered one. This is the charge that scales fastest, because image size and deploy frequency both grow over time. A 1.4 GB image pulled by 40 deploy jobs a day moves 56 GB a day out of the Region. ### [Artifact and cache downloads from S3](#artifact-and-cache-downloads-from-s3)Copy Anchor Link Teams that outgrew `actions/upload-artifact` usually push build outputs, test fixtures, or model weights into their own S3 bucket and pull them back in later jobs. Same shape as the registry: reads from a runner in the same Region cost nothing, reads from a runner outside it are billed at internet data transfer rates, and the first 100 GB of data transfer out to the internet each month is free aggregated across all AWS services and Regions ([Amazon EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/), checked on 2026-08-13). That free allowance is shared with production, so a busy account has usually spent it before GitHub Actions asks for any. ### [Cross-AZ and cross-Region transfer inside your own account](#cross-az-and-cross-region-transfer-inside-your-own-account)Copy Anchor Link Self-hosted fleets often drift. The registry lives in `us-east-1`, someone adds capacity in `us-west-2` for instance availability, and now the pull crosses a Region boundary and is priced as internet transfer on both sides. The same drift happens at the Availability Zone level when runner subnets and the services they read sit in different zones inside one Region. Check the AWS pricing pages linked above for the current per-GB rate on each path, because the rate depends on the exact path and Region pair. ### [Managed NAT data processing](#managed-nat-data-processing)Copy Anchor Link Runners that need a stable outbound IP address get placed in private subnets, and traffic then leaves through a managed NAT service. In US East (N. Virginia), AWS charges $0.045 per hour for the gateway plus $0.045 per GB of data processed, in both directions ([Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13). The WarpBuild [BYOC documentation](/docs/ci/byoc) calls this out directly: enabling static IPs puts runners in private subnets and adds data processing fees on every gigabyte a job moves, so static IPs are worth enabling only for the workflows that actually require an allowlisted address. ### [How to confirm it on your own bill](#how-to-confirm-it-on-your-own-bill)Copy Anchor Link Open Cost Explorer, group by Usage Type, and look for `DataTransfer-Out-Bytes`, `DataTransfer-Regional-Bytes`, and the `NatGateway-Bytes` usage types. Then filter by the account or tag that carries your runner fleet. Two signatures tell you the charge belongs to GitHub Actions rather than production: * The daily curve tracks merge volume. It rises Monday through Thursday, falls on weekends, and spikes on release days. * The curve does not track end-user traffic. Production request volume stays flat while transfer climbs. If both hold, the workflow is the customer of that line item. ## [Fix](#fix)Copy Anchor Link Three moves, in the order that pays back fastest. ### [Pull fewer gigabytes](#pull-fewer-gigabytes)Copy Anchor Link Most deploy images carry build tooling that the runtime never uses. A multi-stage Dockerfile that copies only the compiled output into a slim runtime base cuts what every future job pulls, forever. Pin base images by digest so a silent upstream retag stops invalidating layers across your whole fleet. Registry-backed layer cache is the second lever. When `cache-from` and `cache-to` point at a cache tag in the same registry, unchanged layers stop moving on every build. The third lever is fan-out. A matrix with six legs that each pull the same 1.4 GB image moves 8.4 GB per run. Pull once in a setup job, push the digest through job outputs, and let the legs pull only what differs. The build-side version of the same idea is covered in [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). ### [Cache more inside the runner boundary](#cache-more-inside-the-runner-boundary)Copy Anchor Link WarpBuild Linux runners ship an unlimited cache that is enabled by default, so dependency restores stay on the runner side of the boundary rather than becoming another metered download. Cache storage is billed at $0.20 per GB-month and cache write or restore operations at $0.0001 each on the WarpBuild [pricing page](/pricing), which is the trade you are making against per-GB transfer. ### [Colocate the runner with the data](#colocate-the-runner-with-the-data)Copy Anchor Link This is the structural fix. Run the runners inside the account and Region that already holds the registry and the buckets, and the largest line on the model below goes to $0.00 per GB by AWS's own pricing. BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS, so the placement can live in the same module as the rest of your infrastructure. The setup steps are in the guide to [running GitHub Actions runners in your own AWS account](/byoc/aws). Colocation leaves one residue: runners in private subnets still pay managed NAT data processing on everything they move, including package registries and the GitHub API. Keep the fleet in public subnets unless an allowlisted static IP is a hard requirement. ### [The enterprise outcome](#the-enterprise-outcome)Copy Anchor Link Teams that want this line off the bill entirely qualify on the WarpBuild enterprise tier; the scope and the worked model are on [zero egress for the enterprise tier](/enterprise/zero-egress). ## [Configuration](#configuration)Copy Anchor Link ### [A deploy job that moves less data](#a-deploy-job-that-moves-less-data)Copy Anchor Link The workflow below builds and pushes on a WarpBuild Linux runner, keeps layer cache in the same registry as the image, and never rebuilds a layer it already has. The `runs-on` value is the only line that differs from a GitHub-hosted setup. Copy Text ``` name: deploy on: push: branches: [main] jobs: build-and-push: runs-on: warp-ubuntu-latest-x64-4x permissions: id-token: write contents: read outputs: image: ${{ steps.push.outputs.imageid }} steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 id: push with: push: true tags: ${{ steps.ecr.outputs.registry }}/api:${{ github.sha }} cache-from: type=registry,ref=${{ steps.ecr.outputs.registry }}/api:buildcache cache-to: type=registry,ref=${{ steps.ecr.outputs.registry }}/api:buildcache,mode=max ``` ### [Stop the matrix from pulling the same image six times](#stop-the-matrix-from-pulling-the-same-image-six-times)Copy Anchor Link The smoke job below reads the digest that the build job already resolved, so each matrix leg pulls exactly one image reference and the registry serves cached layers rather than a fresh copy per leg. Copy Text ``` smoke: needs: build-and-push runs-on: warp-ubuntu-latest-x64-2x strategy: matrix: suite: [api, worker, web] steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - run: docker pull "${{ steps.ecr.outputs.registry }}/api@${{ needs.build-and-push.outputs.image }}" - run: ./scripts/smoke.sh ${{ matrix.suite }} ``` ### [Move the same job into your own account](#move-the-same-job-into-your-own-account)Copy Anchor Link BYOC runners register with a `warp-custom-` prefix followed by the runner name you chose in the dashboard. Setting the stack Region to the Region that holds the registry is what turns the pull into same-Region traffic. Copy Text ``` build-and-push: runs-on: warp-custom-deploy-use1 ``` ### [Runner labels and rates used above](#runner-labels-and-rates-used-above)Copy Anchor Link Linux x64 labels from the WarpBuild catalog, with per-minute rates from the [pricing page](/pricing): | Runner label | OS | vCPU | RAM | Storage | Price per minute | | -------------------------- | ------------ | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.064 | BYOC runners are billed at $0.002 per minute in WarpBuild fees on Linux, with the compute billed by your own cloud account. The full label set is in the [cloud runners documentation](/docs/ci/cloud-runners). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Assumptions](#assumptions)Copy Anchor Link Every input below is stated so you can substitute your own. Rates are AWS list prices for US East (N. Virginia), checked on 2026-08-13. | Input | Value | Source | | ------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------- | | Deploy jobs per weekday | 40 | Your workflow run history | | Weekdays per month | 22 | Calendar | | Image bytes pulled per job | 1.4 GB | Your registry metrics | | Artifact and cache bytes pulled per job | 0.2 GB | Your bucket metrics | | Deploy job duration | 6 minutes | Your workflow run history | | Internet data transfer rate | $0.09 per GB | [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/) | | Free internet transfer allowance | 100 GB per month, all services | [Amazon EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/) | | ECR to same-Region compute | $0.00 per GB | [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/) | | Managed NAT data processing | $0.045 per GB | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/) | | Managed NAT hourly charge | $0.045 per hour | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/) | | GitHub-hosted ubuntu-latest, private repositories | $0.006 per minute | [GitHub Actions billing](https://docs.github.com/en/billing/reference/actions-minute-multipliers) | ### [The arithmetic](#the-arithmetic)Copy Anchor Link Monthly deploy jobs: 40 times 22 equals 880. Bytes pulled per job: 1.4 GB plus 0.2 GB equals 1.6 GB. Monthly total: 880 times 1.6 equals 1,408 GB. Runner minutes: 880 times 6 equals 5,280 minutes. At GitHub's published rate of $0.006 per minute for `ubuntu-latest` on private repositories, that is $31.68 of GitHub Actions minutes per month (GitHub billing reference, checked on 2026-08-13). Now price the same 1,408 GB three ways. | Runner placement | Transfer charge | Managed NAT charge | Monthly total | | ------------------------------------------------------------------ | -------------------------------------------- | ------------------------------------------------------------------------------ | ------------- | | Outside your account, pulling from ECR over the internet path | 1,308 GB billable times $0.09 equals $117.72 | $0.00 | $117.72 | | Inside your account and Region, public subnets | 1,408 GB times $0.00 equals $0.00 | $0.00 | $0.00 | | Inside your account and Region, private subnets with a managed NAT | $0.00 | 1,408 GB times $0.045 equals $63.36, plus 730 hours times $0.045 equals $32.85 | $96.21 | The billable figure in row one credits the 100 GB monthly allowance once. If production already consumed it, add $9.00 to that row. ### [What the model says](#what-the-model-says)Copy Anchor Link The transfer line for this workflow is $117.72 a month against $31.68 of GitHub Actions minutes at GitHub's list price. The bytes cost more than the compute, and no dashboard in GitHub shows it, which is why the charge survives so many cost reviews. Placement is the variable that moves it. Moving the same 880 jobs into the account and Region that already holds the registry takes the $117.72 to zero by AWS's own published rate, and leaves $96.21 of managed NAT charges if the fleet needs static IPs. Turning static IPs off for the jobs that do not need an allowlisted address clears that residue too. Scale the model linearly for your own numbers. A team running 200 deploy jobs a day with a 3 GB image moves 13,200 GB a month, which is $1,179.00 at $0.09 per GB after the free allowance. Once the transfer line is handled, the minute line is the next one to work on, and [reducing GitHub Actions costs across runners and workflows](/guides/reduce-github-actions-costs) covers that side. ### [Where WarpBuild fits](#where-warpbuild-fits)Copy Anchor Link WarpBuild pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the runner side of this model is per-minute rates against the labels in the table above. Signup includes $10 free credits. At $0.008 per minute for a 6 minute build job on `warp-ubuntu-latest-x64-4x`, that covers just over 1,000 runs of the deploy job above while you compare Cost Explorer before and after. Matched shape for shape, `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13. For the transfer side, two options exist. BYOC puts the runners in your own AWS, GCP, or Azure account, so pulls become same-account, same-Region traffic priced by your cloud provider. The enterprise tier is the other path, covered on [zero egress on the enterprise tier](/enterprise/zero-egress); [talk to engineering](https://cal.com/suryao/start) with your transfer numbers in hand. Cloud list prices change. Re-check the four AWS links and the GitHub billing reference before you quote these totals internally, and re-run the arithmetic with your own image size and job count. ## [FAQ](#faq)Copy Anchor Link **Does GitHub charge for egress on GitHub Actions?** No. A GitHub Actions invoice bills minutes and artifact storage. Data transfer charges appear on your cloud bill instead, because the bytes leave your cloud account when a job pulls an image or an artifact. **Why did our AWS transfer bill grow when we started deploying more often?** Transfer charges scale with the number of deploy jobs. Production request volume has no effect on them. Every job pulls the image and the artifacts again, so doubling merges per day doubles the gigabytes pulled out of ECR and S3. **Do self-hosted runners remove data transfer charges?** Only when they sit in the same account and Region as the registry and buckets they read. AWS prices data transferred between Amazon ECR and Amazon EC2 in the same Region at $0.00 per GB, and runners placed in private subnets still pay managed NAT data processing at $0.045 per GB in US East (N. Virginia). Both figures checked on 2026-08-13. **Can egress from our cloud reach zero?** On the enterprise tier, yes. The scope, the platforms it covers, and a worked cost model are on [zero egress for the enterprise tier](/enterprise/zero-egress). --- # Running Managed Runners on Open Source Projects | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-for-open-source-projects Description: Managed runners reach public repositories once the Default runner group allows them. Enablement steps, fork pull request controls, and per-minute costs. # Running Managed Runners on Open Source Projects Managed runners reach public repositories once the Default runner group allows them. Enablement steps, fork pull request controls, and per-minute costs. Managed runners run public repository jobs once an organization owner checks `Allow public repositories` on the Default runner group, because GitHub disables self-hosted and managed runners in public repositories by default. The rest of the work is deciding which jobs a pull request from a fork may run on, and pricing the jobs that outgrew the free GitHub-hosted pool. This guide covers the enablement steps from the [public repositories documentation](/docs/ci/public-repos), the fork pull request threat model with the GitHub settings that contain it, a routing workflow, and a cost model with the arithmetic written out. ## [Diagnosis](#diagnosis)Copy Anchor Link Open source projects hit three separate problems, and they get confused with each other because all three end with a slow or stalled pipeline. **Jobs queue forever on a `warp-` label.** WarpBuild registers itself as a self-hosted runner in the `Default` runner group, id `1`, for your GitHub organization, and GitHub disables self-hosted runners in public repositories by default. The runner fleet looks connected in the dashboard, the workflow file is valid, and the job never starts. That is the runner group setting rather than a capacity problem. **The maintainers vetoed managed runners on security grounds.** [GitHub's self-hosted runner guidance](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#self-hosted-runner-security) recommends disabling self-hosted runners on public repositories, and the reason is the machine rather than the trigger. A pull request is a proposal to run arbitrary code. On a persistent runner that code inherits the instance profile, the local caches, and the network routes of every job that ran before it. The [public repositories documentation](/docs/ci/public-repos) names container fleets such as [actions-runner-controller](https://github.com/actions/actions-runner-controller) as the sharp case, since workflows there run in containers that share a kernel with the host. **The free public shape ran out of cores.** GitHub gives public repositories a 4 vCPU, 16 GB standard Linux runner, against 2 vCPU and 8 GB on private repositories ([GitHub-hosted runner specs](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13). Compile-heavy projects saturate that shape and stay saturated. Reaching for a larger GitHub-hosted label is billed on a public repository at the same per-minute rates a private repository pays ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13), so the free path ends at that point regardless of which fleet you pick. Where exactly that boundary sits is worked through in [are GitHub Actions free for open source repositories](/answers/are-github-actions-free-for-open-source). ## [Fix](#fix)Copy Anchor Link Work the three in order. Enablement is a two-minute change, containment is a settings pass, and routing is one workflow edit. ### [Enable the Default runner group for public repositories](#enable-the-default-runner-group-for-public-repositories)Copy Anchor Link From the [public repositories documentation](/docs/ci/public-repos), an organization owner does this: 1. Open `https://github.com/organizations/[YOUR_ORG]/settings/actions/runner-groups/1`. 2. Check the box for `Allow public repositories`. GitHub Enterprise supports creation of multiple runner groups, and WarpBuild runners are added to the `Default` group, id `1`, there as well. On Enterprise the box to check is therefore on that group rather than on a group someone created for another fleet. Group membership and repository access are covered in [GitHub's runner group access documentation](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access). ### [Contain the fork pull request](#contain-the-fork-pull-request)Copy Anchor Link State the threat model in both directions before touching settings. What a fork's code reaches: the runner machine, whatever that machine can reach on the network, and whatever an earlier job left on its disk. What it does not reach on the default `pull_request` trigger: repository secrets are not passed to workflows triggered from a fork, and the run's `GITHUB_TOKEN` is read-only ([GitHub events reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13). Four settings hold that line: * **Fork pull request approval.** In organization or repository Actions settings, require approval for all outside collaborators rather than first-time contributors only. A maintainer then reviews the diff before any job starts ([approving runs from forks](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/approve-runs-from-forks), checked on 2026-08-13). * **Default workflow permissions.** Set the repository default for `GITHUB_TOKEN` to read-only and grant write scopes per job in the workflow file ([workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax), checked on 2026-08-13). * **Trigger choice.** Keep build and test work on `pull_request`. The `pull_request_target` trigger runs with secrets and a writable token in the base repository's context, so a workflow that uses it and then checks out the fork's head commit hands over what the default trigger withholds. * **Deployment secrets behind environments.** Anything that needs a real credential belongs in a job gated by an environment with required reviewers, which keeps the credential out of any run a contributor can trigger. One extra rule applies if the project reuses a prepared disk between runs: keep credential files off that disk. The specifics are in [are snapshot runners safe for public repositories](/answers/are-snapshot-runners-safe-for-public-repositories). ### [Put contributor code on a machine that ends with the job](#put-contributor-code-on-a-machine-that-ends-with-the-job)Copy Anchor Link The [security documentation](/docs/ci/security) describes the runner lifecycle. Each runner runs in its own virtual machine, created on demand and destroyed after each build. The VMs are ephemeral and never reused. Each runner also gets its own encrypted storage volume on the same cycle, and WarpBuild does not access or store build secrets. One contributor's job cannot read what a previous job left behind, because the disk it would read is gone. The runner images carry the same tools and versions as GitHub-hosted runners, so a public repository's workflow file stays portable between the two label sets and a contributor who only has the free pool can still reproduce a failure locally from the same tool versions. ## [Configuration](#configuration)Copy Anchor Link Rates below come from the [pricing page](/pricing) and the [cloud runners catalog](/docs/ci/cloud-runners), checked on 2026-08-13. | Label | Shape | Per minute | Cost of an 8 minute job | | --------------------------- | -------------- | ---------- | ----------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | $0.032 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | $0.064 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $0.128 | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | $0.256 | | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | $0.012 | $0.096 | | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | $0.016 | $0.128 | | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08 | $0.64 | The routing workflow resolves the label inline, so no extra job is needed: Copy Text ``` name: ci on: pull_request: push: branches: [main] permissions: contents: read concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: test: runs-on: >- ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || 'warp-ubuntu-latest-x64-8x' }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test release-build: if: github.event_name == 'push' runs-on: warp-macos-latest-arm64-6x environment: release steps: - uses: actions/checkout@v4 - run: ./scripts/build-release.sh ``` Three things are happening. Fork pull requests stay on the free GitHub-hosted pool, so a drive-by contributor costs the project nothing and never lands on a machine with an account behind it. Pushes to `main` and pull requests from branches inside the repository take `warp-ubuntu-latest-x64-8x`. The release leg runs after merge only, on a macOS label, inside an environment that can require a reviewer before its credentials are issued. Public OSS repositories running `warp-` labels are citable evidence for this shape, and the workflow files are open to read. The [bitcoin/bitcoin GitHub Actions workflow](https://github.com/bitcoin/bitcoin/blob/master/.github/workflows/ci.yml) sizes its Linux jobs per job across `warp-` labels, from `warp-ubuntu-latest-x64-2x` for lint up to `warp-ubuntu-latest-x64-16x` for the fuzz and MSan matrices, with compiler caches restored and saved between runs. [FuelLabs/sway](https://github.com/FuelLabs/sway/blob/master/.github/workflows/ci.yml) builds and tests the Sway compiler on `warp-ubuntu-latest-x64-4x` jobs alongside GitHub-hosted ones in the same file, which is the mixed-fleet pattern above. The [triggerdotdev/trigger.dev end-to-end workflow](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml) runs its matrix across `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x`. All three checked on 2026-08-13. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Price the split rather than the whole pipeline, because the fork side stays free. Assumptions for a mid-sized project: * 600 workflow runs in the month. * 420 of them come from fork pull requests and stay on `ubuntu-latest`, where minutes and storage cost a public repository nothing ([GitHub Actions billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13). * 180 come from maintainer branches and merges to `main`, each running one 9 minute Linux job on `warp-ubuntu-latest-x64-8x`. * 25 release runs add a 12 minute macOS leg on `warp-macos-latest-arm64-6x`. The arithmetic at the rates in the table above: | Leg | Minutes | Rate | Cost | | --------------------------------------------- | ------------ | ------ | ------ | | 420 fork pull requests on ubuntu-latest | free | $0.000 | $0.00 | | 180 x 9 minutes on warp-ubuntu-latest-x64-8x | 1,620 | $0.016 | $25.92 | | 25 x 12 minutes on warp-macos-latest-arm64-6x | 300 | $0.08 | $24.00 | | Month total | 1,920 billed | $49.92 | | The dial to turn next is the fork share. Moving 100 of those 420 fork runs onto `warp-ubuntu-latest-x64-4x` at $0.008 per minute, for a 6 minute job, adds 600 minutes and $4.80 to the month. That is the honest price of giving trusted external contributors the faster machines, and it is small enough to test for a month and reverse with one line in the `runs-on` expression. A quiet month bills less by the same arithmetic. There is no allowance to forfeit and no minimum to hit, so a project that ships nothing in August pays for the jobs it ran in August. Every per-size rate is on the [pricing page](/pricing), and [do public repositories pay for runners](/answers/do-public-repositories-pay-for-runners) prices a second job mix against the same table. ## [FAQ](#faq)Copy Anchor Link ### [Why do warp- labels never pick up jobs in my public repository?](#why-do-warp--labels-never-pick-up-jobs-in-my-public-repository)Copy Anchor Link GitHub blocks self-hosted and managed runners in public repositories by default. An organization owner has to open the Default runner group, id `1`, at `github.com/organizations/[YOUR_ORG]/settings/actions/runner-groups/1` and check `Allow public repositories`. Until that box is checked the job stays queued with no runner assigned. The [public repositories documentation](/docs/ci/public-repos) has the screenshot and the GitHub Enterprise note. ### [What can a pull request from a fork reach on a WarpBuild runner?](#what-can-a-pull-request-from-a-fork-reach-on-a-warpbuild-runner)Copy Anchor Link The machine it runs on, whatever that machine can reach on the network, and whatever is on its disk. On the default `pull_request` trigger it receives no repository secrets and a read-only `GITHUB_TOKEN` ([GitHub events reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13). WarpBuild runs each job in its own virtual machine with its own encrypted volume, both destroyed when the job finishes, per the [security documentation](/docs/ci/security), so a contributor's job starts on a clean disk and leaves nothing behind. ### [Does an open source project need a seat count or a plan before it can use managed runners?](#does-an-open-source-project-need-a-seat-count-or-a-plan-before-it-can-use-managed-runners)Copy Anchor Link No. Managed runners are billed for the time jobs execute, so maintainers and drive-by contributors do not add a per-person charge. The [pricing page](/pricing) lists the per-minute rates. --- # What GitHub Actions Larger Runners Cost | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-larger-runners-cost Description: GitHub Actions larger runners bill $0.012 to $0.252 per minute on Linux x64. See the full rate ladder, break-even math, and WarpBuild rates per size. # What GitHub Actions Larger Runners Cost GitHub Actions larger runners bill $0.012 to $0.252 per minute on Linux x64\. See the full rate ladder, break-even math, and WarpBuild rates per size. Last verified: 2026-08-13 GitHub Actions larger runners bill per minute at GitHub's published list rates: $0.012 for 4 vCPU Linux x64, $0.022 for 8 vCPU, $0.042 for 16 vCPU, $0.082 for 32 vCPU, and up to $0.252 for 96 vCPU, checked on 2026-08-13 against [GitHub's per-minute rate reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). WarpBuild runs the same vCPU sizes for less per minute, from $0.008 at 4 vCPU Linux x64 to $0.064 at 32 vCPU. This guide lays out both rate ladders, the break-even arithmetic for moving up a size, and a worked monthly cost model you can rerun with your own job volume. ## [Diagnosis](#diagnosis)Copy Anchor Link Larger runners are the GitHub-hosted machines above the default 2 vCPU tier. GitHub bills them per minute of workflow execution and rounds each job up to the nearest whole minute. An idle larger runner pool costs nothing; the meter runs only while a job executes. These are GitHub's list prices for hosted larger runners, checked on 2026-08-13 against the [per-minute rate reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing): | Platform | vCPU | GitHub-hosted rate per minute | | ----------- | ---- | ----------------------------- | | Linux x64 | 4 | $0.012 | | Linux x64 | 8 | $0.022 | | Linux x64 | 16 | $0.042 | | Linux x64 | 32 | $0.082 | | Linux x64 | 64 | $0.162 | | Linux x64 | 96 | $0.252 | | Linux ARM64 | 4 | $0.008 | | Linux ARM64 | 8 | $0.014 | | Linux ARM64 | 16 | $0.026 | | Linux ARM64 | 32 | $0.050 | | Linux ARM64 | 64 | $0.098 | | Windows x64 | 4 | $0.022 | | Windows x64 | 8 | $0.042 | | Windows x64 | 16 | $0.082 | | Windows x64 | 32 | $0.162 | | Windows x64 | 64 | $0.322 | Four billing rules shape what these numbers turn into at the end of the month: * Included minutes from your GitHub plan never apply to larger runners. Every larger runner minute is a paid minute, and that holds for public repositories too. * Larger runners require a GitHub Team or GitHub Enterprise Cloud plan. Personal and Free organization accounts cannot create them. * Each job rounds up to a whole minute. A 15 second job on a 32 vCPU Linux runner bills a full minute at $0.082. * How many jobs run concurrently depends on your GitHub plan, so deep matrix builds can queue behind the cap. The harder question is when a bigger machine pays for itself. The answer comes from two curves: the per-minute rate as vCPUs grow, and your job's wall clock time as vCPUs grow. On the GitHub ladder, each step up multiplies the Linux x64 rate by 1.8 to 2.0\. Moving from 4 to 8 vCPU multiplies the rate by 1.83 ($0.012 to $0.022), so cost per run breaks even when the job finishes in about 55 percent of its old duration. Moving from 8 to 16 vCPU multiplies the rate by 1.91, with break-even near 52 percent of the old duration. On the WarpBuild ladder the rate doubles exactly at each step, so break-even sits at half the old duration. Whether your job clears that bar depends on which steps dominate. Compilation, sharded test suites, and multi-stage container builds spread across cores and often scale close to the core count. Checkout, dependency installs against a warm lockfile, single-threaded bundlers, and artifact uploads run at the same speed on 4 vCPUs as on 32\. If those serial steps are most of the job, a bigger runner raises cost per run and buys little time. Two quick signals before any resizing decision. A job whose peak CPU sits near full utilization for most of the run has headroom to convert cores into speed. A job that spends its time waiting on registries, package mirrors, or a database container will look identical on a bigger machine, at a higher rate. ## [Fix](#fix)Copy Anchor Link There are two independent levers, and they compound. The first lever changes the rate without touching the job: run the same vCPU count on a provider with a lower per-minute price. The runner images carry the same tooling as GitHub-hosted runners, and a job moves by editing one `runs-on` label. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits. Here is the same-size comparison, GitHub-hosted list price against the WarpBuild rate, both checked on 2026-08-13: | Platform | vCPU | GitHub-hosted rate | WarpBuild label | WarpBuild rate | Difference per minute | | ----------- | ---- | ------------------ | ---------------------------- | -------------- | --------------------- | | Linux x64 | 4 | $0.012 | warp-ubuntu-latest-x64-4x | $0.008 | $0.004 | | Linux x64 | 8 | $0.022 | warp-ubuntu-latest-x64-8x | $0.016 | $0.006 | | Linux x64 | 16 | $0.042 | warp-ubuntu-latest-x64-16x | $0.032 | $0.010 | | Linux x64 | 32 | $0.082 | warp-ubuntu-latest-x64-32x | $0.064 | $0.018 | | Linux ARM64 | 4 | $0.008 | warp-ubuntu-latest-arm64-4x | $0.006 | $0.002 | | Linux ARM64 | 8 | $0.014 | warp-ubuntu-latest-arm64-8x | $0.012 | $0.002 | | Linux ARM64 | 16 | $0.026 | warp-ubuntu-latest-arm64-16x | $0.024 | $0.002 | | Linux ARM64 | 32 | $0.050 | warp-ubuntu-latest-arm64-32x | $0.048 | $0.002 | | Windows x64 | 4 | $0.022 | warp-windows-latest-x64-4x | $0.016 | $0.006 | | Windows x64 | 8 | $0.042 | warp-windows-latest-x64-8x | $0.032 | $0.010 | | Windows x64 | 16 | $0.082 | warp-windows-latest-x64-16x | $0.064 | $0.018 | | Windows x64 | 32 | $0.162 | warp-windows-latest-x64-32x | $0.128 | $0.034 | WarpBuild Linux runners at these sizes carry 4GB of memory per vCPU and 150GB of SSD storage; Windows runners carry 256GB. WarpBuild also keeps a 2 vCPU Linux tier at $0.004 per minute on x64 and $0.003 on ARM64 for jobs that were never CPU bound in the first place. Full ARM64 and Windows catalogs are on the [Linux ARM64 runner page](/runners/linux-arm64) and the [Windows runner page](/runners/windows), and every rate is listed on the [WarpBuild pricing page](/pricing). Concurrency behaves differently too. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. For matrix-heavy workflows, that removes the queueing that plan-level concurrency caps impose on GitHub-hosted pools. The second lever is right-sizing against measurements. Move a job up a size only when its measured CPU shows the parallel phases dominating, and move oversized jobs down when they do not. The two levers are independent: the rate change requires no scaling assumption at all, while the size change is a bet that your job parallelizes. The [guide to reducing GitHub Actions costs](/guides/reduce-github-actions-costs) covers the levers beyond runner choice, including caching and artifact retention. ## [Configuration](#configuration)Copy Anchor Link Moving a job between sizes is one line. This workflow moves a sharded Jest suite from a 2 vCPU runner to an 8 vCPU runner: Copy Text ``` name: ci on: pull_request: push: branches: [main] jobs: test: runs-on: warp-ubuntu-latest-x64-8x # was: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run build - run: npx jest --maxWorkers=8 ``` The sizing rationale, with the arithmetic: on `warp-ubuntu-latest-x64-2x` this suite ran 28 minutes with both vCPUs pinned, which costs 28 x $0.004 = $0.112 per run. Jest shards across workers, so on `warp-ubuntu-latest-x64-8x` with `--maxWorkers=8` the run drops to 6 minutes, which costs 6 x $0.016 = $0.096 per run. The bigger runner is cheaper per run here and returns 22 minutes per pull request. The move pays because the test phase, the dominant step, spreads across all 8 cores; the `npm ci` step stays serial but is a small share of the total. Label mechanics worth knowing before you edit `runs-on`: * The full catalog lives in the [cloud runners documentation](/docs/ci/cloud-runners). Linux x64 and ARM64 come in 2x, 4x, 8x, 16x, and 32x sizes. Windows comes in 4x through 32x; the 2 vCPU Windows tier was removed on June 8, 2026. * The `latest` labels track Ubuntu 24.04\. To pin an OS version, use the versioned labels such as `warp-ubuntu-2404-x64-8x` or `warp-ubuntu-2604-x64-8x`. * On ARM64, Ubuntu 24.04 runners set the work dir to `/runner/_work`, unlike GitHub's `/home/runner/work/`. Scripts with hardcoded paths need that update when you switch architecture. Verify the resize with numbers rather than impressions. The [Reports page documentation](/docs/ci/features/reports) covers per-job duration at P75 and P90, queue time, and billed cost per run, filterable by runner label, so the before and after comparison is one filter change. CPU and memory percentiles come from CI observability, which correlates system metrics from the runner agent with GitHub Actions job logs; enable it before the experiment so the P90 CPU column can confirm whether the job actually saturated the smaller machine. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Take a concrete volume: a team runs 1,500 GitHub Actions jobs per month on 8 vCPU Linux x64 runners, averaging 10 billed minutes per job. That is 15,000 runner minutes per month. Rates below are the GitHub list prices from the [per-minute rate reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13, and the WarpBuild rates from the [pricing page](/pricing). Same size, both providers: | Option | Arithmetic | Monthly cost | | ----------------------------------- | ------------------- | ------------ | | GitHub-hosted Linux 8-core | 15,000 min x $0.022 | $330.00 | | WarpBuild warp-ubuntu-latest-x64-8x | 15,000 min x $0.016 | $240.00 | The label change alone moves the bill from $330.00 to $240.00, a difference of $90.00 per month or $1,080.00 per year, with identical vCPU count and no assumption about the job speeding up. Now add the sizing lever. Suppose Reports shows the job pinning all 8 vCPUs through its compile and test phases, and a trial run on 16 vCPU brings wall clock from 10 minutes to 6.5: | Option | Arithmetic | Monthly cost | | ----------------------------------------- | ----------------------------- | ------------ | | WarpBuild warp-ubuntu-latest-x64-16x | 1,500 jobs x 6.5 min x $0.032 | $312.00 | | GitHub-hosted Linux 16-core, same profile | 1,500 jobs x 6.5 min x $0.042 | $409.50 | At $312.00 the doubled size stays under the original $330.00 GitHub-hosted baseline while finishing every run 3.5 minutes sooner. Across 1,500 monthly runs that removes 5,250 pipeline minutes, about 87 hours of waiting spread over the team's pull requests. The break-even line for that upgrade: the 16x rate is double the 8x rate, so cost per run matches when duration falls to 10 x ($0.016 / $0.032) = 5.0 minutes. Between 5.0 and 10 minutes, the bigger runner costs more per run and buys speed; below 5.0 minutes it wins on both cost and time. Whether 6.5 minutes is worth $72.00 more per month than the 8x figure is a judgment about how much a faster feedback loop is worth to the team, and the model above gives you the exact price of that choice. Both tables hold job duration constant across providers, so they compare list prices rather than predict a fleet's total. Substitute your measured job duration, and use the [pricing page](/pricing) for current rates. To rerun this model with your own numbers, pull monthly job counts and billed minutes from your GitHub usage report, multiply by the list rate at your size, and set that against the WarpBuild rate at the same vCPU count. macOS runs on a separate ladder on both sides; the [macOS runner cost guide](/guides/github-actions-macos-runner-cost) works through it with the same method. ## [FAQ](#faq)Copy Anchor Link ### [Do included GitHub Actions minutes cover larger runners?](#do-included-github-actions-minutes-cover-larger-runners)Copy Anchor Link No. GitHub excludes larger runners from included minutes, so every larger runner minute bills at the published per-minute rate, on public and private repositories alike (checked on 2026-08-13). ### [Which GitHub plan do larger runners require?](#which-github-plan-do-larger-runners-require)Copy Anchor Link GitHub restricts larger runners to organizations on GitHub Team or GitHub Enterprise Cloud. WarpBuild runners attach through a runs-on label and work with any plan that allows self-hosted runner registration. ### [Is a bigger runner ever cheaper per run than a smaller one?](#is-a-bigger-runner-ever-cheaper-per-run-than-a-smaller-one)Copy Anchor Link Yes, when wall clock time falls faster than the per-minute rate rises. A job that finishes in under half its old duration after moving from 4 vCPU to 8 vCPU costs less per run and ships sooner. ### [Can I run larger runners inside my own cloud account?](#can-i-run-larger-runners-inside-my-own-cloud-account)Copy Anchor Link Yes. BYOC runs on AWS, GCP, and Azure. Jobs keep their warp- labels, compute runs in your cloud account, and the WarpBuild BYOC rate for Linux runners is $0.002 per minute. --- # What macOS Runners Cost on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-macos-runner-cost Description: GitHub-hosted macOS runners list at $0.062 to $0.102 per minute. WarpBuild macOS runners are $0.08 per minute for 6 vCPU and $0.16 for 12 vCPU. # What macOS Runners Cost on GitHub Actions GitHub-hosted macOS runners list at $0.062 to $0.102 per minute. WarpBuild macOS runners are $0.08 per minute for 6 vCPU and $0.16 for 12 vCPU. Last verified: 2026-08-13 GitHub publishes per-minute rates for its hosted macOS runners of $0.062 for the 3-core Apple Silicon image called `macos-latest`, $0.077 for the 12-core Intel image called `macos-latest-large`, and $0.102 for the 5-core Apple Silicon image called `macos-latest-xlarge` ([GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). WarpBuild macOS runners are $0.08 per minute for 6 vCPU with 14GB of memory and $0.16 per minute for 12 vCPU with 28GB of memory. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. ## [Diagnosis](#diagnosis)Copy Anchor Link macOS is the most expensive operating system on GitHub Actions, and an iOS pipeline spends almost all of its wall clock there. Four things stack up. **The per-minute rate leads every other runner OS.** GitHub's published rate for the 5-core Apple Silicon `macos-latest-xlarge` image is $0.102 per minute, against $0.006 per minute for a 2-core hosted Linux runner ([GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). That is a $0.096 gap per minute inside GitHub's own list, and an iOS pipeline spends its minutes on the expensive side of it. Minute count is the lever that moves the bill. **Included minutes evaporate.** GitHub applies a minute multiplier to macOS when a job draws on the allowance that comes with a plan, so one macOS minute consumes ten minutes of that allowance (GitHub Actions billing documentation, checked on 2026-08-13). Teams read their first two invoices as an anomaly. The allowance was gone by the fourth working day. **Archive and export jobs are long and serial.** A release job that resolves Swift Package Manager dependencies, compiles Release configuration from a cold DerivedData directory, signs, archives, and exports an IPA runs for tens of minutes with no useful parallelism inside it. Compilation is the bulk, and every cache miss on DerivedData or on the SPM checkout directory turns a warm 6-minute compile into a cold one. **Simulator boot time is billed time.** `xcrun simctl boot` on a cold runner takes a real fraction of a minute before the first test executes, and a first-run device may also install a runtime. A test job that boots three destinations pays that cost three times. On a 500-run month, one wasted minute per run is 500 macOS minutes on the invoice. **Device matrices serialize when concurrency is capped.** A `strategy.matrix` with four simulator destinations is only cheaper in wall clock when four macOS runners start at once. When macOS capacity is limited, the matrix legs queue, the pull request feedback loop stretches to half an hour, and engineers stop waiting for green before they merge. The billed minutes stay the same, so the team pays full price for a slow signal. Two more items show up in reviews of real iOS repositories. Jobs that do not need Xcode at all, such as SwiftLint runs, changelog validation, license checks, and App Store metadata linting, sit inside the macOS job because that is where the checkout already exists. And `xcodebuild` runs with default settings write result bundles and build logs that later steps upload one file at a time, which adds minutes at the macOS rate. ## [Fix](#fix)Copy Anchor Link Move macOS jobs onto WarpBuild macOS labels, then cut the macOS minute count itself. The first step is a one-line change per job, and the second is ordinary workflow surgery. **Change the label.** WarpBuild runners register with GitHub as self-hosted runners carrying `warp-` labels, so replacing the value of `runs-on` is the entire migration for a job. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the macOS images carry the same tooling as the GitHub-hosted macOS images, which is what makes them drop-in. The macOS catalog offers multiple sizes and configurations per chip, so a job picks 6 vCPU or 12 vCPU by label. **Match the size to the job.** A pull request test job on a simulator rarely saturates 12 vCPU, so `warp-macos-15-arm64-6x` at $0.08 per minute is the right default. An archive job that compiles Release configuration from cold does use the wider machine, and `warp-macos-26-arm64-12x` at $0.16 per minute pays for itself only if it finishes in fewer minutes, so measure the job both ways before you settle. Storage differs too: 120GB of SSD on the 6 vCPU size and 270GB on the 12 vCPU size, which matters once DerivedData, multiple simulator runtimes, and an archive share a disk. **Get the non-Xcode work off macOS.** Lint, metadata validation, JSON and YAML checks, and changelog rules run on `warp-ubuntu-latest-arm64-4x` at $0.006 per minute. The arithmetic in the cost model below shows what that single split is worth on a 500-run month. **Stop paying for repeated simulator boots.** Boot one simulator in a dedicated step, keep the destination stable across the test invocations in the job, and pass `-derivedDataPath` explicitly so a cache action can restore it. **Fan the matrix out instead of letting it queue.** Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. For unusually high macOS concurrency, write to [support@warpbuild.com](mailto:support@warpbuild.com) before the first heavy release week. **Keep image coverage in mind when pinning Xcode.** WarpBuild ships five macOS labels across macOS 14, macOS 15, and macOS 26, and the macOS 26 images add Xcode 27.0 (build 27A266a) on top of the upstream GitHub image, with iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes. A team that needs an older toolchain for a maintenance branch keeps that branch on `warp-macos-14-arm64-6x` at the same $0.08 per minute. The outcome carries a number: `warp-macos-latest-arm64-6x` (6 vCPU, 14GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14GB), which is 22 percent lower list price. GitHub list price checked on 2026-08-13, and the arithmetic below carries it through a monthly volume. ## [Configuration](#configuration)Copy Anchor Link Two workflow files cover the common iOS shape. The first is the pull request workflow, with the non-Xcode job on Linux and the test job on a 6 vCPU macOS runner. Copy Text ``` name: ios-pull-request on: pull_request: branches: [main] concurrency: group: ios-pr-${{ github.ref }} cancel-in-progress: true jobs: lint: runs-on: warp-ubuntu-latest-arm64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/lint-swift.sh - run: ./scripts/validate-app-store-metadata.sh build-and-test: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v4 - name: Restore SPM and DerivedData uses: actions/cache@v4 with: path: | ~/Library/Developer/Xcode/DerivedData ~/Library/Caches/org.swift.swiftpm key: xcode-${{ hashFiles('**/Package.resolved') }} restore-keys: xcode- - name: Boot one simulator for the whole job run: | xcrun simctl boot "iPhone 16" || true xcrun simctl bootstatus "iPhone 16" -b - name: Test run: | xcodebuild test \ -scheme AppTests \ -destination "platform=iOS Simulator,name=iPhone 16" \ -derivedDataPath ~/Library/Developer/Xcode/DerivedData \ -resultBundlePath TestResults.xcresult \ CODE_SIGNING_ALLOWED=NO - name: Upload result bundle if: always() uses: actions/upload-artifact@v4 with: name: test-results path: TestResults.xcresult ``` The second file is the nightly and release path on the 12 vCPU macOS runner, which is where the newest image and the wider machine earn their rate. Copy Text ``` name: ios-archive on: schedule: - cron: "0 6 * * 1-5" workflow_dispatch: jobs: archive: runs-on: warp-macos-26-arm64-12x steps: - uses: actions/checkout@v4 - name: Select Xcode 27.0 run: sudo xcode-select -s "$(ls -d /Applications/Xcode_27*.app | tail -1)" - uses: apple-actions/import-codesign-certs@v3 with: p12-file-base64: ${{ secrets.SIGNING_CERT_P12 }} p12-password: ${{ secrets.SIGNING_CERT_PASSWORD }} - name: Archive run: | xcodebuild archive \ -scheme App \ -configuration Release \ -destination "generic/platform=iOS" \ -archivePath build/App.xcarchive - name: Export IPA run: | xcodebuild -exportArchive \ -archivePath build/App.xcarchive \ -exportOptionsPlist ExportOptions.plist \ -exportPath build/export - uses: actions/upload-artifact@v4 with: name: ipa path: build/export/*.ipa ``` A device matrix belongs in its own job so the legs start in parallel instead of queueing behind one another: Copy Text ``` ui-tests: runs-on: warp-macos-15-arm64-6x strategy: fail-fast: false matrix: destination: - "platform=iOS Simulator,name=iPhone 16" - "platform=iOS Simulator,name=iPhone 16 Pro Max" - "platform=iOS Simulator,name=iPad (10th generation)" steps: - uses: actions/checkout@v4 - run: | xcodebuild test \ -scheme AppUITests \ -destination "${{ matrix.destination }}" \ CODE_SIGNING_ALLOWED=NO ``` Four configuration facts decide whether the move is clean: 1. **Aliases exist for the latest images.** `warp-macos-latest-arm64-6x` resolves to `warp-macos-15-arm64-6x` and `warp-macos-latest-arm64-12x` resolves to `warp-macos-15-arm64-12x`, in step with GitHub's own `macos-latest` pointer. Pinning the explicit version label keeps a release branch stable when the pointer moves. 2. **macOS runners have no Docker and no nested virtualization.** Container builds and Android emulator jobs belong on Linux x64 runners, where the `nested-virtualization.enabled=true` label grants `/dev/kvm` access. 3. **The WarpBuild cache is a Linux runner feature.** On macOS, use `actions/cache` for DerivedData and the SPM cache directory, as shown above, and keep the cache key tied to `Package.resolved`. 4. **Runner storage is ephemeral.** Every job starts on a fresh VM, so anything the next job needs goes to an artifact or a cache. The label list, the sizes, and the per-minute rates live in the [cloud runners documentation](/docs/ci/cloud-runners), which also carries the macOS 26 tooling table with the bundled simulator runtimes. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [The two price tables](#the-two-price-tables)Copy Anchor Link WarpBuild macOS runners, from the runner catalog: | Label | macOS | vCPU | Memory | Storage | Rate per minute | Alias | | ----------------------- | -------- | ---- | ------ | --------- | --------------- | --------------------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | | GitHub-hosted macOS runners, at GitHub's published list rates: | GitHub-hosted label | Chip | vCPU | Memory | Rate per minute | | ------------------------------- | ------------- | ---- | ------ | --------------- | | macos-latest (actions\_macos) | Apple Silicon | 3 | 7GB | $0.062 | | macos-latest-large (macos\_l) | Intel | 12 | 30GB | $0.077 | | macos-latest-xlarge (macos\_xl) | Apple Silicon | 5 | 14GB | $0.102 | Rates read from [GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. GitHub bills these rates on paid plans once included minutes are consumed. The matched shape for a WarpBuild comparison is `macos-latest-xlarge`, GitHub's Apple Silicon macOS runner, since `macos-latest-large` is Intel hardware and `macos-latest` is a smaller 3 vCPU machine. WarpBuild rates come from the [pricing page](/pricing) and the cloud runners documentation, verified the same day. ### [The arithmetic on one minute](#the-arithmetic-on-one-minute)Copy Anchor Link The like-for-like comparison is against `macos-latest-xlarge`, because Apple Silicon on Apple Silicon is the closest published shape between the two catalogs. * GitHub-hosted `macos-latest-xlarge`: 5 vCPU, 14GB of memory, $0.102 per minute. * WarpBuild `warp-macos-15-arm64-6x`: 6 vCPU, 14GB of memory, $0.08 per minute, one more vCPU and the same memory. * $0.102 minus $0.08 is $0.022 per minute, which is 22 percent lower list price at the WarpBuild rate. * 100 macOS minutes cost $10.20 at GitHub's list rate and $8.00 at the WarpBuild rate. * 1,000 macOS minutes cost $102.00 and $80.00. GitHub's other two macOS shapes sit outside this comparison. The 3 vCPU `macos-latest` runner is $0.062 per minute, the cheapest macOS minute on either price list, and it draws on a plan's included minutes the way `macos-latest-xlarge` does not. The 12 vCPU `macos-latest-large` runner is Intel hardware at $0.077 per minute, a different chip family from WarpBuild's Apple Silicon fleet, so it is not a matched size either. The Apple Silicon pair above is the comparison this page carries through the model below. ### [Worked monthly model](#worked-monthly-model)Copy Anchor Link Assumptions, all stated so you can substitute your own numbers: * One iOS repository, 8 engineers, 20 working days in the month. * 25 pull request runs per working day, so 500 pull request runs in the month. * Each pull request run is one macOS job of 14 minutes: 4 minutes of lint, metadata, and changelog checks plus 10 minutes of build and simulator tests. * One nightly archive job per working day at 22 minutes, so 20 runs. * One release job per week at 35 minutes, so 4 runs. * Every job runs to completion. Cancelled runs and reruns are excluded. * Billing is per minute of job time on both platforms. * Artifact storage, cache storage, and cache operations are excluded from both columns so the runner minute is the only variable. Minute totals: 500 x 14 = 7,000, plus 20 x 22 = 440, plus 4 x 35 = 140\. That is **7,580 macOS minutes per month**. | Configuration | macOS minutes | Linux minutes | Monthly runner cost | | -------------------------------------------------------------- | ------------- | ------------- | ------------------- | | Everything on GitHub-hosted macos-latest-xlarge | 7,580 | 0 | $773.16 | | Everything on warp-macos-15-arm64-6x | 7,580 | 0 | $606.40 | | Split, GitHub-hosted macOS plus hosted Linux 2-core | 5,580 | 2,000 | $581.16 | | Split, warp-macos-15-arm64-6x plus warp-ubuntu-latest-arm64-4x | 5,580 | 2,000 | $458.40 | The arithmetic behind each row: * 7,580 x $0.102 = $773.16. * 7,580 x $0.08 = $606.40\. Difference against the first row: $166.76 per month, or $2,001.12 over twelve months at the same volume. * Moving the 4 minutes of non-Xcode work per pull request run off macOS removes 500 x 4 = 2,000 minutes from the macOS column. GitHub-hosted: 5,580 x $0.102 = $569.16, plus 2,000 x $0.006 = $12.00, total $581.16. * WarpBuild: 5,580 x $0.08 = $446.40, plus 2,000 x $0.006 = $12.00, total $458.40\. The split alone saves $148.00 per month against the all-macOS WarpBuild row, and combining the switch with the split is what separates the cheapest row from the rest. Signup includes $10 free credits, which covers 125 minutes on a 6 vCPU macOS runner at $0.08 per minute, or roughly 12 pull request test jobs from the model above before the first invoice. ### [Where the model breaks](#where-the-model-breaks)Copy Anchor Link Three assumptions in that table are worth testing against your own repository before you trust the total. **Cold caches.** The 10-minute test job assumes DerivedData restores. A month of cache misses turns 10 minutes into 16 and adds 500 x 6 x $0.08 = $240.00 at the WarpBuild rate, or 500 x 6 x $0.102 = $306.00 at GitHub's list rate. Cache keys deserve as much review as the runner label. **Reruns.** Flaky UI tests that get rerun twice a day add 40 macOS jobs a month. At 14 minutes each that is 560 minutes, $44.80 at $0.08 per minute. **Matrix width.** Adding a fourth simulator destination to the UI test job adds a full job per run. Cheap in wall clock when the legs run in parallel, and linear on the invoice either way. Read the real numbers instead of the model once you are running. The [reports page](/docs/ci/features/reports) breaks billing down per job, showing repository, job name, runner label, execution time, billed time, and cost, with CSV export and filters by repository and runner label. CI observability adds per-job CPU and memory percentiles, which is how you tell a 12 vCPU job that needed the width from one that idled at 6. For a full list of runner sizes and rates across every platform, see the [WarpBuild pricing page](/pricing). For macOS specifics, start with [WarpBuild macOS runners for GitHub Actions](/runners/macos) and the [Xcode versions on WarpBuild macOS images](/runners/macos/xcode-images). For the pipeline shape around the runner, see [iOS pipelines on GitHub Actions](/solutions/ios-github-actions), and for the same exercise across Linux and Windows jobs, see [reduce GitHub Actions costs](/guides/reduce-github-actions-costs). ## [FAQ](#faq)Copy Anchor Link **How much do GitHub-hosted macOS runners cost per minute?** GitHub publishes $0.062 per minute for the 3-core Apple Silicon image (`macos-latest`), $0.077 per minute for the 12-core Intel image (`macos-latest-large`), and $0.102 per minute for the 5-core Apple Silicon image (`macos-latest-xlarge`). Rates read from the GitHub Actions per-minute rates documentation, checked on 2026-08-13\. Confirm on GitHub's page before you budget, because GitHub revises the table. **What does a WarpBuild macOS runner cost?** $0.08 per minute for 6 vCPU with 14GB of memory and 120GB of SSD, and $0.16 per minute for 12 vCPU with 28GB of memory and 270GB of SSD. Billing is per minute of job time. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. **Which WarpBuild label replaces `macos-latest` in an iOS workflow?** `warp-macos-15-arm64-6x`, which also answers to the alias `warp-macos-latest-arm64-6x`. For a larger machine on the newest image, use `warp-macos-26-arm64-12x`, which carries 12 vCPU, 28GB of memory, and Xcode 27.0. **Can macOS runners run Docker or an Android emulator?** No. WarpBuild macOS runners do not support nested virtualization and cannot run Docker. Send container builds and Android emulator jobs to Linux x64 runners, where the `nested-virtualization.enabled=true` label grants `/dev/kvm` access. --- # Building a GitHub Actions Matrix That Scales | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-matrix-builds Description: A GitHub Actions build matrix turns one job into one job per combination. Size the fan-out to runner capacity, shard the suite, and price every cell. # Building a GitHub Actions Matrix That Scales A GitHub Actions build matrix turns one job into one job per combination. Size the fan-out to runner capacity, shard the suite, and price every cell. Last verified: 2026-08-13 A GitHub Actions build matrix expands one job definition into one job per combination of the values listed under `strategy.matrix`, so a suite written once runs as 24 independent jobs on 24 machines. The matrix keeps paying while every cell does distinct work and the fan-out fits the runner capacity behind your `runs-on` labels, and it starts costing more than it returns the moment either of those two conditions breaks. This guide covers the three ways a matrix stops helping, the edits that fix each one, a workflow that routes cells of different weights to different runner sizes, and the arithmetic that sets the shard count. For the semantics of `include`, `exclude`, `fail-fast`, and the `matrix` context, see the [build matrix glossary entry](/glossary/build-matrix). ## [Diagnosis](#diagnosis)Copy Anchor Link A matrix that has stopped helping still looks healthy in the run view. The grid is green, the job count is high, and the wall clock has quietly stopped tracking the work. Three patterns account for most of it. ### [Cells that duplicate work](#cells-that-duplicate-work)Copy Anchor Link Every cell pays for checkout, dependency install, and cache restore before it runs anything specific to that combination. A dimension earns those repeated minutes only when its cells can disagree. The common case is an operating system dimension on a suite with no operating system specific code. Three operating systems multiplied by three language versions is nine jobs, nine installs, and nine cache restores to answer a question that three jobs plus one targeted test case answers. Read duration P75 and P90 per job in the Jobs report, which reports both percentiles for every unique repository, workflow, and job name combination ([reports documentation](/docs/ci/features/reports)). Cells whose percentiles sit within noise of each other and whose logs contain the same steps are buying no information. Collapsing that dimension divides the job count by the length of its list. ### [fail-fast cancelling useful signal](#fail-fast-cancelling-useful-signal)Copy Anchor Link `fail-fast` defaults to `true`. The first cell that reports a failure cancels the in-progress cells from the same matrix, and cells still waiting in the queue never start. For a merge gate that default is correct. For a compatibility grid it removes the answer the run existed to produce, and the rerun pays for the entire grid a second time. The symptom is a run holding one red cell, a row of cancelled cells, and a comment asking someone to rerun it. ### [Fan-out wider than available capacity](#fan-out-wider-than-available-capacity)Copy Anchor Link Expansion and execution are separate events. The matrix creates every cell at once, then each cell waits for an online runner that advertises every label in its `runs-on` key. Twenty-four cells facing six eligible runners run in four waves, so wall clock is four cell durations plus queue wait, and doubling the matrix width without adding capacity doubles the wall clock. Measure before widening anything. The Queue Timings report gives queue wait at P75 and P90 per runner label and stack, with CSV export, so a label whose P90 climbs while other labels start on time is a capacity signal rather than a workflow bug. On GitHub-hosted runners, subtract each job's `created_at` from its `started_at` and read the spread yourself, then check the account ceilings covered in the guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits). One hard stop sits above all of this: a matrix generates a maximum of 256 jobs per workflow run, and a product larger than that fails the run instead of truncating it ([GitHub Actions usage limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). | Symptom | What it means | Where to look | | ------------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------- | | Every cell logs the same steps and finishes within a minute of the others | A dimension that produces no distinct information | Jobs report, duration P75 and P90 | | One red cell beside a row of cancelled cells | fail-fast cancelling the rest of the grid | Run view, conclusion on sibling jobs | | Wall clock is a whole multiple of one cell duration | Fan-out wider than the eligible runners | Queue Timings, P75 and P90 per label | | One cell pending while every other cell starts | Label typo in one dimension | runs-on values against the runner catalog | | The run fails before any job starts | Product above the 256 job cap | Length of each matrix dimension | ## [Fix](#fix)Copy Anchor Link Work the list in order. The first two edits shrink the grid, the third restores the signal, and the last two give what remains somewhere to run. **Cut dimensions that repeat work.** Keep a dimension when its values change the build product or the runtime under test: architecture, operating system for code with platform specific paths, and the language versions your users actually run. Drop the rest to a single value and cover the edge case with one test rather than one axis. **Shard instead of copying.** Replace a duplicated dimension with a shard index and pass that index into the test runner, so each cell executes a distinct slice of the same suite. Most runners take a shard argument directly, and the ones that do not can partition by test file list. Sharding converts matrix width into wall clock reduction, which is the only thing a wide grid is good for. **Set `fail-fast` by purpose.** True for merge gates, false for compatibility grids. Write the reason in a comment next to the key so the next person keeps the choice. **Size cells by what binds them.** A unit shard bound by a single process and a browser shard running parallel workers want different machines. Put the runner label in a matrix key so one grid can hold both, as the configuration below does. **Give the fan-out somewhere to land.**. A fresh runner is provisioned per job. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Relabeling `runs-on` is the whole change, since the plan level ceiling follows the runner pool rather than the workflow. The [answer on how many GitHub Actions jobs can run at once](/answers/how-many-github-actions-jobs-can-run-at-once) covers which ceiling applies where. Then measure again. The queue and duration percentiles above come from the same reports. If wall clock stays flat after a widening, the constraint moved to queue wait, which the guide to [GitHub Actions queue times](/guides/github-actions-queue-times) takes apart. ## [Configuration](#configuration)Copy Anchor Link This workflow runs three suites of different weights. `exclude` trims the browser suite to three shards, and `include` merges a runner label and a shard total into each suite without changing the job count. Copy Text ``` name: test on: pull_request: push: branches: [main] jobs: test: name: ${{ matrix.suite }}-${{ matrix.shard }} strategy: # this grid is a compatibility report; a merge gate would keep the default fail-fast: false matrix: suite: [unit, integration, browser] shard: [1, 2, 3, 4] exclude: - suite: browser shard: 4 include: - suite: unit runner: warp-ubuntu-latest-x64-4x shard_total: 4 - suite: integration runner: warp-ubuntu-latest-x64-8x shard_total: 4 - suite: browser runner: warp-ubuntu-latest-x64-16x shard_total: 3 runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run test:${{ matrix.suite }} -- --shard=${{ matrix.shard }}/${{ matrix.shard_total }} ``` What each piece does: * The product of `suite` and `shard` is 12 cells. GitHub applies `exclude` first, dropping `browser-4` and leaving 11, then applies `include`. * Each `include` object names a `suite` value that already exists, so it merges `runner` and `shard_total` into the matching cells rather than appending jobs. The count stays at 11. * `shard_total` keeps the shard argument honest. Browser cells receive `--shard=1/3` through `--shard=3/3` while unit and integration cells receive `n/4`. * `runs-on: ${{ matrix.runner }}` reads a matrix value like any other expression, so the machine choice lives in the matrix and the steps stay untouched. * The `name` key puts the suite and shard in the job name, which keeps required status checks readable. Renaming a matrix value renames the check, so update branch protection when you change the shard count. * Add `max-parallel` under `strategy` when something outside the runner fleet sets the ceiling, such as a rate limited API or a shared test database. The three labels resolve to these machines, from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | runs-on label | OS | vCPU | RAM | Storage | USD per minute | | -------------------------- | ------------ | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.064 | ### [Sizing each cell type](#sizing-each-cell-type)Copy Anchor Link Pick the size from what binds the cell, then confirm with CPU and memory P75 and P90 in the Jobs report after one day of runs. | Cell type | What binds it | Starting label | Rate per minute | | ------------------------------------------ | ----------------------------------------------- | -------------------------- | --------------- | | Lint, type check, unit shards | One process, low memory | warp-ubuntu-latest-x64-2x | $0.004 | | Unit shards with parallel workers | Worker count, so vCPU | warp-ubuntu-latest-x64-4x | $0.008 | | Integration shards with service containers | Memory held by databases and brokers | warp-ubuntu-latest-x64-8x | $0.016 | | Browser and end to end shards | Parallel browser instances, memory per instance | warp-ubuntu-latest-x64-16x | $0.032 | | Native compile and link cells | Parallel compile jobs, so vCPU and disk | warp-ubuntu-latest-x64-32x | $0.064 | Two catalog facts change matrix shape. Nested virtualization for Android emulator workloads is available on Linux x64 through the `nested-virtualization.enabled=true` label, so an emulator cell belongs on an x64 label rather than an ARM64 one. macOS runners cannot run Docker, so a Docker based integration cell stays on Linux even when the rest of the grid is a platform dimension. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Sharding trades billed minutes for wall clock, and the exchange rate is the per-job setup time. Two formulas cover it. Billed minutes are `T + N * s`, and wall clock per run is `T / N + s + queue`, where `T` is total suite execution time, `N` is the shard count, and `s` is the fixed setup a cell pays before its first test. Assumptions for the model: a suite with `T` of 96 minutes of test execution, `s` of 2 minutes for checkout, `npm ci`, and cache restore, uniform shards, no retries, and per minute billing. Rates are the catalog rates above. | Shards | Test minutes per cell | Cell duration | Wall clock, queue excluded | Billed minutes | Cost per run at $0.008 | | -------------- | --------------------- | ------------- | -------------------------- | -------------- | ---------------------- | | 1 (serialized) | 96 | 98 min | 98 min | 98 | $0.78 | | 6 | 16 | 18 min | 18 min | 108 | $0.86 | | 12 | 8 | 10 min | 10 min | 120 | $0.96 | | 24 | 4 | 6 min | 6 min | 144 | $1.15 | | 48 | 2 | 4 min | 4 min | 192 | $1.54 | Read the last two rows together. Going from 24 shards to 48 shards buys 2 minutes of wall clock and costs 48 more billed minutes, because every extra shard pays the same 2 minute setup. The useful stopping point is where per-cell test time approaches `s`, which is 24 shards for this suite. Now add queue wait, which is the part the table cannot assume. Pull your own numbers from the Queue Timings row for the label. Suppose that row reports P75 of 0.4 minutes and P90 of 1.5 minutes for `warp-ubuntu-latest-x64-4x`. The 24 shard run then finishes in 6.4 minutes at P75 and 7.5 minutes at P90, against 98.4 and 99.5 minutes for the serialized version. Where a pool runs its cells in waves, multiply the queue figure by the number of waves before comparing, since each wave queues again. Those 144 billed minutes carry a list price. GitHub's per minute prices below are from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Shape | GitHub-hosted runner and rate | WarpBuild label and rate | 144 minutes on GitHub-hosted | 144 minutes on WarpBuild | | --------------- | ------------------------------------ | ---------------------------------- | ---------------------------- | ------------------------ | | 2 vCPU, 8 GB | ubuntu-latest, private repos, $0.006 | warp-ubuntu-latest-x64-2x, $0.004 | $0.86 | $0.58 | | 4 vCPU, 16 GB | 4-core larger runner, $0.012 | warp-ubuntu-latest-x64-4x, $0.008 | $1.73 | $1.15 | | 8 vCPU, 32 GB | 8-core larger runner, $0.022 | warp-ubuntu-latest-x64-8x, $0.016 | $3.17 | $2.30 | | 16 vCPU, 64 GB | 16-core larger runner, $0.042 | warp-ubuntu-latest-x64-16x, $0.032 | $6.05 | $4.61 | | 32 vCPU, 128 GB | 32-core larger runner, $0.082 | warp-ubuntu-latest-x64-32x, $0.064 | $11.81 | $9.22 | Matched shape for shape, that is 33 percent lower list price at 2 and 4 vCPU, 27 percent at 8 vCPU, 24 percent at 16 vCPU, and 22 percent at 32 vCPU, with GitHub list prices checked on 2026-08-13. Scale it to a working month. At 30 runs a day, the 24 shard configuration bills 4,320 minutes a day, which is $34.56 on `warp-ubuntu-latest-x64-4x` and $51.84 on the GitHub-hosted 4-core larger runner. The same change returns 92 minutes of wall clock per run, or 2,760 minutes of engineer waiting a day. Full rates by runner type are on the [WarpBuild pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link **How many shards should a GitHub Actions matrix have?** Billed minutes for a sharded suite are the suite time plus the per-job setup time multiplied by the shard count, and wall clock is the suite time divided by the shard count plus that same setup time. Keep adding shards while the per-cell test time stays well above the setup time, and stop once they are close, because past that point each extra shard buys seconds of wall clock and costs a full setup. **Can different cells in one matrix use different runner sizes?** Yes. Put the runner label in a matrix key and set `runs-on` to that key. An `include` entry whose keys match an existing combination merges the label into that combination without changing the job count, so unit cells can land on a 4 vCPU label while browser cells land on a 16 vCPU label in the same matrix. **Should fail-fast be true or false in a matrix?** Set `fail-fast` true when the matrix is a merge gate, because one broken combination is enough to block the merge and the remaining cells only spend minutes confirming it. Set it false when the matrix is a compatibility report, because the default cancels in-progress siblings and leaves you rerunning the whole grid to learn what else failed. **Does a wider matrix cost more in billed minutes?** Yes, by the fixed setup work each cell repeats. Checkout, dependency install, and cache restore are paid once per job, so a 24 shard matrix over a 96 minute suite bills 144 minutes against 98 for the same suite in one job. The wall clock drops from 98 minutes to 6 minutes for that extra $0.37 per run at $0.008 per minute. --- # When a Matrix Grows Too Wide | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-matrix-explosion Description: A GitHub Actions matrix multiplies every dimension into jobs. Price the width, prune the pull request grid with exclude and a computed matrix, run it nightly. # When a Matrix Grows Too Wide A GitHub Actions matrix multiplies every dimension into jobs. Price the width, prune the pull request grid with exclude and a computed matrix, run it nightly. Last verified: 2026-08-13 A GitHub Actions matrix creates one job per combination of the lists under `strategy.matrix`, so every dimension you add multiplies the job count, and the billed minutes, by the length of its list. A grid that reached 216 jobs is usually five dimensions doing the work of two dimensions plus one scheduled run. This guide covers how to price the width you already pay for, the three tools that prune it, a workflow that runs a narrow matrix on pull requests and the complete grid nightly, and the monthly arithmetic for the change. Matrix structure and sharding live in the [guide to building a matrix that scales](/guides/github-actions-matrix-builds), and the rest of the bill lives in the hub on [reducing GitHub Actions costs](/guides/reduce-github-actions-costs). ## [Diagnosis](#diagnosis)Copy Anchor Link ### [Count the product before changing anything](#count-the-product-before-changing-anything)Copy Anchor Link The generated count is the product of every list under `strategy.matrix`, minus the combinations `exclude` removes, plus the `include` entries that match no existing combination ([workflow syntax for strategy.matrix](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstrategymatrix), checked on 2026-08-13). Read the block that way rather than counting the lines in the file, because one `include` object can merge keys into 12 cells without adding a job, and the [answer on include and exclude](/answers/how-do-i-use-a-matrix-with-include-and-exclude) walks through each case. One hard stop sits above the arithmetic. A matrix generates at most 256 jobs per workflow run, and a product above that fails the run before any job starts ([GitHub Actions usage limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13). Teams usually meet that ceiling by accident, when a routine third value is added to a dimension that already multiplies four other lists. The [answer on how many jobs a matrix can fan out to](/answers/how-many-jobs-can-a-matrix-fan-out-to) covers the cap on its own. ### [Price each dimension](#price-each-dimension)Copy Anchor Link Width has a unit price, and the price is the whole current grid. The table prices one definition as dimensions accumulate, with every cell taking 8 minutes on `warp-ubuntu-latest-x64-4x` at $0.008 per minute from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. Every cell is priced on the same label here so the width is the only variable. | Dimensions in the definition | Product | Jobs | Billed minutes at 8 minutes per cell | Cost per run at $0.008 per minute | | ---------------------------- | ----------------- | ---- | ------------------------------------ | --------------------------------- | | pkg with 3 values | 3 | 3 | 24 | $0.19 | | plus node with 3 values | 3 x 3 | 9 | 72 | $0.58 | | plus runner with 3 values | 3 x 3 x 3 | 27 | 216 | $1.73 | | plus database with 2 values | 3 x 3 x 3 x 2 | 54 | 432 | $3.46 | | plus shard with 4 values | 3 x 3 x 3 x 2 x 4 | 216 | 1,728 | $13.82 | Read it as marginal cost. The two value `database` dimension adds $1.73 per run, which is exactly what the entire grid cost one row earlier, and it does that for every run from then on. The last row also sets up the failure: one more two value dimension takes the product to 432, which is above the 256 job cap, so the run stops generating jobs and starts failing outright. ### [Find the dimensions nobody reads](#find-the-dimensions-nobody-reads)Copy Anchor Link A dimension earns its multiplier when its cells can disagree. The test is failure history rather than intuition: pull the per job rows for the workflow and look for a job name that has never failed while its siblings were green. A `node` value that has produced no independent failure in six months is answering a question the team stopped asking. Duration percentiles catch the second pattern. The Jobs report gives duration P75 and P90 for every repository, workflow, and job name combination, with CSV export ([reports documentation](/docs/ci/features/reports)). Cells whose percentiles sit within noise of each other, with logs that contain the same steps, are paying setup three times to produce one result. On GitHub-hosted runners, pull the same rows from the workflow jobs API and compute the percentiles yourself. Three signals are usually enough to pick the dimension to cut first: * The dimension multiplies the largest number of cells and has the fewest independent failures. * Its values are environment choices, such as a database minor version, rather than something a user runs. * Removing it leaves a targeted test that covers the same edge case in one job. ## [Fix](#fix)Copy Anchor Link Three tools, in the order that removes the most jobs for the least review risk. **Trim the corners with `exclude`.** `exclude` drops every combination whose values match all the keys in the entry, so a single entry removes a whole row or column of the product. Windows plus the worker package, or the oldest runtime plus the newest package, are corners that carry little risk and full cost. One `exclude` entry against a three value dimension removes three jobs, and the entry stays readable next to the lists it prunes. **Compute the matrix at run time.** A hardcoded grid cannot know that a branch touched one package. Emit the matrix as JSON from a small planning job and read it with `fromJSON`, so the pull request width tracks the diff instead of the repository ([expressions reference for fromJSON](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#fromjson), checked on 2026-08-13). This is the edit that turns a fixed 24 cell bill into a bill proportional to the change. When the whole matrix comes from an expression, `exclude` moves inside the generated JSON, since the expression replaces the entire `matrix` mapping. **Move the full grid to a schedule.** The complete product still has to run, just not on every push. A scheduled workflow runs the whole grid once a day on the default branch, which keeps the compatibility answer current at one run per day rather than one per pull request. The [guide to nightly build jobs](/guides/nightly-builds-github-actions) covers who gets notified when that run goes red, which is the part teams skip. Then give what remains somewhere to land. A mixed grid resolves against one catalog and the edit is the `runs-on` label. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a nightly grid of 24 cells starts as 24 cells rather than four waves of six. ## [Configuration](#configuration)Copy Anchor Link This workflow plans the matrix in a first job, then builds. Pull requests get one runner label, one Node version, and only the packages the branch changed. The scheduled run gets the full product with one `exclude` entry. Copy Text ``` name: build on: pull_request: schedule: - cron: "0 6 * * *" workflow_dispatch: jobs: plan: runs-on: warp-ubuntu-latest-x64-2x outputs: matrix: ${{ steps.build.outputs.matrix }} count: ${{ steps.build.outputs.count }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - id: build env: EVENT: ${{ github.event_name }} BASE: ${{ github.base_ref }} run: | if [ "$EVENT" = "pull_request" ]; then base=$(git merge-base "origin/$BASE" HEAD) pkgs=$(git diff --name-only "$base"...HEAD \ | awk -F/ '$1 == "packages" { print $2 }' | sort -u \ | jq -Rsc 'split("\n") | map(select(length > 0))') matrix=$(jq -nc --argjson pkg "$pkgs" \ '{runner: ["warp-ubuntu-latest-x64-4x"], node: ["22"], pkg: $pkg}') else matrix=$(jq -nc '{ runner: [ "warp-ubuntu-latest-x64-4x", "warp-macos-latest-arm64-6x", "warp-windows-latest-x64-4x" ], node: ["20", "22", "24"], pkg: ["api", "web", "worker"], exclude: [{runner: "warp-windows-latest-x64-4x", pkg: "worker"}] }') fi echo "matrix=$matrix" >> "$GITHUB_OUTPUT" echo "count=$(echo "$matrix" | jq '.pkg | length')" >> "$GITHUB_OUTPUT" build: needs: plan if: needs.plan.outputs.count != '0' strategy: fail-fast: ${{ github.event_name == 'pull_request' }} matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: npm ci - run: npm run build --workspace packages/${{ matrix.pkg }} - run: npm test --workspace packages/${{ matrix.pkg }} ``` What each piece does: * The scheduled branch generates 3 runner labels x 3 Node versions x 3 packages, which is 27 cells, and the `exclude` entry removes the 3 Windows plus worker combinations, leaving 24 jobs. * The pull request branch generates one cell per changed package. A branch that touches `packages/api` alone produces 1 job against the 24 the same repository used to run. * `git merge-base` plus the three dot diff compares the branch against the point it forked from, so a merge into the base branch does not widen the matrix with files the branch never touched. * The `env` block passes `github.base_ref` into the script as a variable rather than interpolating it into the shell line, which keeps a branch name from becoming part of the command. * `exclude` lives inside the generated JSON. Setting `matrix` to an expression replaces the whole mapping, so a sibling `exclude` key under `matrix` has nothing to attach to. * The `strategy` key reads the `github` context ([contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts), checked on 2026-08-13), so `fail-fast` is true for the pull request gate and false for the nightly compatibility report without duplicating the job. * `if: needs.plan.outputs.count != '0'` skips the build job when no package changed. A skipped job reports as skipped to branch protection, while a job that generates zero cells leaves a required check with nothing to report, so keep the required check on a job with a fixed name. The three labels in that file resolve to these machines, from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | runs-on label | OS | vCPU | RAM | Storage | USD per minute | | -------------------------- | ------------------- | ---- | ----- | --------- | -------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-macos-latest-arm64-6x | macOS 15 | 6 | 14 GB | 120GB SSD | $0.08 | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16 GB | 256GB SSD | $0.016 | ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Price the nightly grid first, because it is the run that keeps the full product. Assumptions: 24 cells as generated above, 8 minutes per cell, no retries, per minute billing, and the catalog rates in the table above. | Runner label | Cells | Billed minutes | Rate per minute | Cost per run | | -------------------------- | ----- | -------------- | --------------- | ------------ | | warp-ubuntu-latest-x64-4x | 9 | 72 | $0.008 | $0.58 | | warp-macos-latest-arm64-6x | 9 | 72 | $0.08 | $5.76 | | warp-windows-latest-x64-4x | 6 | 48 | $0.016 | $0.77 | | Total | 24 | 192 | $7.10 | | Now run that same grid on every pull request instead. Assume 20 pull request runs a day across 22 working days, so 440 runs a month, and an average of 2 changed packages per branch. | Configuration | Cells per run | Runs per month | Billed minutes per month | Cost per month | | ------------------------------- | ------------- | -------------- | ------------------------ | -------------- | | Full grid on every pull request | 24 | 440 | 84,480 | $3,125.76 | | Narrow pull request grid | 2 | 440 | 7,040 | $56.32 | | Nightly full grid | 24 | 30 | 5,760 | $213.12 | | Pruned total | 12,800 | $269.44 | | | The pruned configuration bills 12,800 minutes a month against 84,480 for the same coverage on every push, a difference of $2,856.32 at these rates. The Linux portion of that carries a list price comparison: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB), which is 33 percent lower list price, arithmetic (0.012 - 0.008) / 0.012, with the GitHub list price checked on 2026-08-13 ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers)). The 7,040 pull request minutes cost $56.32 at the first rate and $84.48 at the second. Wall clock moves less than the invoice does. When every cell of the wide grid already started at once, pruning the pull request matrix returns queue pressure and billed minutes rather than minutes of review latency. The real trade is detection time: a break in the Node 20 plus Windows corner now surfaces in the next morning's scheduled run instead of in the pull request that caused it. Price that against the table before cutting a dimension that has produced independent failures. Per minute rates for every size are on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link **Why does my GitHub Actions matrix create so many jobs?** The job count is the product of every list under `strategy.matrix`, so a definition with 3 packages, 3 Node versions, and 3 runner labels generates 27 jobs before any `exclude` entry is applied. Each dimension added multiplies the whole grid again, which is why a matrix that started at 9 jobs reaches 216 after two more dimensions. **How do I reduce the number of jobs a matrix generates?** Three edits, in order of how much they remove. Drop `exclude` entries on the corners of the product that carry no risk, compute the pull request matrix from the packages a branch actually changed so the width tracks the diff, and move the full grid to a scheduled run so the complete product is paid once a day instead of once per push. **What breaks when a matrix gets too wide?** A matrix generates at most 256 jobs per workflow run, and a definition whose product lands above that fails the run before any job starts. Below the cap, the failure is quieter: billed minutes grow with the width while the answers stay the same, and cells queue in waves once the fan-out is wider than the runners available behind the label. --- # Querying GitHub Actions Data with an MCP Server | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-mcp-server Description: WarpBuild runs an MCP server at https://mcp.warpbuild.com/mcp so an agent can query your GitHub Actions runner fleet, create runners, and build images. # Querying GitHub Actions Data with an MCP Server WarpBuild runs an MCP server at https://mcp.warpbuild.com/mcp so an agent can query your GitHub Actions runner fleet, create runners, and build images. Last verified: 2026-08-13 WarpBuild runs a hosted MCP server at `https://mcp.warpbuild.com/mcp` that connects an MCP host to the WarpBuild API, so an agent can create runners, create runner images, and query your GitHub Actions runner fleet from the editor you already have open. Point the host at that URL with an `Authorization: Bearer ` header, using a key that carries the CI scope, and the questions that used to need a dashboard tab plus a spreadsheet become questions you type once. This guide covers the manual investigation loop the MCP server removes, the exact configuration block, three worked prompts with the shape of the answer that comes back, and a time and cost model you can rerun with your own numbers. ## [Diagnosis](#diagnosis)Copy Anchor Link A runner question almost never lives on one screen. Take the ordinary version of it: the `integration` job on the main branch used to finish in eight minutes and now takes fourteen, and someone in the platform channel wants to know whether to change the label, change the test split, or leave it alone. Answering that means visiting three surfaces. The first is the GitHub Actions run page. Step timings live there, along with the log lines that tell you which step grew. The second is the WarpBuild observability page at `app.warpbuild.com/ci/observability`, which carries CPU, memory, filesystem, and network utilization for the runner instance, plus system logs and the GitHub Actions logs correlated against them. The third is the runner inventory: which labels exist, which image each runner boots, what pool size it holds, and which instance types sit behind it. Only the first two have a dashboard. The third lives in the WarpBuild API, and the mapping from a `runs-on` label in a workflow file to a machine shape and a per-minute rate is the join that nobody has open in a tab. ### [The manual loop, timed](#the-manual-loop-timed)Copy Anchor Link Here is the loop as most teams run it, with the minutes we use as the modeling assumption. Substitute your own. | Step | Surface | Minutes | | ---------------------------------------------------------------- | ---------------------------- | ------- | | Open the run, find the slow job, compare against last week's run | GitHub Actions tab | 4 | | Read the job log, identify which step grew | GitHub Actions tab | 6 | | Pull runner utilization for that job | WarpBuild observability page | 5 | | Export usage minutes and filter down to the workflow | Billing export, spreadsheet | 8 | | Match every runs-on label to a machine shape and rate | Repo plus pricing page | 4 | | Write the recommendation into an issue or a doc | Issue tracker | 5 | | **Total** | **32** | | Three properties make that loop worse than the 32 minutes suggest. The join key is a human. Nothing automatically connects the string `warp-ubuntu-latest-x64-8x` in a workflow file to the 8 vCPU, 32 GB shape it resolves to and the minutes billed against it. An engineer holds that mapping in their head, and it goes stale whenever a label changes. The asker is rarely the owner. The person who wants the answer is often on a product team, and the person who set the runner labels is on the platform team. The question turns into a handoff, and the handoff adds a day. Short jobs fall out of the metrics. Observability collects metrics and logs for jobs longer than about one minute, so a fleet of fast jobs has to be understood from log timings and minute counts rather than utilization charts. ### [What an agent needs instead](#what-an-agent-needs-instead)Copy Anchor Link An agent can carry the whole loop if three things are true: the platform exposes a machine-readable surface, the agent can authenticate to it, and there is a protocol the host already speaks. WarpBuild satisfies the first two by design. The MCP server supplies the third. It exposes that same API behind a Model Context Protocol endpoint, so the host handles discovery and calling conventions, and your attention goes to the question instead of to curl syntax. ## [Fix](#fix)Copy Anchor Link The setup is four steps and takes about ten minutes end to end. 1. Generate an API key at [app.warpbuild.com/settings/api-keys](https://app.warpbuild.com/settings/api-keys), give it a name, and check the CI scope. 2. Add the WarpBuild MCP server block to your host's MCP configuration with the key in an `Authorization: Bearer` header. 3. Reload the host and confirm the server shows as connected. 4. Ask a read-only question first, so a misconfigured key fails on a listing call rather than on a create call. Once the host is connected, the loop above collapses into prompts. Three of them cover most of what teams actually ask. ### [Prompt 1: audit the runner inventory](#prompt-1-audit-the-runner-inventory)Copy Anchor Link > List every custom runner in the organization with its labels, pool size, image id, and instance types. Then check the workflow files in this repository and flag any runner whose labels never appear in a `runs-on`. The answer comes back as one table row per runner, plus a short list of orphans. The agent reads the fleet through the MCP server and reads the workflows from the repository the host already has open, which is the join that costs an engineer four minutes of tab switching. This one is worth a monthly cadence. The automation docs note that unused custom runners can increase job pick-up times, so an inventory that only grows is a latency problem as well as a tidiness problem. ### [Prompt 2: create an image and a runner](#prompt-2-create-an-image-and-a-runner)Copy Anchor Link > Create a BYOC runner image from AMI `ami-0f1c2d3e4a5b6c7d8` on our EC2 stack, then create a runner named `payments-arm-large` with pool size 2 and instance types `c7g.4xlarge` and `c7g.8xlarge`. Return the label I should put in `runs-on`. The agent resolves the stack, posts the runner image, posts the runner, and returns the created runner id along with its labels array. The label it hands back is the one you paste into the workflow. The same sequence by hand is three curl calls with two ids copied between them, and the second id only exists after the first call succeeds. Two guardrails belong on this prompt. Ask the agent to echo the request body before it posts, and keep deletes out of agent hands during the first weeks, since deleting a runner through the API is irreversible. ### [Prompt 3: sweep for oversized labels](#prompt-3-sweep-for-oversized-labels)Copy Anchor Link > For every job in `.github/workflows`, print the runner label, the vCPU and RAM behind it, and the per-minute rate. Sort by weekly minutes and show me the three jobs where the shape looks larger than the work. The answer shape is a table of job, label, shape, rate, weekly minutes, and a recommendation column. The agent can produce the first five columns entirely from the workflow files plus the runner catalog. The recommendation column is a hypothesis, so confirm it against the observability recommendations view before you merge anything: sustained CPU at or above 80 percent marks a runner as under-provisioned, and consistently low utilization is the signal that a smaller label will hold. ### [The fleet these prompts describe](#the-fleet-these-prompts-describe)Copy Anchor Link The prompts above assume a workflow that fans a build across four platforms. This is the shape they read: Copy Text ``` name: build-and-test on: pull_request: push: branches: [main] jobs: unit: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test integration: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - run: docker compose -f docker-compose.ci.yml up -d - run: ./scripts/integration.sh ios: runs-on: warp-macos-latest-arm64-6x steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'platform=iOS Simulator,name=iPhone 16' test windows: runs-on: warp-windows-latest-x64-8x steps: - uses: actions/checkout@v4 - run: .\build.ps1 -Configuration Release ``` When prompt 3 reports that the `unit` job peaks at 35 percent CPU on an 8 vCPU shape, the change is one line: Copy Text ``` unit: runs-on: warp-ubuntu-latest-x64-4x ``` Run that on a branch for a few days before merging. Halving vCPU raises wall clock on anything that parallelizes well, and a job that stretches from 12 minutes to 26 has given back the rate difference. The observability view and the minute counts tell you which case you are in. ## [Configuration](#configuration)Copy Anchor Link ### [Generate the API key](#generate-the-api-key)Copy Anchor Link Go to [app.warpbuild.com/settings/api-keys](https://app.warpbuild.com/settings/api-keys), set a name, and check CI. Keys can be scoped to combinations of WarpBuild products, and the scopes available today are CI, Cache, and Helios. MCP work needs CI. Add Cache if the same key will drive cache endpoints in your automation scripts. The generated key is displayed only once and starts with the `wkey-` prefix, so copy it into your secret store at the moment it appears. Name and scope stay editable from the same page afterwards, which is also where you rotate a key that leaked. ### [Wire the MCP server](#wire-the-mcp-server)Copy Anchor Link The server URL is `https://mcp.warpbuild.com/mcp`. Configure your MCP client with: Copy Text ``` { "mcpServers": { "warpbuild": { "url": "https://mcp.warpbuild.com/mcp", "headers": { "Authorization": "Bearer wkey-xxxxxxxxxxxxxxxx" } } } } ``` Hosts differ in where that JSON lives. The [MCP support docs](/docs/ci/mcp) walk through Cursor: open the command palette, search for MCP settings, choose the option that opens MCP settings, click New MCP Server, and paste the `warpbuild` block into the JSON file that opens. Antigravity and other hosts that accept a remote server by URL take the same block. If your host expands environment variables inside its MCP configuration, reference the key through a variable rather than pasting it. Either way, keep the file with the raw key out of version control. ### [Verify the key before you debug the host](#verify-the-key-before-you-debug-the-host)Copy Anchor Link When a host reports a red server, it rarely says whether the key or the transport is at fault. Check the key on its own first: Copy Text ``` curl -X GET "https://api.warpbuild.com/api/v1/runners?only_custom_runners=true" \ -H "Accept: application/json" \ -H "Authorization: Bearer wkey-xxxxxxxxxxxxxxxx" ``` A JSON array back means the key and its scope are good, and the remaining problem is host configuration. An authorization error means the key is wrong or the CI scope is missing. The [API keys docs](/docs/ci/api-keys) carry the full request shapes for runners, runner images, and stacks if you want to script any of this outside an agent. ### [The catalog the agent resolves labels against](#the-catalog-the-agent-resolves-labels-against)Copy Anchor Link Prompt 3 needs a mapping from label to shape to rate. These are the labels that appear most often in the workflows an agent will read: | Label | OS | vCPU | RAM | Storage | Rate per minute | | ---------------------------- | ------------------- | ---- | ----- | --------- | --------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.012 | | warp-ubuntu-latest-arm64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.024 | | warp-macos-latest-arm64-6x | macOS 15 | 6 | 14 GB | 120GB SSD | $0.08 | | warp-macos-latest-arm64-12x | macOS 15 | 12 | 28 GB | 270GB SSD | $0.16 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64 GB | 256GB SSD | $0.064 | Rates are per minute in USD, verified on 2026-08-13 against the [WarpBuild pricing page](/pricing). ### [Where agents stop and Terraform starts](#where-agents-stop-and-terraform-starts)Copy Anchor Link An agent is good at exploration and at one-off creation: audit the fleet, stand up a runner for an experiment, answer a question during an incident. Long-lived fleet definitions want a file in a repository with a review on it. BYOC runs on AWS, GCP, and Azure. Terraform support exists for BYOC on AWS, so an AWS fleet can live in state files while the MCP server handles the questions and the throwaway runners. A reasonable split is Terraform for anything a second engineer needs to reproduce, and the agent for anything you would otherwise have done in the dashboard. ### [Access and support](#access-and-support)Copy Anchor Link Anyone who can mint a CI-scoped key can create and delete runners in your organization, so key issuance deserves the same review as cloud credentials. SSO is available for a flat $250 per month, whatever the user count, and the [pricing page](/pricing) is where that line lives. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Two numbers move when an agent takes over the loop: engineer minutes per investigation, and runner minutes per week once the investigation produces a change. Model them separately. ### [Engineer time](#engineer-time)Copy Anchor Link The manual loop above totals 32 minutes. The agent version is three prompts plus review of the answers, which we model at 6 minutes, since reading a table and sanity-checking a recommendation still takes attention. | Input | Value | | --------------------------------------- | ------------- | | Manual loop, per investigation | 32 minutes | | Agent loop, per investigation | 6 minutes | | Difference | 26 minutes | | Investigations per week (assumption) | 3 | | Reclaimed time per week | 78 minutes | | Reclaimed time per year | 67.6 hours | | Fully loaded engineer cost (assumption) | $120 per hour | | Annual value of reclaimed time | $8,112 | Both the cadence and the hourly rate are assumptions you should replace. The structural point survives either number: the loop is dominated by joining data across surfaces, and that join is the part an agent does at no marginal cost. ### [Runner minutes](#runner-minutes)Copy Anchor Link Assume a repository that runs 400 Linux jobs a week averaging 12 minutes each, which is 4,800 minutes. | Scenario | Rate per minute | Weekly | Annual | | -------------------------------------------- | --------------- | ------- | --------- | | warp-ubuntu-latest-x64-8x today | $0.016 | $76.80 | $3,993.60 | | warp-ubuntu-latest-x64-4x after right-sizing | $0.008 | $38.40 | $1,996.80 | | GitHub-hosted 8-core Linux larger runner | $0.022 | $105.60 | $5,491.20 | Arithmetic: 400 jobs multiplied by 12 minutes gives 4,800 minutes, and 4,800 multiplied by $0.016 gives $76.80. The right-sizing row is what prompt 3 pays for. Finding one oversized label on a busy workflow is worth $1,996.80 a year on this fleet, and the agent found it in a prompt rather than in a spreadsheet. The third row is the baseline the same minutes carry on GitHub-hosted larger runners. Against list prices: | WarpBuild runner | Shape | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | Difference | | --------------------------- | ------------- | -------------------- | ---------------------------------------------------------- | ----------------- | --------------------------- | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent lower list price | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent lower list price | | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | $0.012 | 8-core Linux ARM64 larger runner | $0.014 | 14 percent lower list price | | warp-windows-latest-x64-8x | 8 vCPU, 32 GB | $0.032 | 8-core Windows larger runner | $0.042 | 24 percent lower list price | | warp-macos-latest-arm64-6x | 6 vCPU, 14 GB | $0.08 | Largest GitHub-hosted macOS ARM64 runner, 5 vCPU and 14 GB | $0.102 | 22 percent lower list price | GitHub list prices come from [GitHub pricing](https://github.com/pricing) and the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. The macOS row compares against GitHub's nearest ARM64 macOS shape, which carries one fewer vCPU and 8 GB less RAM than the WarpBuild runner in the same row. Adding the two halves together for this example fleet: $8,112 of reclaimed engineer time on the assumptions above, plus $1,996.80 a year from the single label change the sweep surfaced. ### [What the MCP server itself costs](#what-the-mcp-server-itself-costs)Copy Anchor Link Nothing beyond the runner minutes you already pay for. If you want the broader picture the prompts feed into, read the [GitHub Actions observability guide](/solutions/github-actions-observability) for how the metrics are collected, [reading GitHub Actions runner metrics](/guides/github-actions-runner-metrics) for interpreting the utilization charts, and [reducing GitHub Actions costs](/guides/reduce-github-actions-costs) for the changes that follow a sweep. When a prompt tells you a job is failing rather than slow, [debugging GitHub Actions failures](/guides/debug-github-actions-failures) covers getting onto the runner itself. ## [FAQ](#faq)Copy Anchor Link **What is the WarpBuild MCP server URL?** The server is at `https://mcp.warpbuild.com/mcp`. Add it to your MCP host with an Authorization header of `Bearer `, using a key generated at [app.warpbuild.com/settings/api-keys](https://app.warpbuild.com/settings/api-keys) with the CI scope checked. **What can an agent do through the WarpBuild MCP server?** MCP can be used to interact with the WarpBuild API to create runners, create runner images, and query the API. **Which MCP hosts can connect to it?** Any host that supports a remote MCP server addressed by URL with custom headers. The WarpBuild docs walk through Cursor step by step, and name Antigravity as another host that connects the same way. **Is it safe to hand an agent a WarpBuild API key?** Scope the key to CI only, keep it in your host's secret storage rather than a committed file, and rotate it from the API keys page. A CI-scoped key can create and delete runners, so treat it as a production credential. --- # Pulling GitHub Actions Metrics Through the API | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-metrics-api Description: Pull GitHub Actions metrics from the WarpBuild API: CI billing, jobs, queue timings and daywise costs, with a wkey- key on the ci scope and CSV export. # Pulling GitHub Actions Metrics Through the API Pull GitHub Actions metrics from the WarpBuild API: CI billing, jobs, queue timings and daywise costs, with a wkey- key on the ci scope and CSV export. Last verified: 2026-08-13 GitHub Actions metrics are available over HTTP from the WarpBuild API at `https://api.warpbuild.com/api/v1`, using an API key that starts with `wkey-` and carries the `ci` scope, sent as an `Authorization: Bearer` header. Four read endpoints cover the ground most teams want: the CI billing report, the jobs report, the queue timings report, and daywise costs, each taking an RFC3339 start and end date, and the report endpoints also returning CSV. This guide covers why a dashboard runs out of road, which endpoint answers which question, how to create and scope the key, and how to turn one report pull into a weekly cost per repository with the arithmetic written out. ## [Diagnosis](#diagnosis)Copy Anchor Link A dashboard answers last week's question. It was designed around the groupings someone needed at the time, and it holds those groupings until somebody edits it. The question a team actually asks on a Monday morning is usually one grouping away from what the dashboard offers. Three shapes of question keep landing outside a fixed view. **Questions that need data the platform does not hold.** Cost per team, cost per service, cost per tier of customer. None of that lives in a GitHub Actions report, because team ownership lives in your service catalog or in a `CODEOWNERS` file. Answering it means pulling rows keyed by repository and job name and joining them to a table you own. **Questions with an odd window.** A dashboard tends to offer 7, 30, and 90 days. The real question is often "what did the two weeks after the test-sharding change cost compared with the two weeks before", which is two explicit RFC3339 windows and a subtraction. **Questions that should run without a human.** A weekly digest in Slack, a check that fails when a repository crosses a budget, a row appended to a warehouse table every night. Those need a scheduled pull rather than a browser tab. There is a second reason to pull rather than look. Reports render a page at a time, and the interesting analysis usually needs the whole set. The CI billing report caps `per_page` at 200 and the jobs and queue timings reports cap it at 50, so a full month of job rows is a paging loop in JSON or a single request with `format=csv`. One constraint to plan around before you write the client. Every operation in the WarpBuild API is annotated alpha, and every response carries an `X-WarpBuild-API-Stability` header. The endpoints and fields below are subject to change. Pin only to documented behavior, read the stability header in your client, and treat a schema surprise as expected maintenance rather than an outage. ## [Fix](#fix)Copy Anchor Link Four endpoints cover reporting. All of them take `start_date` and `end_date` as required RFC3339 timestamps. | Endpoint | Returns | Page size | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- | | GET /reports/billing/ci | Chart data, a summary, a paginated job list, and the available filters | per\_page default 50, max 200 | | GET /reports/jobs | Per job run count, success rate, and p75 and p90 for duration, queue time, CPU, and memory | per\_page default 10, max 50 | | GET /reports/queue-timings | Per runner and stack queue time p75 and p90, plus a daily chart splitting total queue time into GitHub time and WarpBuild time | per\_page default 10, max 50 | | GET /jobs/daywise-costs | One row per day with date, amount, and cumulative\_amount | Not paginated | Start with the CI billing report, which is the one that carries money. Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/reports/billing/ci' \ -H 'Authorization: Bearer wkey-xxxx' \ -H 'Accept: application/json' \ --data-urlencode 'start_date=2026-08-04T00:00:00Z' \ --data-urlencode 'end_date=2026-08-11T00:00:00Z' \ --data-urlencode 'chart_group_by=repo' \ --data-urlencode 'per_page=200' ``` `chart_group_by` accepts `repo` or `runner_label`. The filters are `repos`, `runner_labels`, `stack_ids`, `job_names`, `vcs_job_ids`, `run_ids`, and `snapshot`, plus a `search` parameter that matches repository, job name, and runner label. Sorting uses `sort_by` with `sort_order` set to `asc` or `desc`. Each row in `jobs.items` carries the fields the billing table renders. | Field | Meaning | | ------------------------------ | ----------------------------------------------------------------------------- | | repo, job\_name, runner\_label | The identity of the job and the runner size it landed on | | vcs\_job\_id, run\_id | The GitHub Actions job id and workflow run id, for joining to your own data | | execution\_time | Time the job spent running | | billed\_time | Time charged for the job, which is the figure to reconcile against an invoice | | runner\_cost | Runner charge for the row | | snapshot, snapshot\_cost | Whether the job used a snapshot runner, and the snapshot charge | | total\_cost | Runner cost plus snapshot cost | | stack, stack\_kind | The stack the job ran on, which separates hosted runners from BYOC | | timestamp | When the job ran | For a spreadsheet or a warehouse load, ask for CSV instead. The export contains every row matching the current filters rather than the visible page. Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/reports/billing/ci' \ -H 'Authorization: Bearer wkey-xxxx' \ --data-urlencode 'start_date=2026-08-04T00:00:00Z' \ --data-urlencode 'end_date=2026-08-11T00:00:00Z' \ --data-urlencode 'format=csv' \ -o ci-billing-2026-08-04.csv ``` The jobs report answers the timing half of the same window, aggregated per repository, workflow, and job name. Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/reports/jobs' \ -H 'Authorization: Bearer wkey-xxxx' \ --data-urlencode 'start_date=2026-08-04T00:00:00Z' \ --data-urlencode 'end_date=2026-08-11T00:00:00Z' \ --data-urlencode 'chart_metric=duration' \ --data-urlencode 'chart_percentile=p90' \ --data-urlencode 'sort_by=duration_p90' \ --data-urlencode 'sort_order=desc' \ --data-urlencode 'per_page=50' \ | jq -r '.table.items[] | [.repo, .workflow_name, .job_name, .run_count, .success_rate, .duration_p90, .queue_time_p90, .cpu_p90, .memory_p90] | @tsv' ``` When end to end pipeline time moves while `duration_p90` stays flat, the queue is where it went. The queue timings report aggregates per runner and stack, accepts a `categories` filter of `stock`, `custom-byoc`, or `custom-warp`, and returns a daily chart alongside the table. Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/reports/queue-timings' \ -H 'Authorization: Bearer wkey-xxxx' \ --data-urlencode 'start_date=2026-08-04T00:00:00Z' \ --data-urlencode 'end_date=2026-08-11T00:00:00Z' \ --data-urlencode 'categories=stock' \ --data-urlencode 'sort_by=queue_time_p90' \ | jq -r '.table.items[] | [.runner_label, .stack, .stack_kind, .run_count, .queue_time_p75, .queue_time_p90] | @tsv' ``` Daywise costs is the smallest response of the four and the easiest to graph. It returns an array of `{date, amount, cumulative_amount}`, which makes it the right endpoint for a burn-down against a monthly budget. Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/jobs/daywise-costs' \ -H 'Authorization: Bearer wkey-xxxx' \ --data-urlencode 'start_date=2026-08-01T00:00:00Z' \ --data-urlencode 'end_date=2026-09-01T00:00:00Z' \ | jq -r '.[] | [.date, .amount, .cumulative_amount] | @tsv' ``` One more endpoint is worth wiring into the same job. `GET /org_metrics/job_runner_recommendations` returns the jobs whose utilization crossed an upgrade or downgrade threshold, with the current label and the recommended label for each. Full request and response definitions for all of these live in the [API reference](/docs/api). ## [Configuration](#configuration)Copy Anchor Link Authentication is a single header. Create the key on the [API keys page](https://app.warpbuild.com/settings/api-keys) in dashboard settings and grant it the CI scope. API key scopes are CI, Cache, and Helios, and a key can carry any combination of them. The generated value starts with `wkey-` and is displayed only once, so write it into your secret manager in the same step that creates it. The [API keys documentation](/docs/ci/api-keys) covers creating, editing, and rotating keys. Send it as a bearer token on every request. Copy Text ``` Authorization: Bearer wkey-xxxx Accept: application/json ``` A weekly pull is a scheduled workflow, and the runner it needs is small. Copy Text ``` name: weekly-actions-cost-report on: schedule: - cron: "0 7 * * 1" workflow_dispatch: jobs: pull-reports: runs-on: warp-ubuntu-latest-x64-2x steps: - name: Compute the window id: window run: | echo "start=$(date -u -d '7 days ago' +%Y-%m-%dT00:00:00Z)" >> "$GITHUB_OUTPUT" echo "end=$(date -u +%Y-%m-%dT00:00:00Z)" >> "$GITHUB_OUTPUT" - name: Pull the CI billing report env: WARPBUILD_API_KEY: ${{ secrets.WARPBUILD_API_KEY }} START: ${{ steps.window.outputs.start }} END: ${{ steps.window.outputs.end }} run: | curl -sS -G 'https://api.warpbuild.com/api/v1/reports/billing/ci' \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ --data-urlencode "start_date=$START" \ --data-urlencode "end_date=$END" \ --data-urlencode 'format=csv' \ -o ci-billing.csv - name: Roll the rows up per repository run: | curl -sS -G 'https://api.warpbuild.com/api/v1/reports/billing/ci' \ -H "Authorization: Bearer ${{ secrets.WARPBUILD_API_KEY }}" \ --data-urlencode "start_date=${{ steps.window.outputs.start }}" \ --data-urlencode "end_date=${{ steps.window.outputs.end }}" \ --data-urlencode 'per_page=200' \ | jq -r '.jobs.items | group_by(.repo)[] | [ .[0].repo, length, (map(.billed_time) | add), (map(.total_cost) | add) ] | @tsv' | tee per-repo.tsv - uses: actions/upload-artifact@v4 with: name: actions-cost-report path: | ci-billing.csv per-repo.tsv ``` Three notes on running this in production. Walk `page` until `jobs.next` stops advancing when you take the JSON path, because a busy month exceeds 200 rows. Keep the window boundaries aligned to UTC midnight so two consecutive pulls neither overlap nor leave a gap. And log the `X-WarpBuild-API-Stability` header from each response, so an alpha change is visible in your own logs. The same key type drives the rest of the platform. Terraform support exists for BYOC on AWS through the [WarpBuild Terraform provider](/docs/ci/api-keys/terraform), and BYOC itself runs on AWS, GCP, and Azure, so runners inside your own cloud account report into the same endpoints as hosted ones. For an agent that reads these reports and proposes changes in a chat window, see the guide to the [WarpBuild MCP server](/guides/github-actions-mcp-server). Every one of them appears in the `runner_label` column of the billing rows. Access control around the key is worth a line. Keys are created per organization from dashboard settings, and SSO is available for a flat $250 per month, whatever the user count, if you want the dashboard behind your identity provider before you hand out key creation. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Turning a report pull into a cost per repository is arithmetic over two inputs: billed minutes per runner label, and the published per minute rate for that label. Rates below come from the WarpBuild [pricing page](/pricing), checked on 2026-08-13, and billing is per minute. | Runner label | vCPU | RAM | USD per minute | | --------------------------- | ---- | ----- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | | warp-macos-15-arm64-6x | 6 | 14 GB | $0.08 | | warp-windows-latest-x64-4x | 4 | 16 GB | $0.016 | Two line items sit next to runner minutes and show up in the same rows. | Metric | Price | | ---------------------- | ------------------------ | | Snapshot restore | $0.04 per job | | Snapshot storage | $0.025 per snapshot-hour | | Cache storage | $0.20 per GB-month | | Cache write or restore | $0.0001 per operation | **Assumptions.** The numbers below are an example pull for one repository over one week, of the shape the CI billing report returns. Rates are the published rates above. Costs use `billed_time` rather than `execution_time`, summed per runner label and converted to minutes, because billed time is what reconciles against an invoice. Queue time is excluded because it is not billed. **The pull.** One request for `acme/web` over 2026-08-04 to 2026-08-11, filtered with `repos=acme/web`, grouped by runner label: | Runner label | Jobs | Billed minutes | Rate | Cost | | ------------------------- | ---- | -------------- | ------ | ------ | | warp-ubuntu-latest-x64-4x | 412 | 2,680 | $0.008 | $21.44 | | warp-ubuntu-latest-x64-8x | 96 | 1,150 | $0.016 | $18.40 | | warp-macos-15-arm64-6x | 60 | 300 | $0.08 | $24.00 | **The arithmetic.** Runner cost is 2,680 x $0.008 = $21.44, plus 1,150 x $0.016 = $18.40, plus 300 x $0.08 = $24.00, giving $63.84 for the week. The 96 jobs on the 8x label restored a snapshot, which adds 96 x $0.04 = $3.84 in the `snapshot_cost` column. The weekly total for the repository is $63.84 + $3.84 = $67.68. Two derived numbers make that figure useful to someone outside the platform team. Against 84 pull requests merged in the same week, the repository costs $67.68 / 84 = $0.81 per merged pull request. Held flat, the weekly figure annualizes to $67.68 x 52 = $3,519.36, which is $293.28 a month. **The cross-check.** Run `GET /jobs/daywise-costs` over the same seven days and compare the last `cumulative_amount` against the sum of the per repository totals. Daywise costs is organization wide, so it should land at or above the single repository figure; if it lands below, the window boundaries are misaligned or a filter dropped rows. Repeat the per repository step for every row in the `group_by(.repo)` output and you have the weekly table. Joining that table to team ownership is the next step, covered in [attribute GitHub Actions cost to teams and repositories](/guides/github-actions-cost-attribution). If the duration percentiles beside the cost are what moved, [read build duration percentiles for GitHub Actions](/guides/github-actions-build-duration-percentiles) works the jobs report from the timing side, and [GitHub Actions observability with WarpBuild](/solutions/github-actions-observability) is the wider view of what the platform reports across an estate. ## [FAQ](#faq)Copy Anchor Link **Which API key scope do the metrics endpoints need?** The ci scope. Create the key on the API keys page in dashboard settings, grant it CI, and send it as an Authorization Bearer header. Keys start with wkey- and the value is displayed only once, so store it in your secret manager at creation time. The available scopes are CI, Cache, and Helios. **Is the WarpBuild API stable enough to build a report on?** Every operation is annotated alpha and every response carries an X-WarpBuild-API-Stability header. Expect breaking changes and pin only to documented behavior. Read the header in your client and log it, so a stability change shows up in your own logs before it shows up in a broken dashboard. **Can I get report data as CSV instead of JSON?** Yes. The reports endpoints accept format=csv, and the export contains every row matching the current filters rather than the visible page. JSON responses page instead, with per\_page capped at 200 on the CI billing report and 50 on the jobs and queue timings reports. **How do I join WarpBuild billing rows to GitHub Actions runs?** Each row in the CI billing report carries vcs\_job\_id and run\_id, which are the GitHub Actions job id and workflow run id. The same endpoint accepts vcs\_job\_ids and run\_ids as filters, so you can pull the cost of one workflow run by id after a pull request merges. --- # Setting a Minutes Budget for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-minutes-budget Description: A GitHub Actions minutes budget is jobs per month times billed minutes per job times the rate per label, bounded by labels, timeouts, concurrency, and paths. # Setting a Minutes Budget for GitHub Actions A GitHub Actions minutes budget is jobs per month times billed minutes per job times the rate per label, bounded by labels, timeouts, concurrency, and paths. Last verified: 2026-08-13 A GitHub Actions minutes budget is one row per runner label: jobs per month multiplied by billed minutes per job multiplied by the rate for that label. Four workflow controls hold that number in place once it is written down, and a scheduled pull of daywise costs tells you on day 12 of the month whether spend is on pace or already past it. This guide covers why a limit entered in billing settings leaves runner minutes unbounded, the three inputs the model needs and where each one comes from, the four controls that bound them, and a worked monthly budget with the arithmetic shown. ## [Diagnosis](#diagnosis)Copy Anchor Link Budgets usually fail for a structural reason rather than a discipline reason: the number is written somewhere that cannot stop a job. **A limit in billing settings covers what GitHub meters.** Jobs that run on self-hosted or third-party runner labels are not billed by GitHub, so they never draw down a spending limit configured there ([GitHub Actions billing documentation](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13). The [spending limit answer](/answers/can-i-set-a-spending-limit-on-github-actions-runners) works through what that leaves you. **The invoice arrives as a total, and the budget is per label.** A single monthly figure cannot be compared against a plan that assumed 1,200 lint jobs at two minutes each. Comparison needs the same granularity on both sides, which means the budget has to be written per label before the first invoice lands. **Run count moves faster than duration.** A matrix that grows from four legs to eight doubles the jobs on that label overnight, while dependency bots, nightly schedules, and re-runs add executions that no one attributes to a decision. Duration creeps by percentages; run count jumps by multiples of itself. **The default job timeout is 360 minutes.** GitHub cancels a job at that point unless the job sets `timeout-minutes` lower ([workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidtimeout-minutes), checked on 2026-08-13). One hung job on a macOS runner at $0.08 per minute bills $28.80 against a budget line that assumed 20 minutes. ## [Fix](#fix)Copy Anchor Link ### [Write the budget as one row per label](#write-the-budget-as-one-row-per-label)Copy Anchor Link The model has three inputs and no fourth. | Input | Where it comes from | | ---------------------- | ------------------------------------------------------------------------------------------------------------- | | Jobs per month | Run count in the Jobs section of [Reports](/docs/ci/features/reports), per repository, workflow, and job name | | Billed minutes per job | The billed time column in the CI tab of the Billing section, which sits beside execution time | | Rate per label | The [pricing page](/pricing) and the per-minute rate for the label in runs-on | Use billed time rather than execution time. Billing is per minute, so a 35 second job bills a full minute, and a fleet of short jobs is bounded by job count before it is bounded by duration. Every job records the label it ran on, so each budget row maps to a filter in the report that produced it. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the budget carries no fixed term and every dollar in it traces back to a job execution. ### [Bound each term with four workflow controls](#bound-each-term-with-four-workflow-controls)Copy Anchor Link Each control acts on one term of the multiplication. The last column shows what each one moved in the worked model below. | Control | Term it bounds | Ceiling it sets | Effect in the model | | ----------------------------------- | -------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------- | | Runner label in runs-on | Rate per minute | Fixed by the label, $0.003 to $0.128 per minute | $172.80 to $86.40 on the integration row if the size step is safe | | timeout-minutes | Minutes one job can bill | The value you set, or 360 minutes if you set nothing | Caps a hung macOS job at $2.40 instead of $28.80 | | concurrency with cancel-in-progress | Minutes spent on superseded runs | One run per group in flight | Returns $10.37 per month on the integration row | | paths filters | Jobs per month | Runs only on commits touching the listed paths | Takes the iOS row from $192.00 to $48.00 | Path filters move the most money because a run that is never created bills nothing at all, and `timeout-minutes` matters most on the expensive labels, where the 360 minute default is worth tens of dollars per incident. ### [Close the loop with a scheduled daywise cost pull](#close-the-loop-with-a-scheduled-daywise-cost-pull)Copy Anchor Link A budget that is checked once a month is checked once too late. A daily job compares the latest `cumulative_amount` against the budget prorated to the day of the month: Copy Text ``` pace ceiling = monthly budget x day of month / days in month ``` On a $500 budget in a 31 day month, day 12 allows $193.55\. Anything above that is a signal to look at run counts before the invoice does it for you. ## [Configuration](#configuration)Copy Anchor Link Two files. The first bounds the workflow, the second watches the number. Copy Text ``` name: ci on: pull_request: paths: - 'src/**' - 'package.json' push: branches: [main] concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: lint: runs-on: warp-ubuntu-latest-x64-2x timeout-minutes: 10 steps: - uses: actions/checkout@v4 - run: npm ci && npm run lint unit-tests: runs-on: warp-ubuntu-latest-x64-4x timeout-minutes: 20 steps: - uses: actions/checkout@v4 - run: npm ci && npm test integration-tests: runs-on: warp-ubuntu-latest-x64-8x timeout-minutes: 30 steps: - uses: actions/checkout@v4 - run: make integration ``` The iOS build belongs in its own workflow so that its `paths` filter is independent of the Linux jobs: Copy Text ``` name: ios on: pull_request: paths: - 'ios/**' jobs: ios-build: runs-on: warp-macos-latest-arm64-6x timeout-minutes: 30 steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build ``` The monitor runs on the smallest Linux label and needs an API key with the `ci` scope, created from the API keys page and sent as a bearer token, as described in the [automation documentation](/docs/ci/api-keys/automation). Copy Text ``` name: actions-budget-monitor on: schedule: - cron: '0 8 * * *' workflow_dispatch: jobs: check-pace: runs-on: warp-ubuntu-latest-x64-2x timeout-minutes: 5 env: MONTHLY_BUDGET_USD: '500' steps: - name: Compare month to date spend against pace run: | start=$(date -u +%Y-%m-01T00:00:00Z) end=$(date -u -d 'tomorrow' +%Y-%m-%dT00:00:00Z) curl -sS -G 'https://api.warpbuild.com/api/v1/jobs/daywise-costs' \ -H "Authorization: Bearer ${{ secrets.WARPBUILD_API_KEY }}" \ -H 'Accept: application/json' \ --data-urlencode "start_date=$start" \ --data-urlencode "end_date=$end" \ -o daywise.json spent=$(jq -r 'if length == 0 then 0 else .[-1].cumulative_amount end' daywise.json) day=$(date -u +%-d) days=$(date -u -d "$(date -u +%Y-%m-01) +1 month -1 day" +%-d) pace=$(awk -v b="$MONTHLY_BUDGET_USD" -v d="$day" -v n="$days" 'BEGIN { printf "%.2f", b * d / n }') echo "month to date $spent against pace ceiling $pace" awk -v s="$spent" -v p="$pace" 'BEGIN { exit (s > p) ? 1 : 0 }' ``` A failing scheduled run is the alert. Teams that want the number in a channel add a step that posts `$spent` and `$pace` to Slack before the comparison exits. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link A team merging around 300 pull requests a month, with three Linux jobs per pull request, an iOS build, and a Windows test job. Rates come from the [pricing page](/pricing), checked on 2026-08-13. | Job | Label | Jobs per month | Billed minutes | Minutes | Rate | Monthly | | -------------- | -------------------------- | -------------- | -------------- | ------- | ---------- | ------- | | lint | warp-ubuntu-latest-x64-2x | 1,200 | 2 | 2,400 | $0.004/min | $9.60 | | unit tests | warp-ubuntu-latest-x64-4x | 1,200 | 7 | 8,400 | $0.008/min | $67.20 | | integration | warp-ubuntu-latest-x64-8x | 900 | 12 | 10,800 | $0.016/min | $172.80 | | iOS build | warp-macos-latest-arm64-6x | 120 | 20 | 2,400 | $0.08/min | $192.00 | | Windows tests | warp-windows-latest-x64-4x | 150 | 8 | 1,200 | $0.016/min | $19.20 | | Runner minutes | 3,570 | 25,200 | $460.80 | | | | Cache storage at 30 GB-month adds $6.00 at $0.20 per GB-month, and 120,000 cache reads and writes add $12.00 at $0.0001 per operation, so the baseline budget is $478.80\. Round the ceiling to $500 and the pace formula above has its input. Now apply the controls. Gating the iOS workflow on `ios/**` drops it from 120 runs to the 30 that touch iOS code, taking that row from $192.00 to $48.00\. At 12 percent of pushes superseding a run in flight, and half the minutes burned before the cancel, `cancel-in-progress` returns 108 partial integration runs at 6 minutes each, which is 648 minutes and $10.37\. Both are arithmetic on the table above rather than a projection. | Line | Before | After | | -------------------------------------- | ------- | ------- | | iOS build, path filtered | $192.00 | $48.00 | | Integration, superseded runs cancelled | $172.80 | $162.43 | | Everything else | $96.00 | $96.00 | | Runner total | $460.80 | $306.43 | The label control is the one to check last, since it needs evidence. The Jobs section reports CPU and memory at the 75th and 90th percentile per job, and a job that never crosses half its cores is a candidate for the next size down: moving 10,800 integration minutes from `warp-ubuntu-latest-x64-8x` at $0.016 per minute to `warp-ubuntu-latest-x64-4x` at $0.008 per minute takes that row from $172.80 to $86.40, provided the wall clock holds. Price the same minutes against GitHub-hosted list prices to sanity check the budget. `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB), which is 33 percent lower list price, with the GitHub list price checked on 2026-08-13 at [docs.github.com](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The 8,400 unit test minutes cost $67.20 here and $100.80 at that list price. Signup includes $10 free credits, enough to run a week of real jobs through the billing report and replace the estimated minutes in the table with measured ones. The per-pull-request view of the same numbers is in [working out CI cost per pull request](/guides/ci-cost-per-pull-request), the input side is covered in [how to estimate a GitHub Actions bill](/answers/how-do-i-estimate-my-github-actions-bill), and the levers that move a row once the budget is breached are in [cutting GitHub Actions costs](/guides/reduce-github-actions-costs). ## [FAQ](#faq)Copy Anchor Link ### [What inputs does a GitHub Actions minutes budget need?](#what-inputs-does-a-github-actions-minutes-budget-need)Copy Anchor Link Three per runner label: jobs per month, billed minutes per job, and the rate for that label. Run count comes from the Jobs section of [Reports](/docs/ci/features/reports), billed time comes from the CI tab of the Billing section, where billed time and execution time are separate columns, and the rate comes from the [pricing page](/pricing). Multiply the three and sum the labels, then add the cache storage and snapshot lines, which bill alongside minutes. ### [Which workflow controls actually cap the number?](#which-workflow-controls-actually-cap-the-number)Copy Anchor Link Label choice fixes the rate of every billed minute, `timeout-minutes` caps what one job can bill, `concurrency` with `cancel-in-progress` stops runs that a newer push has superseded, and `paths` filters stop runs from being created at all. Path filters move the largest amount of money because a run that never starts bills nothing, and `timeout-minutes` matters most on expensive labels, where the default ceiling is 360 minutes. ### [How do I know mid-month that the budget is drifting?](#how-do-i-know-mid-month-that-the-budget-is-drifting)Copy Anchor Link Pull `GET /jobs/daywise-costs` on a schedule. It returns one row per day with `date`, `amount`, and `cumulative_amount`, and it is not paginated, so a daily workflow can compare the latest `cumulative_amount` against the monthly budget prorated to the day of the month and fail when spend runs ahead of pace. The key needs the `ci` scope. --- # Network Timeouts in GitHub Actions Jobs | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-network-timeouts Description: A GitHub Actions job that times out downloading dependencies is hitting a registry rate limit, a DNS failure, or a slow mirror. Read the log signature first. # Network Timeouts in GitHub Actions Jobs A GitHub Actions job that times out downloading dependencies is hitting a registry rate limit, a DNS failure, or a slow mirror. Read the log signature first. A GitHub Actions job that stalls or fails while downloading dependencies is hitting one of three things: a registry rate limit, a transient DNS failure, or a slow package mirror, and each one leaves a different string in the log. Read the signature first, then apply the matching fix, which is a bounded retry around the fetch step for the transient cases and a warm cache or a prepared machine image so the fetch stops happening at all. This guide covers the three log signatures, the retry that is worth keeping in a workflow, the client timeout settings per package manager, and a model that prices the wasted minutes. ## [Diagnosis](#diagnosis)Copy Anchor Link Separate the three sources before changing anything, because a retry helps two of them and hides the third. | Source | Log signature | What confirms it | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | Registry rate limit | 429 Too Many Requests from npm or PyPI, toomanyrequests: You have reached your pull rate limit from a container registry, 503 from a Maven mirror | The response arrives fast and carries a status code. Failures cluster by registry across unrelated repositories and clear after a wait | | Transient DNS failure | getaddrinfo EAI\_AGAIN registry.npmjs.org, Temporary failure in name resolution, dial tcp: lookup ...: no such host, Could not resolve host | Several unrelated jobs go red inside the same few minutes, then everything recovers. The next attempt seconds later succeeds | | Slow package mirror | ETIMEDOUT or ESOCKETTIMEDOUT from npm, Read timed out. (read timeout=15) from pip, Connection timed out \[IP: ...\] from apt | No status code appears. The step runs for the length of the client timeout and the byte count in the log stops growing before it ends | **Registry rate limits** are the only one of the three that answers you. A status code in the log means the remote host is reachable, resolved, and refusing the request on purpose, so the fix is spacing the requests out, authenticating the pull, or pulling from a copy you control. The [guide to GitHub Actions rate limits](/guides/github-actions-rate-limits) covers which limits apply per registry and how to read the headers they return. **Transient DNS failures** cluster in time rather than by registry. The tell is that a job fails on one hostname while an unrelated job fails on a different hostname in the same window. These are worth a retry with a short wait, because the resolution path changes between attempts and the second attempt usually succeeds. **Slow package mirrors** are the case people misread. Nothing errors, so the step holds the runner while throughput drops toward zero, and the job ends only when a client timeout fires or when the job hits its own limit. A job with no `timeout-minutes` runs to GitHub's default of 360 minutes ([workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)), so an unbounded hang bills six hours of runner time for a download that was never going to finish. Two checks separate a genuine network problem from a runner problem. Turn on [debug logging](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) and look for whether any bytes moved before the stall. Then confirm the job actually started on a runner: a job that never picks up a runner because of repository access or runner group restrictions looks like a hang from the outside, and those cases are in the [WarpBuild common issues documentation](/docs/ci/common-issues). CI observability, the Action Debugger, and the WarpBuild MCP server all read the same job history, so the clustering question can be answered from a report rather than by opening runs one at a time. ## [Fix](#fix)Copy Anchor Link Apply these in order. The first two bound the damage, and the third removes the failure mode. **1\. Bound every step that talks to a registry.** Put `timeout-minutes` on the job and a tighter value on each fetch step. This converts a six-hour hang into a four-minute failure and costs nothing when the network behaves. **2\. Keep the retry on the fetch step.** A registry pull, a package install, and an artifact download are worth two or three attempts with a doubling wait. Retrying at that level costs the attempt plus the backoff; a whole-run re-run replays checkout, install, build, and test. The step-level and job-level options are compared in [how to retry a failed step in GitHub Actions](/answers/how-do-i-retry-a-failed-step-in-github-actions). Keep the retry off the build and test steps, because an assertion failure repeats on every attempt. **3\. Remove the fetch.** Removing the download removes the timeout with it. Two mechanisms do this, and they cover different state. * A cache action moves named paths. Replacing `actions/cache@v4` with `WarpBuilds/cache@v1` is a one-line change, and the [WarpBuild setup actions](/docs/ci/features/caching) for Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise wire the same storage in with no key management at all. A warm entry turns a registry round trip into a local restore, so the registry stops being in the critical path on most runs. * A [snapshot runner](/runners/snapshot) captures the whole runner VM mid-workflow, and later jobs boot from that image with dependency trees, pulled container images, and prior build outputs already on disk. Nothing is fetched at job start, which is the case a cache action handles worst. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners; the same idea for named paths is covered in [persistent caches for GitHub Actions runs](/guides/persistent-caches-github-actions). Neither mechanism removes the first fetch. The cache still misses when a lockfile changes, and the snapshot is still built from a run that downloaded everything once, so keep the bounded retry from step 2 in place for those runs. ## [Configuration](#configuration)Copy Anchor Link Client defaults decide how long a stalled connection holds the runner. Set them explicitly on the steps that fetch. | Tool | Timeout setting | Retry setting | | ---- | ------------------------------------------------------------- | ----------------------------- | | npm | npm config set fetch-timeout 120000 (milliseconds) | fetch-retries, default 2 | | pip | pip install --timeout 60 (default read timeout is 15 seconds) | \--retries, default 5 | | apt | Acquire::http::Timeout "30"; in an apt.conf.d file | Acquire::Retries "3"; | | curl | \--connect-timeout 10 --max-time 300 | \--retry 3 --retry-all-errors | | git | http.lowSpeedLimit 1000 with http.lowSpeedTime 30 | none; wrap the clone instead | Turn the client retries off when an outer loop is doing the retrying, so the attempt count stays predictable and the backoff is the one you wrote. The workflow below wraps only the two steps that talk to a remote host. Build and test carry a job timeout and no retry. Copy Text ``` name: pr-build on: pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x timeout-minutes: 25 steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - name: Install dependencies shell: bash timeout-minutes: 8 run: | set -euo pipefail npm config set fetch-timeout 120000 npm config set fetch-retries 0 attempt=1 max_attempts=3 delay=10 until npm ci; do if [ "$attempt" -ge "$max_attempts" ]; then echo "npm ci failed after $attempt attempts" exit 1 fi echo "attempt $attempt failed, retrying in ${delay}s" sleep "$delay" attempt=$((attempt + 1)) delay=$((delay * 2)) done - name: Pull test fixtures image shell: bash timeout-minutes: 4 run: | set -euo pipefail for attempt in 1 2 3; do docker pull ghcr.io/acme/test-fixtures:2026-08 && exit 0 sleep $((attempt * 10)) done echo "image pull failed after 3 attempts" exit 1 - name: Build run: npm run build - name: Test run: npm test ``` Four properties make this shape safe to leave in place. The attempt cap exits non-zero after the last try, so a permanent failure still turns the job red. The wait doubles from 10 to 20 seconds, which spreads the attempts across a rate-limit window instead of stacking three requests inside 30 seconds. `timeout-minutes` on each fetch step bounds the loop below the job limit. And the `cache: npm` line on the setup action means most runs restore the dependency tree and never enter the loop at all. Platform placement decides what is available. WarpBuild caching is not supported on Windows runners, so a Windows job in the same workflow keeps its existing setup steps and relies on the bounded retry alone. Cache entries expire 7 days after last use, which is why a repository with a quiet week pays the cold fetch again on Monday. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Take one pull request gate: 800 runs a month, one build job billing 12 minutes, on `warp-ubuntu-latest-x64-8x` at $0.016 per minute from the [WarpBuild pricing page](/pricing), checked on 2026-08-13\. Baseline is 9,600 billed minutes, or $153.60\. Assume 5 percent of runs, so 40 a month, hit a network failure about 4 minutes into the job. | Posture | Extra minutes per incident | Incidents per month | Extra minutes | Extra cost | | ---------------------------------------------- | ----------------------------------------------------------- | ------------------- | ------------- | ---------- | | No retry, developer re-runs the whole workflow | 4 wasted plus a 12 minute re-run, so 16 | 40 | 640 | $10.24 | | Bounded retry on the fetch steps only | 2 attempts of 40 seconds plus 30 seconds of waiting, so 1.8 | 40 | 72 | $1.15 | | Blind retry around an 8 minute test step | 2 x (8 + 0.25), so 16.5 | 60 red runs | 990 | $15.84 | The third row is the trap. Wrapping every step in three attempts costs more than the failures it absorbs, and it converts a flaky test into a green run, which removes it from the success-rate ranking that would have found it. Now price the structural fix. A cold `npm ci` against the registry takes 100 seconds on this repository; a warm cache restore takes 15 seconds, so each run drops 1.42 minutes. | Line | Monthly effect | | ----------------------------------------------- | -------------- | | Fetch minutes removed, 1,136 at $0.016 | $18.18 | | Re-run minutes removed, 640 at $0.016 | $10.24 | | Cache storage added, 2 GB at $0.20 per GB-month | \-$0.40 | | Cache operations added, 1,600 at $0.0001 | \-$0.16 | | Net | $27.86 | The dollar column is the smaller half. The same change removes 1,776 minutes of wall clock a month from runs a developer is watching, and it removes the incident class rather than absorbing it, because the registry is no longer in the critical path on a cache hit. Where the state is the machine rather than a set of paths, price a snapshot instead. A snapshot restore is $0.04 per job, which is 2.5 minutes at $0.016 per minute, so a snapshot pays when it removes more than 2.5 minutes of setup per run on this size. Snapshot storage is $0.025 per snapshot-hour, or $18.00 a month per alias held continuously. For the order in which this sits against runner sizing, job splitting, and caching work, see the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [Why does my GitHub Actions job time out while downloading dependencies?](#why-does-my-github-actions-job-time-out-while-downloading-dependencies)Copy Anchor Link Three sources cover almost every case. A registry rate limit returns HTTP 429 or a `toomanyrequests` message and clears on its own. A transient DNS failure prints `EAI_AGAIN` or a name resolution error and clears in seconds. A slow package mirror never returns an error at all; the client sits on an open connection until its own read timeout fires, so the step ends at whatever timeout the tool defaults to. ### [Should I retry the whole job or only the fetch step?](#should-i-retry-the-whole-job-or-only-the-fetch-step)Copy Anchor Link Only the fetch step. A whole-job re-run replays checkout, install, build, and test, so one failed download bills the full job a second time. A bounded retry around the install or pull step costs the attempt plus the backoff wait, which is under two minutes in most workflows against 12 or more minutes for a re-run. ### [How long can a hung download bill before GitHub stops it?](#how-long-can-a-hung-download-bill-before-github-stops-it)Copy Anchor Link A job with no `timeout-minutes` set runs to GitHub's default of 360 minutes, and every one of those minutes bills. Set `timeout-minutes` on the job and a tighter one on each step that talks to a registry, so a hung connection is killed in minutes rather than hours. --- # When a GitHub Actions Job Runs Out of Memory | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-out-of-memory Description: Exit code 137 means the kernel out-of-memory killer stopped the job. Read the log signature, cap workers and heaps, then price the runner memory ladder. # When a GitHub Actions Job Runs Out of Memory Exit code 137 means the kernel out-of-memory killer stopped the job. Read the log signature, cap workers and heaps, then price the runner memory ladder. Last verified: 2026-08-13 A GitHub Actions job that ends with `Process completed with exit code 137` was killed by the Linux out-of-memory killer, which delivers SIGKILL and leaves no stack trace behind. Fix it in workflow order first, by capping parallel test workers, setting explicit heap limits, and batching tests across jobs, and move up the runner memory ladder only when a single worker no longer fits the machine. This guide covers the log signature that separates an out-of-memory kill from an ordinary crash, the arithmetic that turns worker counts and heap flags into a memory budget, the Linux runner ladder with the rate at each step, and a monthly model that prices the workflow-side fix against buying a larger machine. ## [Diagnosis](#diagnosis)Copy Anchor Link Exit codes carry the first signal. A shell reports a process that died on a signal as 128 plus the signal number, so SIGKILL (signal 9) surfaces as 137 and SIGTERM (signal 15) surfaces as 143, per [signal(7)](https://man7.org/linux/man-pages/man7/signal.7.html). An ordinary crash exits with a small code such as 1 and prints an error before it goes. | What you see | Where it appears | What it means | | ------------------------------------------------------ | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Process completed with exit code 137 | Last line of the failed step | The process received SIGKILL. With no cancellation in flight, the kernel out-of-memory killer is the usual sender | | Out of memory: Killed process 4213 (node) total-vm:... | dmesg on the runner | Kernel confirmation, naming the process and its resident size at the moment of the kill | | FATAL ERROR: Ineffective mark-compacts near heap limit | Job log, above a V8 stack trace | Node reached its own old-space ceiling while the machine may still have had free RAM ([Node CLI options](https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-mib)) | | java.lang.OutOfMemoryError: Java heap space | Job log, above a Java stack trace | The JVM hit its \-Xmx ceiling, which is a heap setting rather than a machine size | | Process completed with exit code 143 | Last line of the failed step | SIGTERM, which comes from a cancellation or a timeout rather than the out-of-memory killer | Three properties separate the kernel kill from every other failure. The output stops mid-line, because the process never got a chance to flush its buffer. No application-level error precedes it. And the failure moves between test files on reruns of the same commit, because which process the kernel picks depends on the moment memory ran out. Container jobs add a second killer. When a step runs inside a container with a memory limit, the cgroup enforces that limit before the machine runs dry, and the kill is counted in `memory.events` under `oom_kill` ([cgroup v2 documentation](https://docs.kernel.org/admin-guide/cgroup-v2.html)). Read that file in the same job to tell a container limit apart from a machine limit. GitHub Actions reports none of this. The job object in the [workflow jobs REST API](https://docs.github.com/en/rest/actions/workflow-jobs) carries timestamps, steps, and a conclusion, with no memory field. WarpBuild agents collect CPU, memory, filesystem, and network utilization from every runner they operate, and the Recommendations view flags any instance at or above 80 percent max memory utilization as High Memory Usage, per the [observability documentation](/docs/ci/features/observability). Two collection details shape what you read: metrics cover jobs longer than about one minute, and the reading is machine-wide, so a service container in the same job lands in the same number. [Runner level metrics for GitHub Actions jobs](/guides/github-actions-runner-metrics) walks those reports field by field, and [can I alert when a runner runs out of memory](/answers/can-i-alert-when-a-runner-runs-out-of-memory) covers turning the threshold into a notification. ## [Fix](#fix)Copy Anchor Link Work through the workflow-side levers before you touch the label. Worker count multiplies peak memory while the rate per minute stays flat, so it is the cheapest lever on the list. ### [1\. Measure one worker, then budget the machine](#1-measure-one-worker-then-budget-the-machine)Copy Anchor Link Run the suite with a single worker and record peak resident memory with `/usr/bin/time -v`. That gives the per-worker figure the whole budget rests on. Copy Text ``` workers <= (machine RAM - base overhead) / peak worker RSS ``` Reserve about 4GB of the machine for the runner agent, service containers, and the page cache that keeps dependency reads off disk. On a 32GB runner with a worker peaking at 3.5GB, the budget allows `(32 - 4) / 3.5 = 8` workers. Raise the worker peak to 6GB and the same machine allows `(32 - 4) / 6 = 4.6`, so four workers is the ceiling and eight workers is the reason the job died. ### [2\. Set explicit heaps](#2-set-explicit-heaps)Copy Anchor Link Defaults are sized for the machine rather than for the number of processes sharing it. The JVM sets its maximum heap to one quarter of physical memory when nothing overrides it ([HotSpot ergonomics](https://docs.oracle.com/en/java/javase/21/gctuning/ergonomics.html)), which permits four unbounded forks to commit an entire 32GB runner. Pin `-Xmx` per fork, pin `NODE_OPTIONS=--max-old-space-size` in MiB for Node, and the sum becomes something you can check against the budget above. ### [3\. Batch the tests across jobs](#3-batch-the-tests-across-jobs)Copy Anchor Link Splitting one job with eight workers into four sharded jobs with two workers each cuts peak memory per machine by a factor of four while the shards run at the same time. Jest exposes `--shard`, pytest-xdist exposes `-n` with `--dist loadfile`, and Gradle exposes `maxParallelForks`; [sizing runners for Java and Gradle builds](/guides/sizing-runners-for-java-builds) carries the Gradle arithmetic in full. ### [4\. Then price the ladder](#4-then-price-the-ladder)Copy Anchor Link The Linux x64 ladder holds a fixed 4GB per vCPU, so each step doubles both RAM and the rate. | Runner label | vCPU | RAM | Rate per minute | | -------------------------- | ---- | ----- | --------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | $0.064 | Rates come from the [pricing page](/pricing) and bill per minute. The Linux ARM64 ladder carries the same shapes from `warp-ubuntu-latest-arm64-2x` at $0.003 per minute to `warp-ubuntu-latest-arm64-32x` at $0.048\. Windows runs 16GB at `warp-windows-latest-x64-4x` up to 128GB at `warp-windows-latest-x64-32x`, and macOS offers 14GB at `warp-macos-latest-arm64-6x` and 28GB at `warp-macos-latest-arm64-12x`. Above 128GB on Linux the ladder ends, so a job that still cannot fit has to shard. [Right sizing GitHub Actions runners](/guides/runner-right-sizing-github-actions) covers the recommendation endpoint that flags exactly that case. ## [Configuration](#configuration)Copy Anchor Link Cap the workers, pin the heap, and leave a diagnostic step in place so the next kill is readable rather than mysterious. Copy Text ``` name: tests on: pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-8x env: NODE_OPTIONS: --max-old-space-size=3072 steps: - uses: actions/checkout@v4 - run: npm ci - run: npx jest --maxWorkers=6 --workerIdleMemoryLimit=2048 - name: Report out-of-memory kills if: always() run: | sudo dmesg -T | grep -iE "out of memory|killed process" \ || echo "no kernel oom kills recorded" cat /sys/fs/cgroup/memory.events 2>/dev/null || true ``` `--workerIdleMemoryLimit` restarts a worker once it crosses the limit, which contains the leak-shaped growth that kills long suites ([Jest CLI reference](https://jestjs.io/docs/cli)). The `always()` step keeps the kernel evidence attached to the failed run. When one job cannot hold the whole suite, shard it and keep each shard inside the budget. Copy Text ``` jobs: unit-tests: strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] runs-on: warp-ubuntu-latest-x64-8x env: NODE_OPTIONS: --max-old-space-size=3072 steps: - uses: actions/checkout@v4 - run: npm ci - run: npx jest --shard=${{ matrix.shard }}/4 --maxWorkers=2 ``` Only the job whose single worker exceeds the machine gets the larger label, and the label is the whole change. Runner labels are documented in the [cloud runners reference](/docs/ci/cloud-runners). Copy Text ``` integration-tests: runs-on: warp-ubuntu-latest-x64-16x steps: - uses: actions/checkout@v4 - run: pytest -n 4 --dist loadfile ``` ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Substitute your own numbers. This fleet runs one test job family for a month on `warp-ubuntu-latest-x64-16x` at $0.032 per minute, and 15 percent of attempts die at minute 9 with exit code 137 and get rerun. | Line | Attempts | Minutes each | Minutes | Cost | | --------------------------- | -------- | ------------ | --------- | ------- | | Clean runs | 2,550 | 12 | 30,600 | $979.20 | | Attempts killed at minute 9 | 450 | 9 | 4,050 | $129.60 | | Reruns of those attempts | 450 | 12 | 5,400 | $172.80 | | Total | 3,450 | 40,050 | $1,281.60 | | The $129.60 line buys nothing, and the 450 red runs cost engineer attention that the invoice never shows. Two fixes, priced against that baseline. The first caps workers from eight to six and pins the Node heap, keeping the same label; assume the job stretches to 12.9 minutes and the kills go to zero. Three thousand runs at 12.9 minutes is 38,700 minutes, or $1,238.40, which is $43.20 under the baseline with 450 fewer failures. The second doubles the machine instead: 3,000 runs at 12 minutes on `warp-ubuntu-latest-x64-32x` is 36,000 minutes at $0.064, or $2,304.00\. Doubling the rate to solve a worker-count problem is the expensive path, and it is worth taking only when the measured single-worker peak needs the extra RAM. Replace the 12.9 with a measured duration after a week of runs before you commit either. Both options price better against GitHub-hosted list prices for identical machine shapes. `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price, GitHub list price checked on 2026-08-13 in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The capped-worker option costs $1,238.40 here against $1,625.40 at that list price. `warp-ubuntu-latest-x64-32x` (32 vCPU, 128 GB) costs $0.064 per minute against $0.082 per minute for the 32-core Linux larger runner (32 vCPU, 128 GB): 22 percent lower list price on the same checked date, so the upsize option costs $2,304.00 here against $2,952.00. The memory readings this guide uses come from CI observability. For the other levers that shorten the same jobs, see [speed up GitHub Actions](/guides/speed-up-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [What does exit code 137 mean in a GitHub Actions job?](#what-does-exit-code-137-mean-in-a-github-actions-job)Copy Anchor Link The process was killed by SIGKILL, because a shell reports a signalled death as 128 plus the signal number and SIGKILL is signal 9\. When no cancellation was in flight, the sender is almost always the kernel out-of-memory killer, and the step output stops mid-line because the process never got to flush it. ### [How do I tell a kernel out-of-memory kill from an application heap error?](#how-do-i-tell-a-kernel-out-of-memory-kill-from-an-application-heap-error)Copy Anchor Link An application heap error prints its own message and a stack trace, then exits with a small code such as 1\. `java.lang.OutOfMemoryError: Java heap space` and the V8 `Allocation failed` message both belong to that group, and the machine often still has free RAM. A kernel kill prints nothing in the job log, ends at exit code 137, and shows up in `dmesg` as a `Killed process` line naming the process and its resident size. ### [Should I move to a bigger runner or cap parallelism first?](#should-i-move-to-a-bigger-runner-or-cap-parallelism-first)Copy Anchor Link Cap parallelism first, because worker count multiplies peak memory while the rate per minute stays flat. Moving from `warp-ubuntu-latest-x64-8x` to `warp-ubuntu-latest-x64-16x` doubles RAM from 32GB to 64GB and doubles the rate from $0.016 to $0.032 per minute, so it is worth doing once the measured peak of a single worker no longer fits the machine. --- # GitHub Actions Queue Times and How to Fix Them | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-queue-times Description: GitHub Actions queue time is the wait between a job being queued and a runner claiming it. Trace it to concurrency caps, labels, or pool capacity. # GitHub Actions Queue Times and How to Fix Them GitHub Actions queue time is the wait between a job being queued and a runner claiming it. Trace it to concurrency caps, labels, or pool capacity. Last verified: 2026-08-13 GitHub Actions queue time is the gap between the moment a job enters the queue and the moment a runner picks it up, and long waits trace to five causes: account concurrency ceilings, runner group misconfiguration, label mismatches, saturated self-hosted pools, and matrix fan-out beyond available capacity. WarpBuild removes the capacity causes by provisioning a fresh runner for every job, and its Queue Timings report measures the wait per runner label so you can confirm the fix. This guide walks through each cause, the fix that matches it, the configuration to measure queue wait per runner label and stack, and a model that converts queue minutes per week into engineer hours. ## [Diagnosis](#diagnosis)Copy Anchor Link Queue time is easy to miss because GitHub records it and then bills you nothing for it. The workflow run page shows when a job was queued and when it started, and the difference is the wait. In the API, each job object carries `created_at` and `started_at` timestamps, so `started_at` minus `created_at` is the queue wait for that job. Most teams only notice the number when someone watches a pull request check sit at "Waiting for a runner". The five causes below cover nearly every long queue. ### [Account-level concurrency ceilings](#account-level-concurrency-ceilings)Copy Anchor Link Every GitHub plan caps how many jobs can run at the same time on GitHub-hosted runners, and the cap applies across the whole account. When the account hits its ceiling, each additional job queues, whatever repository it belongs to and however much capacity GitHub has free. macOS jobs have a separate, much lower ceiling on every plan, which is why iOS pipelines are usually the first to stack up. The per-plan numbers are listed in [GitHub's usage limits documentation](https://docs.github.com/en/actions/administering-github-actions/usage-limits-billing-and-administration) (checked on 2026-08-13). The practical symptom: a busy release day in one repository pushes every other team's checks into the queue. ### [Runner group misconfiguration](#runner-group-misconfiguration)Copy Anchor Link Runner groups control which repositories and workflows may use a set of runners. If a repository is missing from the group's allowed list, its jobs wait for a runner that will never claim them. Workflow restrictions add a second filter: a group can be pinned to specific workflow paths, branches, or tags, for example `cd.yaml` on `main` only, and a job from any other workflow queues silently. WarpBuild documents the checks for both cases in the [common issues guide](/docs/ci/common-issues), including where to verify group access in the GitHub organization settings. ### [Label mismatch leaving jobs unclaimed](#label-mismatch-leaving-jobs-unclaimed)Copy Anchor Link A job runs only on a runner that carries every label in its `runs-on` value. A typo in one label, or a label that no registered runner advertises, leaves the job unclaimed. The symptom is distinctive: the job stays queued indefinitely and the log view stays empty, because no runner ever accepted it. Capacity problems look different, since those jobs do start once a runner frees up. A job that sits for hours rather than minutes points to labels, so check the spelling against the [runner catalog](/docs/ci/cloud-runners) before anything else. ### [Self-hosted pool saturation](#self-hosted-pool-saturation)Copy Anchor Link A self-hosted pool has a fixed number of registered runners. Jobs beyond that number wait until a runner frees up, so queue time grows with load: quiet mornings look fine and the 5 pm merge rush does not. Autoscaled pools built on Actions Runner Controller shrink the problem but keep a floor of scale-up latency, because a new runner needs a pod scheduled, an image pulled, and a registration completed before it can claim its first job. Every one of those steps lands in the queue-time column. ### [Matrix fan-out exceeding available capacity](#matrix-fan-out-exceeding-available-capacity)Copy Anchor Link A matrix multiplies one job definition into many jobs, and all of them want a runner at the same moment. A 16-way test matrix on a pool of 4 self-hosted runners executes in 4 waves: the first 4 shards start immediately, the next 4 queue for one full shard duration, and the last 4 wait through 3\. Average queue time across the matrix climbs even though the pool is running flat out. The wall clock for the suite becomes the shard duration multiplied by the number of waves, which defeats the point of sharding in the first place. ## [Fix](#fix)Copy Anchor Link Start by separating never-starts from starts-late. A job that stays queued with an empty log view points to labels or runner groups. A job that starts after minutes of waiting points to capacity. For label mismatches, copy the label string from the [runner catalog](/docs/ci/cloud-runners) rather than typing it. WarpBuild labels encode operating system, architecture, and size in one string, for example `warp-ubuntu-latest-x64-8x` for 8 vCPU on Ubuntu 24.04, so a single wrong character produces a label that nothing advertises. For runner group problems, work through the two checks in the common issues guide: confirm the group allows the repository, then confirm workflow restrictions match the workflows you actually run. The three capacity causes share one fix: stop making jobs share a fixed pool. Provisions an ephemeral virtual machine for each job at the moment the job queues. There is no pool to size and no autoscaler to tune. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That applies to generally available features on Linux and Windows runners; if your use case needs high concurrency on macOS runners, contact [support@warpbuild.com](mailto:support@warpbuild.com). Because each runner is created for one job and destroyed afterward, a 16-way matrix gets 16 machines and a 100-job release day gets 100\. Queue waits caused by account ceilings, pool saturation, or matrix bursts drop to the provisioning time of a fresh machine. The Queue Timings report gives you the per-label numbers to verify it on your own workflows. Two boundary notes. If "concurrency" means three different things in your incident channel, the guide to [GitHub Actions concurrency limits](/guides/github-actions-concurrency-limits) separates the workflow-level `concurrency` key, account ceilings, and pool capacity. And once jobs start promptly and the build itself becomes the bottleneck, continue with the guide to [speeding up GitHub Actions builds](/guides/speed-up-github-actions), or share the short version from [why are GitHub Actions slow](/answers/why-are-github-actions-slow). ## [Configuration](#configuration)Copy Anchor Link ### [Measure queue wait per runner label](#measure-queue-wait-per-runner-label)Copy Anchor Link The Queue Timings report in the WarpBuild dashboard breaks queue wait down per runner label and stack. The daily chart shows average queue time and total job count per day over a selected date range. The table shows one row per runner label and stack combination, with run count, queue time P75, and queue time P90, and it filters by runner label and stack. CSV export includes every row matching the current filters, so the data drops straight into a spreadsheet. The Jobs report adds per-job context. For each repository, workflow, and job name combination it tracks duration and queue time at P75 and P90 over time, which makes a queue regression visible as a line bending upward on the time-series chart. CPU and memory percentiles appear in the same table when CI observability is enabled on the runners. The full reference is in the [reports documentation](/docs/ci/features/reports), and the guide to [runner metrics](/guides/github-actions-runner-metrics) covers which of these numbers deserve a weekly look. ### [Pull the numbers from the API](#pull-the-numbers-from-the-api)Copy Anchor Link The Queue Timings report is the `GetQueueTimings` operation, served at `GET /reports/queue-timings`: Copy Text ``` curl -G "https://api.warpbuild.com/api/v1/reports/queue-timings" \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ --data-urlencode "start_date=2026-08-01T00:00:00Z" \ --data-urlencode "end_date=2026-08-13T00:00:00Z" \ --data-urlencode "runner_labels=warp-ubuntu-latest-x64-8x" \ --data-urlencode "sort_by=queue_time_p90" ``` The response aggregates queue time per runner label and stack at P75 and P90 and includes a daily series that splits total queue time into GitHub time and WarpBuild time, so you can see which side of the handoff the wait comes from. Add `format=csv` for the spreadsheet version, or narrow the result with the `runner_labels`, `stack_ids`, and `categories` filters. ### [Fan a matrix out on warp- labels](#fan-a-matrix-out-on-warp--labels)Copy Anchor Link On a fixed pool, a matrix serializes. With four registered self-hosted runners, the eight shards below run in two waves, and shards five through eight log a queue time equal to one full shard duration: Copy Text ``` jobs: tests: runs-on: [self-hosted, linux, x64] strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - uses: actions/checkout@v4 - run: npm ci - run: npx jest --shard=${{ matrix.shard }}/8 ``` The same matrix on a WarpBuild label fans out fully. Each shard gets its own machine with 8 vCPU, 32GB of memory, and a 150GB SSD, provisioned when the job queues: Copy Text ``` jobs: tests: runs-on: warp-ubuntu-latest-x64-8x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npx jest --shard=${{ matrix.shard }}/8 ``` The change that matters is the `runs-on` line. All eight shards start together, the wall clock for the suite falls to one shard duration plus provisioning, and the queue-time column in the report flattens. Run it for a week, then compare P90 queue time per label before and after in the Queue Timings report. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Queue minutes into engineer hours](#queue-minutes-into-engineer-hours)Copy Anchor Link Queue minutes are unbilled, which makes them easy to ignore and expensive to keep. The model below converts them into engineer wait. Substitute your own numbers from the Queue Timings report. Assumptions: * A team of 25 engineers. * 6 workflow runs per engineer per working day, 5 working days per week: 750 runs per week. * Average queue wait of 4 minutes per run, taken as the P75 from the report. * 1 run in 3 gates a person who is actively waiting, such as a pull request check before review or merge. The arithmetic: 750 runs multiplied by 4 minutes is 3,000 queue minutes per week. One third of that is actively watched: 1,000 minutes, or roughly 17 engineer hours per week. At a loaded cost of $100 per engineer hour, queue wait costs this team about $1,700 per week, or about $88,000 per year, before counting the context switches that follow every stalled check. ### [Runner minutes at list price](#runner-minutes-at-list-price)Copy Anchor Link Moving jobs onto per-job runners changes the billed side too. GitHub-hosted runners bill per minute at public list prices, published in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) (checked on 2026-08-13). WarpBuild per-minute rates for the matching vCPU counts are on the [pricing page](/pricing). | vCPU | GitHub-hosted Linux rate per minute | WarpBuild Linux x64 label | WarpBuild rate per minute | | ---- | ----------------------------------- | -------------------------- | ------------------------- | | 2 | $0.006 | warp-ubuntu-latest-x64-2x | $0.004 | | 4 | $0.012 | warp-ubuntu-latest-x64-4x | $0.008 | | 8 | $0.022 | warp-ubuntu-latest-x64-8x | $0.016 | | 16 | $0.042 | warp-ubuntu-latest-x64-16x | $0.032 | | 32 | $0.082 | warp-ubuntu-latest-x64-32x | $0.064 | A worked example using the eight-shard matrix above. Assume each shard runs 6 minutes and the workflow runs 1,000 times per month: * Runner minutes: 8 shards x 6 minutes x 1,000 runs = 48,000 minutes per month. * GitHub-hosted 8 vCPU Linux: 48,000 x $0.022 = $1,056 per month. * warp-ubuntu-latest-x64-8x: 48,000 x $0.016 = $768 per month. * Computed difference: $288 per month at this volume. That arithmetic holds the shard duration constant on both sides. ## [FAQ](#faq)Copy Anchor Link ### [Does GitHub Actions queue time count as billable minutes?](#does-github-actions-queue-time-count-as-billable-minutes)Copy Anchor Link No. Billing starts when the job begins running, so queued minutes cost nothing on the invoice and surface as engineer wait instead. The time model on this page converts that wait into hours and dollars. ### [Why does my job sit at Waiting for a runner with an empty log?](#why-does-my-job-sit-at-waiting-for-a-runner-with-an-empty-log)Copy Anchor Link Either the `runs-on` labels match no online runner or a runner group excludes the repository or workflow. Check the label spelling against the [runner catalog](/docs/ci/cloud-runners) first, then verify group access and workflow restrictions. ### [Do WarpBuild runners have concurrency limits?](#do-warpbuild-runners-have-concurrency-limits)Copy Anchor Link Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That covers generally available features on Linux and Windows runners. ### [How do I measure queue time per runner label?](#how-do-i-measure-queue-time-per-runner-label)Copy Anchor Link The Queue Timings report shows P75 and P90 queue wait per runner label and stack, with CSV export. The same data comes from the `GetQueueTimings` API operation at `GET /reports/queue-timings`. --- # Hitting API Rate Limits Inside Workflows | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-rate-limits Description: GitHub API rate limits inside GitHub Actions: which token owns the hourly budget, how to read the x-ratelimit headers, back off safely, and cut calls. # Hitting API Rate Limits Inside Workflows GitHub API rate limits inside GitHub Actions: which token owns the hourly budget, how to read the x-ratelimit headers, back off safely, and cut calls. A GitHub API call made inside a workflow is charged to whichever credential the step sent, and each credential carries its own hourly budget: 1,000 REST requests per hour per repository for `GITHUB_TOKEN`, 5,000 per hour for a personal access token, and 60 per hour for a call with no credential at all ([rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api), checked on 2026-08-13). When a budget runs out, GitHub answers 403 or 429 with `x-ratelimit-remaining: 0` and an `x-ratelimit-reset` timestamp, so the working fix is to read those headers, wait or exit on purpose, and then reduce the number of calls the job makes. This guide covers which identity a call is attributed to and how that changes the limit that applies, workflow YAML that reads the headers and backs off instead of failing the job, the structural fixes that keep runs under the budget, and what the two wasteful designs cost per month on real runner rates. ## [Diagnosis](#diagnosis)Copy Anchor Link Start by separating the four failures that all surface as a red step. | What you see | What it is | Where to look | | -------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------- | | 403 or 429, x-ratelimit-remaining: 0 | Primary rate limit for that credential and resource | x-ratelimit-reset, the UTC epoch second the window resets | | 403 or 429 with a retry-after header | Secondary rate limit, such as too many concurrent requests | The retry-after value, in seconds | | 403 with budget remaining and no retry-after | Permissions. The token cannot do that thing | The permissions: block and the token's scopes | | 404 on a repository you can see in a browser | The token cannot see the repository | The token's repository access list | Every REST response carries `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-used`, `x-ratelimit-reset`, and `x-ratelimit-resource`, which names the budget the call drew from ([rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api), checked on 2026-08-13). GraphQL is tracked as its own resource with a points budget rather than a request count, and `GET /rate_limit` reports every resource at once without drawing down any of them. ### [Which identity the call is attributed to](#which-identity-the-call-is-attributed-to)Copy Anchor Link The limit that applies is a property of the credential, so a step that swaps `GITHUB_TOKEN` for a personal access token moves the whole job onto a different budget. | Credential the step sends | What GitHub attributes it to | Documented primary REST budget | | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | secrets.GITHUB\_TOKEN or github.token | An installation token scoped to the repository running the workflow | 1,000 requests per hour per repository, and 15,000 per hour per repository for repositories owned by a GitHub Enterprise Cloud account | | A personal access token stored as a secret | The user who minted it, pooled across every workflow, every repository, and that person's laptop | 5,000 requests per hour | | A GitHub App installation token from actions/create-github-app-token | The installation | 5,000 requests per hour, scaling with organization size to a documented ceiling of 12,500 per hour | | No credential, such as a plain curl https://api.github.com/... | The source IP address | 60 requests per hour | Budgets and attribution come from [rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api) and [automatic token authentication](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication), both checked on 2026-08-13\. Two consequences catch teams out. A personal access token shared across an organization is one 5,000 per hour pool for everyone using it, so an unrelated team's backfill script can starve your pull request checks; the [personal access token entry](/glossary/personal-access-token) covers the token type on its own, and [GITHUB\_TOKEN](/glossary/github-token) covers the per-job alternative. And an action that reaches `api.github.com` without a token lands on the 60 per hour per IP budget, which a runner shares with everything else behind the same egress address. Secondary limits sit on top of all of this and no header budget predicts them. GitHub documents a cap of 100 concurrent requests across REST and GraphQL, and a cap on content-creating requests such as posting comments, at 80 per minute and 500 per hour ([rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api), checked on 2026-08-13). A 20-way matrix that fans out API calls at the same instant trips the concurrency cap while the hourly budget still reads healthy. ## [Fix](#fix)Copy Anchor Link **Send the narrowest credential that works.** `GITHUB_TOKEN` for anything inside the current repository, a GitHub App installation token when the job crosses a repository boundary, and a personal access token only when nothing else fits. A GitHub App installation also starts from a larger budget than a repository-scoped `GITHUB_TOKEN` and expires within the hour. **Never call the API without a credential.** A step that fetches a release asset or resolves a tool version anonymously is spending the 60 per hour per IP budget. Pass `${{ secrets.GITHUB_TOKEN }}` to setup actions that accept a `token` input, and add the `Authorization` header to raw `curl` calls. **Stop fetching what the checkout already has.** The single largest source of avoidable calls is reading file contents over the API in a job that has already run `actions/checkout`. Read from the working copy. **Batch instead of looping.** Ask for `--per-page 100` on every paginated list, and replace a loop of per-object REST calls with one GraphQL query that returns the same fields. **Cache responses within the run.** Repository, team, and membership objects do not change during a six minute job. `gh api --cache 10m` stores the response on the runner and serves repeats from disk without a network call. **Bound the backoff.** Waiting is billed. A cap turns an unbounded sleep into a known worst case, and a job that exits at the cap is cheaper than one that sleeps out the window. Where the job runs does not change any of this. GitHub mints `GITHUB_TOKEN` per job and attributes the call to the credential the step sent, so the budget is identical on GitHub-hosted runners, on ARC, and on managed runners. The token behavior above is the same on all four. Build secrets stay in your repository rather than with the runner provider, and each runner runs in its own virtual machine that is created on demand and destroyed after the build ([security documentation](/docs/ci/security)). Work that talks to WarpBuild rather than to GitHub draws on a different budget entirely. warpbuild.com\` and leaves the repository's GitHub budget untouched ([automation documentation](/docs/ci/api-keys/automation)). ## [Configuration](#configuration)Copy Anchor Link A preflight step prints the budget before the job spends it, and `GET /rate_limit` costs nothing against any resource. Copy Text ``` name: pr-audit on: pull_request: jobs: audit: runs-on: warp-ubuntu-latest-x64-2x permissions: contents: read pull-requests: write steps: - uses: actions/checkout@v4 - name: Report the budget before spending it env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh api rate_limit \ --jq '.resources.core | "core remaining=\(.remaining) of \(.limit), resets at \(.reset)"' ``` The audit step itself wraps every call in a function that reads the headers and decides between sleeping, exiting, and failing fast. Copy Text ``` - name: Walk the pull request with bounded backoff env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR: ${{ github.event.pull_request.number }} MAX_WAIT_SECONDS: "900" run: | waited=0 header() { awk -v key="$1" -F': ' \ 'tolower($1)==key { gsub(/\r/, "", $2); print $2 }' headers.txt } github_api() { local path="$1" while :; do code=$(curl -sS -o body.json -D headers.txt -w '%{http_code}' \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ "https://api.github.com${path}") remaining=$(header x-ratelimit-remaining) reset=$(header x-ratelimit-reset) retry_after=$(header retry-after) if [ "$code" = "200" ]; then cat body.json; return 0; fi if [ "$code" != "403" ] && [ "$code" != "429" ]; then echo "::error::${path} returned ${code}"; return 1 fi if [ -n "$retry_after" ]; then nap="$retry_after" elif [ "${remaining:-1}" = "0" ]; then nap=$(( reset - $(date -u +%s) + 1 )) else echo "::error::403 on ${path} with budget left, so this is permissions" return 1 fi if [ "$nap" -lt 1 ]; then nap=1; fi waited=$(( waited + nap )) if [ "$waited" -gt "$MAX_WAIT_SECONDS" ]; then echo "::error::backoff would exceed ${MAX_WAIT_SECONDS}s, exiting rather than billing idle minutes" return 1 fi echo "::notice::rate limited on ${path}, sleeping ${nap}s" sleep "$nap" done } github_api "/repos/${GITHUB_REPOSITORY}/pulls/${PR}/files?per_page=100" > files.json ``` Three decisions in that function matter more than the shell around them. A `retry-after` header means a secondary limit, so the value is used directly. A missing `retry-after` with `x-ratelimit-remaining` at zero means a primary limit, so the sleep is computed from `x-ratelimit-reset` and the current UTC second. A 403 with budget remaining is a permissions failure and returns immediately, because retrying it can only spend minutes. The read-only lookups that repeat inside the same run go through the client cache instead: Copy Text ``` - name: Resolve owners once per run env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh api --cache 10m "/repos/${GITHUB_REPOSITORY}" --jq '.default_branch' gh api --cache 10m "/orgs/${GITHUB_REPOSITORY_OWNER}/teams" --jq '.[].slug' ``` ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Backoff is billed at the runner rate, so the design choice has a monthly number attached to it. ### [Assumptions](#assumptions)Copy Anchor Link | Input | Value | Source | | --------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Repositories running the audit workflow | 6 | Your workflow inventory | | Workflow runs per repository per hour | 4 | Pull request activity | | REST calls per run | 260 | The run log of the job above | | Budget with GITHUB\_TOKEN | 1,000 requests per hour per repository | [GitHub rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api), checked 2026-08-13 | | Job length to the tripping call | 5 minutes, of a 6 minute job | The run log | | Runner rate | $0.004 per minute on warp-ubuntu-latest-x64-2x | [pricing page](/pricing), checked 2026-08-13 | Four runs at 260 calls is 1,040 calls per repository per hour against a 1,000 budget, so the fourth run of each hour trips 221 calls into its work. That is one run in four. Runs per repository per month are 4 x 24 x 30 = 2,880, so six repositories run 17,280 times and 4,320 of those runs trip the limit. ### [Three designs, same workflow](#three-designs-same-workflow)Copy Anchor Link | Design | Extra billed minutes per tripped run | Extra minutes per month | Extra cost per month | | --------------------------------------------------- | ------------------------------------ | ----------------------- | -------------------- | | Fail at the tripping call, rerun the job by hand | 11 | 47,520 | $190.08 | | Sleep until x-ratelimit-reset, capped at 15 minutes | 10 | 43,200 | $172.80 | | Batch and cache so each run makes 18 calls | 0 | 0 | $0 | Arithmetic for the first row: 5 billed minutes to reach the failing call plus a 6 minute rerun is 11 minutes, times 4,320 tripped runs is 47,520 minutes, times $0.004 is $190.08\. For the second row: the fourth run of each hour reaches the tripping call about 10 minutes before the window resets, so 4,320 x 10 = 43,200 minutes, times $0.004 is $172.80\. The cap earns its place on the tail rather than the average, because a burst that lands early in a window can otherwise sleep out most of an hour. ### [Where the 260 calls go](#where-the-260-calls-go)Copy Anchor Link | Call pattern in the run | Before | After | Change | | ---------------------------------------------------------------- | ------ | ----- | ------------------------------------------------- | | Paginating the pull request file list at the default 30 per page | 7 | 2 | per\_page=100 | | Fetching each changed file's contents over the API | 180 | 0 | Read them from the actions/checkout working copy | | Re-reading the repository, team, and membership objects per file | 60 | 3 | gh api --cache 10m, one lookup per object per run | | Posting review comments | 13 | 13 | Unchanged | | Total | 260 | 18 | | At 18 calls, four runs spend 72 calls per repository per hour against the 1,000 budget, and no run trips. The 13 comment posts across four runs are 52 content-creating requests per hour, inside the documented 500 per hour secondary cap. ### [Size the waiting job down](#size-the-waiting-job-down)Copy Anchor Link A job that sleeps on a reset window does no work while it waits, so the label it runs on is a pure multiplier on wasted spend. | Label | vCPU | RAM | Rate per minute | | --------------------------- | ---- | ----- | --------------- | | warp-ubuntu-latest-arm64-2x | 2 | 8 GB | $0.003 | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | Rates come from the [pricing page](/pricing), checked on 2026-08-13\. The 43,200 idle minutes in the second design cost $172.80 a month on `warp-ubuntu-latest-x64-2x` and $345.60 a month on `warp-ubuntu-latest-x64-4x`, for a job whose busiest instruction is `sleep`. If the same jobs are also stalling on the network rather than on a budget, [diagnose network timeouts in GitHub Actions](/guides/github-actions-network-timeouts) covers that path, dispatch calls across repositories are counted in [triggering workflows across repositories](/guides/cross-repository-workflows-github-actions), and the wider pipeline view is in [speeding up GitHub Actions](/guides/speed-up-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [Which rate limit applies to GITHUB\_TOKEN in a workflow?](#which-rate-limit-applies-to-github%5Ftoken-in-a-workflow)Copy Anchor Link `GITHUB_TOKEN` is an installation token scoped to the repository whose workflow is running, and GitHub documents its REST budget as 1,000 requests per hour per repository, rising to 15,000 requests per hour per repository for repositories owned by a GitHub Enterprise Cloud account ([rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api), checked on 2026-08-13). Every job in every workflow in that repository draws on the same hourly budget, so a 20-way matrix shares one pool. ### [How do I tell a rate limit 403 from a permissions 403?](#how-do-i-tell-a-rate-limit-403-from-a-permissions-403)Copy Anchor Link Read the headers. A primary rate limit answers 403 or 429 with `x-ratelimit-remaining` at 0 and `x-ratelimit-reset` carrying the UTC epoch second the window resets. A secondary rate limit answers 403 or 429 with a `retry-after` header in seconds. A 403 with budget remaining and no `retry-after` is a permissions problem, and retrying it burns minutes without ever succeeding, which is why the function in the configuration section returns immediately on that case. ### [Does moving to managed runners change the rate limit?](#does-moving-to-managed-runners-change-the-rate-limit)Copy Anchor Link No. GitHub mints `GITHUB_TOKEN` per job and attributes the call to whichever credential the step sent, so the budget is the same whether the job runs on GitHub-hosted runners, on ARC, or on WarpBuild runners. What changes is what an idle backoff costs per minute, which is a runner rate question rather than a rate limit question. --- # Using Runner Groups with GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-runner-groups Description: A runner group decides which repositories and workflows may use a set of runners. Jobs that never start are usually blocked by group access, rarely by capacity. # Using Runner Groups with GitHub Actions A runner group decides which repositories and workflows may use a set of runners. Jobs that never start are usually blocked by group access, rarely by capacity. Last verified: 2026-08-13 A GitHub Actions runner group is the access boundary around a set of self-hosted runners: it decides which repositories may use those runners and, optionally, which workflow files, branches, tags, and commit SHAs may claim them. When a job sits at "Waiting for a runner" with an empty log and never starts, a runner group restriction is the usual cause, and the fix is a setting in GitHub organization settings rather than more runner capacity. This guide separates a group block from a capacity wait, gives the troubleshooting checklist in the order the causes actually occur, shows where WarpBuild runners register and how workflow restrictions interact with a real workflow file, and puts a number on what a misconfigured group costs while it goes unnoticed. ## [Diagnosis](#diagnosis)Copy Anchor Link ### [Two failures that look the same on the Actions tab](#two-failures-that-look-the-same-on-the-actions-tab)Copy Anchor Link A job that starts late was queued and then claimed. Its log fills in, the run completes, and the wait shows up as the gap between `created_at` and `started_at` on the job object in the GitHub API. That is a capacity problem, and it responds to adding runners. A job that never starts stays queued with an empty log view. No runner ever accepts it, so there is nothing to write to the log. Cancel it an hour later and the log is still empty. That shape points at a runner group or at a label. Duration is the tell. Capacity waits drain as other jobs finish, so they resolve in minutes. A group block persists, because the passage of time changes nothing about whether the repository appears on the group's allowed list. If a job is still queued after every other job in the organization has finished, capacity is the wrong place to look. WarpBuild provisions a fresh virtual machine for each job at the moment the job queues, so there is no pool to exhaust. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. A queued WarpBuild job that never starts is therefore an access problem in almost every case, and the checklist below finds it. ### [The checklist, most likely cause first](#the-checklist-most-likely-cause-first)Copy Anchor Link Work these top to bottom. They are ordered so the causes that turn out to be the real one most often come first, and each check takes under a minute. 1. **The GitHub connection does not include the repository.** The WarpBuild bot serves the repositories granted to the connection. A repository added after onboarding is the single most common miss. 2. **The repository is public and public repositories are disallowed on the group.** GitHub disables self-hosted runners in public repositories by default, so this blocks an entire class of repositories at once and looks like a total outage in open-source repositories. 3. **The group's repository access list excludes the repository.** This applies when the organization points WarpBuild at a group other than the default one. 4. **A workflow restriction on the group excludes this workflow.** The subtlest cause, because most workflows in the repository keep working while one file, branch, or tag stays blocked. 5. **The `runs-on` label matches no registered runner.** A typo produces the identical symptom with no group involvement at all, so rule it out before opening a support thread. The first four are group problems. The fifth is not, and it is worth checking last only because the first four are faster to verify from a single browser tab. ## [Fix](#fix)Copy Anchor Link ### [1\. Confirm the repository is connected](#1-confirm-the-repository-is-connected)Copy Anchor Link Open `https://app.warpbuild.com/settings/account`, click Configure Runners on the GitHub connection, and read the repository list. Add the repository if it is missing. The connection must list a repository before runners can serve it. ### [2\. Allow public repositories](#2-allow-public-repositories)Copy Anchor Link WarpBuild registers itself as a self-hosted runner in the Default runner group (id 1) for your GitHub organization. GitHub disables the use of self-hosted runners, including managed ones, in public repositories by default. Until that setting changes, every job in every public repository in the organization queues and never starts. Go to `https://github.com/organizations/[YOUR_ORG]/settings/actions/runner-groups/1` and check the `Allow public repositories` box. GitHub recommends keeping self-hosted runners off public repositories because a pull request from an outside contributor can run arbitrary code on shared infrastructure. WarpBuild runs each workflow inside an isolated virtual machine that is destroyed when the job ends, which is why enabling the setting is safe here. The [public repositories documentation](/docs/ci/public-repos) has the screenshot and the GitHub Enterprise note. If the billing question comes up in the same conversation, [are GitHub Actions free for open source](/answers/are-github-actions-free-for-open-source) covers the free-minutes rules for public repositories. ### [3\. Check repository access on the group](#3-check-repository-access-on-the-group)Copy Anchor Link This step applies when your organization points WarpBuild at a group other than the default. Confirm which group that is at `https://app.warpbuild.com/ci` under Runners, then open GitHub Organization Settings, Actions, Runner groups, and select the same group. Check the repository access list there. A group set to selected repositories blocks every repository that was never added to it. ### [4\. Check workflow restrictions](#4-check-workflow-restrictions)Copy Anchor Link GitHub can pin a runner group to specific workflows. Each restriction is written as a full workflow path with a ref: Copy Text ``` monalisa/octocat/.github/workflows/cd.yaml@refs/heads/main ``` That entry allows `cd.yaml` on `main` and nothing else. GitHub supports restricting by workflow path, branch, sha, and tag, and each entry is an allow-list rule, so adding the first entry excludes every workflow you did not list. A pull request check defined in `ci.yaml` queues silently, and so does the same `cd.yaml` when it runs on a release tag instead of `main`. The [common issues guide](/docs/ci/common-issues) documents both the group access check and the workflow restriction check with screenshots of the GitHub settings pages. ### [5\. Rule out the label](#5-rule-out-the-label)Copy Anchor Link Copy the label from the runner catalog rather than typing it. WarpBuild labels encode operating system, architecture, and size in one string, so a single wrong character produces a label that no runner advertises. [How do I change the runner in a GitHub Actions workflow](/answers/how-do-i-change-the-runner-in-a-github-actions-workflow) covers the `runs-on` edit itself, and [runner group](/glossary/runner-group) holds the plain definition if someone on the thread needs it. If all five checks pass and jobs still queue, mail [support@warpbuild.com](mailto:support@warpbuild.com). ## [Configuration](#configuration)Copy Anchor Link ### [Where runners register](#where-runners-register)Copy Anchor Link WarpBuild runners join the Default runner group (id 1) unless the organization selects a different group in the WarpBuild dashboard under Runners. GitHub Enterprise supports creating multiple runner groups, so enterprise organizations are the ones most likely to run a non-default group and hit check 3 above. BYOC runs on AWS, GCP, and Azure, and BYOC runners register into the group the same way, so every check on this page applies to runners in your own cloud account too. ### [A workflow that exercises the restriction](#a-workflow-that-exercises-the-restriction)Copy Anchor Link The workflow below lives at `.github/workflows/ci.yaml` and uses three of them: Copy Text ``` name: ci on: pull_request: push: branches: [main] jobs: test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - name: Print runner identity run: echo "name=$RUNNER_NAME os=$RUNNER_OS arch=$RUNNER_ARCH" - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test package: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - run: ./scripts/package.sh deploy: needs: [test, package] if: github.ref == 'refs/heads/main' runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./scripts/deploy.sh ``` Now apply the restriction from step 4 to the group these runners belong to. The entry names `cd.yaml`, and this file is `ci.yaml`, so all three jobs queue and none of them start. The `Print runner identity` step is the cheap probe: when it never appears in the log, the job was never claimed, and the cause is access rather than anything inside the workflow. The three labels in that workflow, plus the macOS and Windows labels a release pipeline usually adds, with the shapes and per-minute rates published on the [pricing page](/pricing), checked on 2026-08-13: | Runner label | OS | vCPU | RAM | Storage | Rate per minute | | --------------------------- | ------------------- | ---- | ----- | --------- | --------------- | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.012 | | warp-macos-latest-arm64-6x | macOS 15 | 6 | 14 GB | 120GB SSD | $0.08 | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16 GB | 256GB SSD | $0.016 | A group restriction applies to every label in the group at once. Splitting a workflow across Linux, macOS, and Windows labels does nothing to route around it, which is why a matrix that fails on all legs at once is a strong signal for the group rather than for any one image. Once jobs start reliably, the remaining wait is ordinary queue time. [GitHub Actions queue times and how to fix them](/guides/github-actions-queue-times) covers measuring it per runner label. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [What a blocked group costs on day one](#what-a-blocked-group-costs-on-day-one)Copy Anchor Link Blocked jobs bill nothing, which is exactly why they stay unnoticed. The cost lands on engineers instead. Substitute your own repository count and run volume. Assumptions: * An organization with 6 public repositories, each averaging 8 workflow runs per working day. * The `Allow public repositories` box is unchecked, so every one of those runs queues without starting. * Each blocked run holds up one engineer for 20 minutes: the wait on a pull request check, then the time spent working out why nothing is happening. * A loaded engineer cost of $100 per hour. The arithmetic: 6 repositories multiplied by 8 runs is 48 blocked runs on day one. 48 runs multiplied by 20 minutes is 960 minutes, or 16 engineer hours. At $100 per hour that is $1,600 for a single day, and the fix is one checkbox. A workflow restriction costs less per day and lasts longer. It blocks one workflow file while the rest of the repository behaves normally, so the failure reads as flakiness in that one pipeline and can survive several sprints before anyone connects it to the group. ### [What the same workflow costs to run](#what-the-same-workflow-costs-to-run)Copy Anchor Link Once the group grants access, the workflow above bills at the rates in the table. Assume the same 6 repositories at 8 runs per working day over 21 working days, rounded to 1,000 runs per month, with the `test` job at 6 minutes, `package` at 4 minutes, and `deploy` at 2 minutes: * `test` on warp-ubuntu-latest-x64-8x: 1,000 runs x 6 minutes x $0.016 = $96 per month. * `package` on warp-ubuntu-latest-arm64-8x: 1,000 runs x 4 minutes x $0.012 = $48 per month. * `deploy` on warp-ubuntu-latest-x64-4x: 1,000 runs x 2 minutes x $0.008 = $16 per month. * Total: $160 per month. Set that against the model above. A full month of compute for this workflow costs $160, and one day of a runner group that blocks the same repositories costs about $1,600 in engineer time. Access configuration is worth an hour of attention during onboarding. ## [FAQ](#faq)Copy Anchor Link ### [Which runner group do WarpBuild runners join?](#which-runner-group-do-warpbuild-runners-join)Copy Anchor Link WarpBuild registers itself as a self-hosted runner in the Default runner group (id `1`) of your GitHub organization. If your organization uses a different group, select it in the WarpBuild dashboard under Runners, then confirm that group grants access to the repository. ### [Why do my jobs never start in a public repository?](#why-do-my-jobs-never-start-in-a-public-repository)Copy Anchor Link GitHub disables self-hosted runners in public repositories by default, including managed ones. Open the organization runner group settings page for group id `1` and check the `Allow public repositories` box. Every job in every public repository queues until that box is checked. ### [Can a runner group restrict which workflows use it?](#can-a-runner-group-restrict-which-workflows-use-it)Copy Anchor Link Yes. GitHub restricts a group by workflow path, branch, sha, and tag. A restriction is written as a full path with a ref, for example `monalisa/octocat/.github/workflows/cd.yaml@refs/heads/main`, which allows only `cd.yaml` on `main` and queues every other workflow. ### [How do I tell a runner group block from a capacity problem?](#how-do-i-tell-a-runner-group-block-from-a-capacity-problem)Copy Anchor Link A group block leaves the job queued with an empty log for as long as you let it sit, because no runner ever claims it. A capacity wait resolves once other jobs finish. Generally available WarpBuild Linux and Windows runners do not have plan-level concurrency caps, so check group access first when one of those jobs never starts. --- # Runner Level Metrics for GitHub Actions Jobs | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-runner-metrics Description: GitHub Actions reports job duration and nothing about the machine. Read per instance CPU, memory, disk and queue metrics in WarpBuild, then right size. # Runner Level Metrics for GitHub Actions Jobs GitHub Actions reports job duration and nothing about the machine. Read per instance CPU, memory, disk and queue metrics in WarpBuild, then right size. Last verified: 2026-08-13 GitHub Actions tells you how long a job took and nothing about what the machine was doing while it ran, so CPU and memory numbers have to come from an agent on the runner. WarpBuild runs that agent on every runner it operates, collects CPU, memory, filesystem, and network utilization over OpenTelemetry, and rolls the results up per repository, workflow, job, and instance type so you can point at the resource that is actually holding the job back. This guide covers what job-level timing hides, how to read the per instance metrics in the Observability Usage view and the right sizing suggestions in the Recommendations view, how to pull the same numbers from the API, and how to price a runner size change before you make it. ## [Diagnosis](#diagnosis)Copy Anchor Link A GitHub Actions job that takes 18 minutes gives you one number. That number is the sum of at least four separate things, and each one has a different fix. **CPU saturation.** The job is core bound. Sustained CPU sits near 100 percent for most of the run, the compiler or the test runner is already using every core, and the only lever that moves the clock is more cores. This is the case where a bigger runner size actually returns time. **Memory pressure.** The job is allocating close to the ceiling. Symptoms show up as test workers dying without a stack trace, a linker that gets killed, or a build that slows down long before it fails while the kernel reclaims pages. Peak memory utilization near 100 percent explains failures that look random in the GitHub Actions log. **Disk throughput limits.** A job can look idle on CPU while a large checkout, a Docker layer export, or a cache restore saturates disk. Read plus write throughput is recorded separately from CPU for exactly this shape, because the log line that takes four minutes gives no hint that the bottleneck is the volume. **Queue wait.** Time spent waiting for a runner is counted separately from execution. A pipeline whose end to end time grew while duration p90 stayed flat grew in the queue. Queue timings are reported per runner label and per stack, and the daily chart splits total queue time into GitHub time and WarpBuild time so you can tell which side of the handoff moved. Metrics are aggregated in a hierarchy: repository, then workflow, then job, then instance type. That ordering matters when you are hunting. Start at the repository to find the workflow burning the most machine time, drill to the job, then look at the instance type the job actually landed on, because a matrix job frequently runs on a size nobody chose deliberately. Five metrics are recorded per runner instance. | Metric | What the agent records | | ---------------------- | -------------------------------------------------------------------------------------- | | CPU utilization | Maximum rolling average CPU usage percentage over the last 30 seconds | | Memory utilization | Maximum memory usage percentage | | Filesystem utilization | Maximum storage usage percentage | | Disk I/O | Maximum rolling average of read plus write disk throughput over the last 30 seconds | | Network utilization | Maximum rolling average of read plus write network throughput over the last 30 seconds | The rolling average matters when you read the CPU number. A single 3 second spike during a compile burst does not push the sustained figure up, so a job showing 40 percent max sustained CPU has real headroom even if its flame chart looks busy. Two limits are worth knowing before you go looking: Observability collects metrics only for jobs longer than about one minute, and collection can be paused, which stops system logs and GitHub Actions logs along with the metrics. ## [Fix](#fix)Copy Anchor Link The workflow is the same every time. Find the outliers in the Jobs report, confirm the shape in the Usage view, then change the label. **Step 1: rank jobs by resource, not by duration.** The Jobs report aggregates per unique repository, workflow, and job name combination, and gives you run count, success rate, and p75 and p90 for duration, queue time, CPU, and memory. Sorting by cpu\_p90 descending surfaces the jobs that are pinned. Sorting ascending surfaces the jobs paying for cores they never touch. **Step 2: read the Recommendations view.** Recommendations applies fixed thresholds to the utilization data and labels the instances that cross them. | Metric | Threshold | Alert label | | -------------------------- | -------------------------------------------- | --------------------- | | Max sustained CPU | 80 percent or higher | High CPU Usage | | Max memory utilization | 80 percent or higher | High Memory Usage | | Max filesystem utilization | 80 percent or higher | High Filesystem Usage | | Max disk I/O | 80 percent or higher of supported throughput | High Disk IO | Each recommendation covers the organization's last 7 days and carries the job's repository, workflow, and job name, a recommendation type of upgrade or downgrade, the current label with its vCPU and memory, the recommended label with its vCPU and memory, and the subset of resources (cpu, memory, disk) that triggered it. An `already_at_max_size` flag marks jobs that are on the largest available size and still asking for more, which is the signal to split the job or shard the test suite instead of buying a bigger machine. **Step 3: confirm in the Usage view before you move.** Usage shows metrics and logs for an individual runner instance, with system logs and GitHub Actions logs in the same view. A job at 85 percent memory for 30 seconds during a bundle step is a different problem from a job that sits at 85 percent for 12 minutes. The chart tells them apart, and the correlated logs tell you which step owns the plateau. **Step 4: change one label and measure for a week.** Run count in the Jobs report tells you how much traffic the job gets, which tells you how long you need to wait before the new p90 means anything. A job with 40 runs a week needs a full week. A job with 900 runs a month can be judged in two days. Every runner WarpBuild operates carries the same agent. BYOC runs on AWS, GCP, and Azure, so the metrics for a runner inside your own cloud account arrive in the same reports as the metrics for a hosted one. ## [Configuration](#configuration)Copy Anchor Link Runner size is a label. Once you know which job is pinned and which is idle, the change is one line per job. Copy Text ``` name: ci on: pull_request: jobs: lint: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run lint build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run build test: needs: build runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test -- --shard=${{ matrix.shard }}/4 ``` Three sizes, three shapes of work: a single threaded lint on 2 vCPU, a core hungry build on 8 vCPU, a sharded test suite on 4 vCPU where the shard count does the parallelism. Publishing an image for a second architecture uses the ARM64 labels from the same catalog. Copy Text ``` build-arm: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - run: make release ARCH=arm64 ``` The agent collecting the metrics needs port `33931` for telemetry, which is open by default on WarpBuild operated runners. On BYOC runners, confirm that egress on that port is allowed from the subnet your runners launch in, otherwise the Usage view stays empty while jobs run normally. ### [Pulling the numbers from the API](#pulling-the-numbers-from-the-api)Copy Anchor Link Create an API key with the `ci` scope, then request the aggregated per job metrics for a date window. Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/reports/jobs' \ -H 'Authorization: Bearer wkey-xxxx' \ -H 'Accept: application/json' \ --data-urlencode 'start_date=2026-07-13T00:00:00Z' \ --data-urlencode 'end_date=2026-08-13T00:00:00Z' \ --data-urlencode 'chart_metric=cpu' \ --data-urlencode 'chart_percentile=p90' \ --data-urlencode 'sort_by=cpu_p90' \ --data-urlencode 'sort_order=desc' \ --data-urlencode 'per_page=50' ``` Each row in `table.items` carries the fields the dashboard renders. | Field | Meaning | | ---------------------------------- | ---------------------------------------------- | | repo, workflow\_name, job\_name | The aggregation key for the row | | run\_count | Executions in the selected window | | success\_rate | Share of runs that succeeded | | duration\_p75, duration\_p90 | Execution time percentiles | | queue\_time\_p75, queue\_time\_p90 | Percentiles of time spent waiting in the queue | | cpu\_p75, cpu\_p90 | Percentiles of peak CPU utilization | | memory\_p75, memory\_p90 | Percentiles of peak memory utilization | Flatten it into a table you can paste into a spreadsheet or a ticket: Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/reports/jobs' \ -H 'Authorization: Bearer wkey-xxxx' \ --data-urlencode 'start_date=2026-07-13T00:00:00Z' \ --data-urlencode 'end_date=2026-08-13T00:00:00Z' \ --data-urlencode 'runner_labels=warp-ubuntu-latest-x64-8x' \ --data-urlencode 'sort_by=cpu_p90' \ --data-urlencode 'sort_order=asc' \ | jq -r '.table.items[] | [.repo, .workflow_name, .job_name, .run_count, .duration_p90, .queue_time_p90, .cpu_p90, .memory_p90] | @tsv' ``` Add `--data-urlencode 'format=csv'` to get the same rows as CSV. All reports support date range selection, sorting, filtering, search, and CSV export, and the export always contains every row matching the current filters rather than the visible page. The right sizing suggestions have their own endpoint, which returns one entry per job that should move plus a sample of that job's recent runs: Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/org_metrics/job_runner_recommendations' \ -H 'Authorization: Bearer wkey-xxxx' \ --data-urlencode 'per_page=200' \ | jq -r '.recommendations[] | [.repository, .workflow_name, .job_name, .recommendation.type, .recommendation.current_label, .recommendation.recommended_label, (.recommendation.resources | join(","))] | @tsv' ``` Run that on a schedule and you have a weekly diff of which jobs drifted. For an agent that reads these reports and proposes the label changes for you, see the guide to the [WarpBuild MCP server](/guides/github-actions-mcp-server). Full field definitions live in the [Observability documentation](/docs/ci/features/observability) and the [Reports documentation](/docs/ci/features/reports). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Runner size changes price in a straight line. Linux x64 rates from the WarpBuild [pricing page](/pricing), billed per minute: | Label | vCPU | RAM | Storage | USD per minute | | -------------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Linux ARM64 carries the same shapes at a lower rate: `warp-ubuntu-latest-arm64-4x` at $0.006 per minute and `warp-ubuntu-latest-arm64-8x` at $0.012 per minute. **Assumptions used below.** Rates are the published per minute rates above. Run counts and duration percentiles are the sort of numbers the Jobs report returns, stated per job per month. Billing is per minute of runner time; the CI billing table reports execution time and billed time per job separately, so use billed time when you reconcile against an invoice. Queue time is excluded because it is not billed. **Model A: an over provisioned lint job.** The job runs 900 times a month, duration p90 is 6.0 minutes on `warp-ubuntu-latest-x64-8x`, cpu\_p90 is 21 percent, memory\_p90 is 17 percent. Current spend is 900 x 6.0 = 5,400 minutes at $0.016 = $86.40 a month. Moving it to `warp-ubuntu-latest-x64-2x`, and assuming the single threaded lint step stretches duration p90 to 7.5 minutes, gives 6,750 minutes at $0.004 = $27.00 a month. The break even point is the useful number here: at 900 runs, the 2x size stays cheaper than the 8x size until duration p90 passes 24.0 minutes, which no amount of stretching will reach. The intermediate stop, `warp-ubuntu-latest-x64-4x` at an assumed 6.5 minutes, costs 5,850 minutes at $0.008 = $46.80 a month. **Model B: a CPU pinned build job.** The job runs 600 times a month, duration p90 is 18.0 minutes on `warp-ubuntu-latest-x64-4x`, cpu\_p90 is 96 percent, memory\_p90 is 62 percent. Current spend is 10,800 minutes at $0.008 = $86.40 a month. Moving it to `warp-ubuntu-latest-x64-8x` and assuming duration p90 falls to 11.0 minutes gives 6,600 minutes at $0.016 = $105.60 a month. That is $19.20 more per month and returns 7 minutes per run, which is 4,200 minutes of wall clock a month back to the people waiting on the pull request. The dollar break even sits at 9.0 minutes: below that the upgrade also lowers the bill. Going to `warp-ubuntu-latest-x64-16x` would need duration p90 at 4.5 minutes to hold the same $86.40, which is the test to run before assuming more cores keep paying. For a baseline, GitHub publishes these per minute list prices for GitHub-hosted Linux runners, checked on 2026-08-13 in the [GitHub billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers): | GitHub-hosted runner | Shape | USD per minute | | ------------------------------------- | ------------- | -------------- | | ubuntu-latest on private repositories | 2 vCPU, 8 GB | $0.006 | | 4-core Linux larger runner | 4 vCPU, 16 GB | $0.012 | | 8-core Linux larger runner | 8 vCPU, 32 GB | $0.022 | Run Model B's 6,600 minutes against the 8-core GitHub-hosted list price and the same work costs $145.20 a month, against $105.60 on `warp-ubuntu-latest-x64-8x`. That is the same 8 vCPU and 32 GB shape at $0.016 per minute against $0.022, which is 27 percent lower list price, checked on 2026-08-13. For the wider picture of what the platform reports across a GitHub Actions estate, start at [GitHub Actions observability with WarpBuild](/solutions/github-actions-observability). If queue time rather than execution time is the number that moved, [cut GitHub Actions queue times](/guides/github-actions-queue-times) covers that path, and [reduce GitHub Actions costs](/guides/reduce-github-actions-costs) works the same data from the billing side. ## [FAQ](#faq)Copy Anchor Link **Does GitHub Actions report CPU and memory usage for a job?** No. GitHub Actions reports step timing, job timing, and exit status. CPU, memory, filesystem, and network utilization come from an agent running on the runner itself. WarpBuild collects them over OpenTelemetry on port 33931 and shows them next to the GitHub Actions logs for the same job. **Why do some jobs show a dash instead of CPU and memory in the Jobs report?** Two reasons. Observability collects metrics only for jobs longer than about one minute, and the CPU and memory columns require Observability to be enabled for the runner. Jobs with no telemetry render a dash in those columns and still report duration, queue time, run count, and success rate. **How do I decide whether a job should move to a larger or a smaller runner?** Read cpu\_p90 and memory\_p90 for that job in the Jobs report. Sustained values at or above 80 percent are the upgrade signal. Values in the low tens across every run are the downgrade signal. The Recommendations view applies those thresholds over the last 7 days and names the current label and the recommended label for each job. **Can I export runner metrics for analysis outside the dashboard?** Yes. Every report tab exports CSV with all rows matching the current filters and sort order, and the reports endpoints accept format=csv. The same data is reachable from the API with a wkey- API key carrying the ci scope. --- # No Space Left on Device in GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-runner-out-of-disk Description: A no space left on device failure means the runner volume filled mid-job. Print usage at three points to name the consumer, then apply one of three fixes. # No Space Left on Device in GitHub Actions A no space left on device failure means the runner volume filled mid-job. Print usage at three points to name the consumer, then apply one of three fixes. Last verified: 2026-08-13 A GitHub Actions job that fails with `No space left on device` filled the volume that holds the runner work directory, and the step named in the log is whichever step happened to write last. The consumer is almost always one of four things sharing that volume: the checkout, restored caches, Docker image layers, or artifacts staged for upload, and the fix depends on which one it is. Measuring takes two extra steps in the workflow. Every disk figure on this page comes from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. ## [Diagnosis](#diagnosis)Copy Anchor Link The message is the kernel ENOSPC error passed through whatever tool was writing. Two separate resources can run out, and they need different commands: * Free blocks. `df -h /` shows used and available bytes on the volume that carries the work directory. * Free inodes. `df -i /` shows the file count ceiling. A dependency tree with millions of small files can exhaust inodes while `df -h` still reports free gigabytes, which is why a job can report no space with a disk that looks half empty. Print both at three points so the growth is attributable rather than a guess. The first reading establishes the starting floor, because the preinstalled runner image already occupies part of the volume. The second isolates the checkout. The third runs with `if: always()` so it survives the failure that prompted the investigation. Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - name: Disk at job start run: | df -h / df -i / - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Disk after checkout run: | df -h / du -xh --max-depth=1 "$GITHUB_WORKSPACE" | sort -h | tail -10 - name: Build the image run: docker build -t app:${{ github.sha }} . - name: Disk after the build if: always() run: | df -h / df -i / docker system df du -xh --max-depth=1 "$RUNNER_TEMP" | sort -h | tail -10 ``` Read the three readings as deltas, and each delta points at one consumer: * Start to post-checkout. Repository history and LFS objects. A `fetch-depth: 0` clone of a repository with binary history is the largest single fixed cost in most jobs. * Post-checkout to post-build, with `docker system df` showing growth in images and build cache. Image layers. Every intermediate stage of a multi-stage build stays resident until the build ends, and each matrix tag adds another copy of the layers that differ. * Little block growth with `df -i` near its ceiling. File count. Package stores that hard-link thousands of files per dependency hit this before they hit the byte limit. * A late spike after the build. Artifact staging. `actions/upload-artifact` compresses into a temporary path, so the tarball is a second copy of the data on the same volume. Two path details save time on the way. Use `$GITHUB_WORKSPACE` and `$RUNNER_TEMP` instead of hardcoded paths, because Ubuntu 24.04 ARM64 runners set the work directory to `/runner/_work` rather than GitHub's `/home/runner/work/`. And runner storage is ephemeral and is deleted when the runner terminates, so every measurement has to happen inside the job. When the log still leaves the consumer unclear, the Action Debugger pauses the workflow and opens an SSH session on the runner, which is the fastest way to run `du` interactively against a filesystem that is about to disappear. Set the ceiling you are measuring against from the catalog. The example workload sizes below are placeholders to replace with your own numbers, and the free-at-peak column assumes the whole volume is available, which the `df -h` reading at job start will correct downward. | Placement | Volume | Checkout | Restored caches | Image layers | Staged artifacts | Free at peak | | ----------------------- | ------ | -------- | --------------- | ------------------------------------- | ---------------- | ------------ | | Linux x64, every size | 150GB | 12 GB | 20 GB | 45 GB | 5 GB | 68 GB | | Linux ARM64, every size | 150GB | 12 GB | 20 GB | 45 GB | 5 GB | 68 GB | | macOS, 6 vCPU labels | 120GB | 12 GB | 20 GB | none, macOS runners cannot run Docker | 5 GB | 83 GB | | macOS, 12 vCPU labels | 270GB | 12 GB | 20 GB | none, macOS runners cannot run Docker | 5 GB | 233 GB | | Windows, every size | 256GB | 12 GB | 20 GB | 45 GB | 5 GB | 174 GB | The row that surprises teams is the flat one. Disk does not scale with vCPU on Linux or Windows: `warp-ubuntu-latest-x64-2x` and `warp-ubuntu-latest-x64-32x` both mount 150GB SSD, so answering a disk failure with a bigger label moves the job from $0.004 per minute to $0.064 per minute and leaves the ceiling where it was. ## [Fix](#fix)Copy Anchor Link Three fixes, ranked by how much headroom they buy per unit of effort. **1\. Move image builds to a remote Docker builder.** Image layers are the fastest growing row in the table above, and a remote builder takes them off the runner volume entirely. The build runs on a dedicated builder virtual machine with a persistent layer cache, and builder profiles run from 100GB of disk on the 16 vCPU size up to 2TB on the 96 and 192 vCPU sizes ([Docker builders documentation](/docs/ci/docker-builders)). The swap is one action and one input: Copy Text ``` - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/app:${{ github.sha }} profile-name: "release-builder" ``` Remove the `docker/setup-buildx-action` step if it only existed to set up a builder. After the swap the runner holds the checkout and the build context, and the layers live on the builder profile, where the persistent cache also survives between runs. The [Docker builder catalog](/runners/docker-builders) lists every profile with its disk and rate. **2\. Prune between steps.** When the build has to stay on the runner, reclaim space at the boundary between stages instead of at the end: Copy Text ``` - name: Reclaim space before the test stage run: | docker image prune -af --filter "until=1h" docker builder prune -af rm -rf "$RUNNER_TEMP"/build-context df -h / ``` Pruning costs wall clock on every run, including the runs that were never close to the ceiling, which is why it ranks below moving the build off the runner. Reach for it when the pressure comes from a single stage whose output a later stage never reads. **3\. Raise the ceiling where raising it is possible.** On Linux and Windows labels there is no larger volume to move to, so this fix means something specific. On macOS, the 12 vCPU labels carry 270GB against 120GB on the 6 vCPU labels. On BYOC, the disk comes from the instance type in your own cloud account, and instance types that ship local NVMe SSDs have those devices detected, striped into RAID-0, formatted, and mounted to the runner work directory at boot with no configuration on Linux images ([local SSD documentation](/docs/ci/features/local-ssd)). The [local SSD runner page](/runners/local-ssd) covers which instance families carry them. ## [Configuration](#configuration)Copy Anchor Link Once the consumer is known, these are the knobs that move it. Checkout. `fetch-depth: 1` is the default and the cheapest setting; full history belongs only in jobs that read it, such as tag calculation or changelog generation. Sparse checkout narrows a monorepo working tree to the paths the job builds. Both matter most on the repositories where this failure shows up first, and the [large repository guide](/guides/large-repository-github-actions) works through them. Caches. Cache entries live off the runner, so only the restored copy occupies runner disk during the job. Restore the caches a job actually uses instead of a shared superset, and keep the restore paths out of directories that later steps recurse over. Artifacts. Upload, then delete the local copy before the next heavy step, because compression writes a second copy into `RUNNER_TEMP` before the upload starts. Matrix shape. Each matrix leg gets its own runner and its own volume, so splitting a job that builds five images sequentially into five legs divides the peak by five and shortens the critical path at the same time. Snapshot runners. A snapshot captures the runner virtual machine mid-workflow so later jobs boot from that state. Capture the snapshot after the dependency warm-up and before the layers accumulate, since whatever occupies the disk at capture time occupies it again on every boot. Labels. Pinning an image family changes the image and leaves the volume and the rate alone: `warp-ubuntu-2604-x64-8x` carries the same 150GB SSD at the same $0.016 per minute as `warp-ubuntu-latest-x64-8x`. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link A disk failure costs the minutes already burned plus the full re-run, so its price scales with how late in the job it lands. Take a repository that builds images on 500 runs a month, 18 minutes per run on `warp-ubuntu-latest-x64-8x` at $0.016 per minute, where 6 percent of runs die on disk at minute 16. * Wasted minutes on the failed attempts: 30 runs x 16 minutes = 480 minutes, or $7.68. * Re-run minutes: 30 x 18 = 540 minutes, or $8.64. * Total waste: $16.32 a month, plus about 17 hours of developer wall clock spent waiting on doomed jobs and their retries. Now price the two fixes that change the outcome. Rates from the [pricing page](/pricing), checked on 2026-08-13. | Line | Prune between steps | Remote Docker builder | | ----------------------- | -------------------------- | ----------------------------- | | Runs per month | 500 | 500 | | Added runner minutes | 375, at 45 seconds per run | 0 | | Added runner cost | $6.00 at $0.016 per minute | $0.00 | | Builder session minutes | 0 | 2,000, at 4 minutes per build | | Builder cost | $0.00 | $120.00 at $0.06 per minute | | Failure waste removed | $16.32 | $16.32 | Pruning is the cheaper line and it taxes all 500 runs to protect the 30 that would fail. The builder profile costs more per month and buys a persistent layer cache and a volume that starts at 100GB, which is the reason to pick it when image layers are the growing row rather than a one-off. The third option, paying for a bigger label, does nothing here. `warp-ubuntu-latest-x64-16x` costs $0.032 per minute against $0.016 and mounts the same 150GB. Against the hosted baseline the size you are already on is the cheaper seat: `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), which is 27 percent lower list price, with GitHub list prices checked on 2026-08-13 against the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Disk is one of several ceilings a slow or failing pipeline runs into; the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions) covers runner sizing, caching, and the rest. ## [FAQ](#faq)Copy Anchor Link ### [What does no space left on device mean in GitHub Actions?](#what-does-no-space-left-on-device-mean-in-github-actions)Copy Anchor Link The kernel refused a write because the volume holding the runner work directory ran out of free blocks or free inodes. The step named in the log is whichever step happened to write last, so the step that filled the disk is usually an earlier one. Print `df -h` and `df -i` at three points in the job to find it. ### [How much disk do WarpBuild runners have?](#how-much-disk-do-warpbuild-runners-have)Copy Anchor Link Every Linux x64 and Linux ARM64 label carries 150GB SSD, every Windows label carries 256GB SSD, and macOS labels carry 120GB on the 6 vCPU sizes and 270GB on the 12 vCPU sizes. Remote Docker builder profiles run from 100GB up to 2TB. Figures from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13, and the [disk space answer](/answers/do-github-actions-runners-have-large-disks) lists them label by label. ### [Does moving to a larger runner size give me more disk?](#does-moving-to-a-larger-runner-size-give-me-more-disk)Copy Anchor Link On Linux and Windows, no. Disk is flat across sizes, so `warp-ubuntu-latest-x64-32x` mounts the same 150GB as `warp-ubuntu-latest-x64-2x` and costs $0.064 per minute against $0.004\. The macOS 12 vCPU labels do carry 270GB against 120GB, and on BYOC an instance type with local NVMe SSDs raises the ceiling. When Docker layers are the cause, [why Docker build runs out of disk](/answers/why-does-docker-build-run-out-of-disk-in-github-actions) covers the build-side fixes. --- # Running GitHub Actions Runners in Your Own VPC | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-runners-in-your-vpc Description: Run GitHub Actions runners in your own VPC as ephemeral EC2 instances. The network shape AWS needs, the outbound allowlist, the IAM split, and the cost. # Running GitHub Actions Runners in Your Own VPC Run GitHub Actions runners in your own VPC as ephemeral EC2 instances. The network shape AWS needs, the outbound allowlist, the IAM split, and the cost. Last verified: 2026-08-13 You can run GitHub Actions runners inside your own VPC by granting a control plane permission to launch ephemeral EC2 instances in subnets you own, which is what WarpBuild BYOC does through a CloudFormation stack deployed in one region of your AWS account. This guide covers what pushes teams into a VPC, the network shape AWS asks for, the outbound destinations a locked-down security group has to allow, and who pays for what once the fleet is running. ## [Diagnosis](#diagnosis)Copy Anchor Link Three requirements drive runners into a customer VPC. Each one carries a trade that lands on the team that asks for it, so name the requirement before you build the network. ### [Private service access](#private-service-access)Copy Anchor Link A build step needs something that has no public address: an internal package registry, a database with a private endpoint, a staging service behind a private DNS zone, or an artifact store reachable only from inside the network. Runners outside the VPC cannot see any of it, and the workarounds all degrade. Publishing the service, punching a hole for a rotating IP range, or proxying through a bastion each add a surface that a security review will ask about later. Placing runners inside the VPC removes the question, and the detail of what a runner can then reach is covered in [can GitHub Actions runners reach my private network](/answers/can-github-actions-runners-access-my-private-network). The trade is that routing becomes yours. Subnet placement, route tables, DNS resolution, and the security group are now your team's to own and to debug when a job cannot resolve an internal hostname. ### [Egress control](#egress-control)Copy Anchor Link The second driver is a security policy that says outbound traffic from build machines is allowlisted, and often that a third party sees a stable source IP address. Both are network properties, so both need the runner inside a network you control. The trade here has a price attached. Stable outbound addresses mean runners in private subnets behind NAT gateways, and every gigabyte that crosses that path is metered. In US East (N. Virginia), AWS charges $0.045 per hour per gateway plus $0.045 per GB of data processed in both directions ([Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13). Runners in public subnets skip that meter entirely. Enable static addressing only for the workflows that genuinely require an allowlisted source, which is the same guidance the [BYOC documentation](/docs/ci/byoc) gives and the subject of the guide to [static IP addresses for GitHub Actions runners](/guides/static-ip-github-actions-runners). ### [Audit boundaries](#audit-boundaries)Copy Anchor Link The third driver is where the bytes live. When runners execute in your account, build data, caches, logs, and artifacts stay inside your account and your region, and the evidence you hand an auditor comes from your own CloudTrail and your own tags rather than from a vendor questionnaire. Isolation improves too, because a dedicated VPC or a dedicated AWS account for GitHub Actions limits the blast radius if a runner is ever compromised. The trade is operational ownership. EC2 quotas, subnet capacity, EBS limits, and the cloud bill all become yours. ## [Fix](#fix)Copy Anchor Link WarpBuild BYOC splits the system so the network stays yours and the operations do not. The control plane runs on WarpBuild and holds the GitHub App connection, the queue watch, and the reconciliation loop. The data plane is a CloudFormation stack in your account: VPC, subnets, security group, S3 bucket, and the EC2 instances themselves. BYOC runs on AWS, GCP, and Azure, and the AWS path is the one that also offers import mode. Two ways to get the network in place: 1. **Create mode.** The CloudFormation template provisions the VPC, subnets, and security group for you. Use this when GitHub Actions gets a dedicated network and you want the documented shape without assembling it yourself. 2. **Import mode.** You select an existing VPC, subnets, and security group when creating the stack. Use this when the network already exists under a platform team's Terraform, or when a policy forbids a template from creating network resources. Import mode is available on AWS. Each job runs on a distinct, freshly provisioned EC2 instance that is terminated when the job finishes, so there is no shared state between jobs and no long-lived machine to patch. Moving a workflow across is a one-line change to `runs-on`. Three properties are immutable once the stack exists: the stack name, the S3 bucket, and the region. Decide those before you apply, and give the stack a name that still reads clearly in six months. On AWS BYOC the covered platforms are Linux and Windows, so a fleet that also builds for Apple platforms keeps those jobs on WarpBuild-hosted macOS runners. ## [Configuration](#configuration)Copy Anchor Link ### [The network shape AWS asks for](#the-network-shape-aws-asks-for)Copy Anchor Link This is the documented prerequisite list from the [AWS BYOC configuration guide](/docs/ci/byoc/aws/config). Check each row against your existing VPC before you start the stack, because the two that bite later are IP capacity and subnet spread. | Requirement | Minimum | Recommended | Why it matters | | ---------------------- | --------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | Subnets | At least one public and one private subnet | Three public and three private subnets in different availability zones | Spread across zones maximizes instance type availability and keeps a single zone event from stalling the queue | | IPs per subnet | Enough for peak concurrent runners | 250 per subnet | Every running job holds an address, so the subnet is the real concurrency ceiling | | Internet connectivity | Every subnet must have it | Same | Runners fetch dependencies, register with GitHub, and report status | | Private subnet routing | NAT gateways | Same | Runners with static IPs use the addresses of the NAT gateways as their external addresses | | Route tables | Internet-bound traffic through the Internet Gateway | Same | Broken egress routing shows up as registration timeouts rather than as a network error | | Quotas | vCPU, EBS, and IP headroom in the region | Raise before migration | Quota denials surface as queued jobs with no obvious cause | Generally available Linux and Windows runners do not have plan-level concurrency caps, which means the ceiling on a BYOC fleet is set by your subnet addresses and your EC2 quotas rather than by a plan limit. That is the reason the 250 IP recommendation is worth honoring on day one. The stack also wires private access paths for S3 and ECR so cache reads and image pulls stay on the AWS network rather than crossing the public internet. ### [The outbound allowlist](#the-outbound-allowlist)Copy Anchor Link Runners need outbound access and no inbound path. If your security policy replaces the default allow-all egress rule with an allowlist, these are the destinations to open. | Destination | Used for | Consequence if blocked | | -------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------ | | api.warpbuild.com | Runner registration and lifecycle | Instances launch and never pick up a job | | api.github.com | GitHub API calls from the runner and from actions | Job setup fails and status never reports back | | github.com | Git operations, including actions/checkout | Checkout fails on the first step | | Package registries you depend on | npm, PyPI, crates.io, Maven Central, apt or yum mirrors, and your own registry | Dependency installs fail partway through the build | | WarpBuild S3 storage over HTTPS | Runner telemetry | Jobs still run, and runner-side diagnostics go missing | Cache traffic is routed to the stack S3 bucket over the private AWS path, so it does not need an internet allowlist entry. On the inbound side, be precise about what the default gives you. The stack security group allows ingress from VPC subnet CIDRs, so blocking inbound traffic entirely is a hardening step you apply rather than a default you inherit. The [security hardening guide](/docs/ci/byoc/aws/security) has the shape: remove every inbound rule, keep outbound to the destinations above, and use Network ACLs to stop runners from talking to each other, since runner instances have no reason to communicate. ### [Pointing workflows at the fleet](#pointing-workflows-at-the-fleet)Copy Anchor Link BYOC runners register with the `warp-custom-` prefix followed by the runner name you chose in the dashboard. The label is the only line that changes when a job moves off hosted capacity. Copy Text ``` name: integration on: pull_request: jobs: test: runs-on: warp-custom-ci-use1-8x steps: - uses: actions/checkout@v4 - name: Resolve the internal registry run: | getent hosts artifacts.internal.example.com npm config set registry https://artifacts.internal.example.com/npm/ - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run test:integration package: needs: test runs-on: warp-custom-ci-use1-8x permissions: contents: read steps: - uses: actions/checkout@v4 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - run: | docker build -t "${{ steps.ecr.outputs.registry }}/api:${{ github.sha }}" . docker push "${{ steps.ecr.outputs.registry }}/api:${{ github.sha }}" ``` The ECR login step carries no AWS credentials because the instance profile attached to the runner supplies them. Keep a hosted label available for jobs that have no private dependency, so a stack change never blocks every pipeline at once: Copy Text ``` lint: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: npm ci && npm run lint ``` ### [Permissions on the runner itself](#permissions-on-the-runner-itself)Copy Anchor Link Permissions available to a runner workload are exactly the ones you attach. Instance profiles are configured at the runner level, so a deploy runner can hold ECR push rights while a test runner holds none. Require IMDSv2 on every runner to protect instance credentials against SSRF in build steps, and scope each policy to the specific repository or bucket the job touches. Terraform support exists for BYOC on AWS, so runner sets and images can live in the same module as the network they run in. ### [Who owns what](#who-owns-what)Copy Anchor Link | Responsibility | Your team | WarpBuild | | --------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------- | | Provisioning the VPC, subnets, and security group | Owns it, through create mode or an existing network in import mode | Authors the CloudFormation template and both flows | | Route tables, DNS, NAT gateways, and quota headroom | Owns it | Documents the required shape | | IAM permissions on runner workloads | Owns it, through the instance profile attached per runner | Injects no permissions into runner workloads | | The cloud bill for EC2, EBS, and data transfer | Pays AWS directly at your rates | Not billed | | Runner lifecycle, queue watch, and image patching | Escalates issues | Owns it | | Build data, caches, logs, and artifacts | Held in your account and your region | Control plane metadata only | If regional placement rather than account placement is the actual requirement, WarpBuild-hosted runners cover it. US and EU are the regions WarpBuild names publicly. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Assumptions](#assumptions)Copy Anchor Link Substitute your own numbers. WarpBuild rates come from the [pricing page](/pricing), and the AWS and GitHub figures carry their own links and dates. | Input | Value | Source | | -------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Jobs per month | 3,000 | Your workflow run history | | Job duration | 6 minutes | Your workflow run history | | Runner shape | 8 vCPU, 32 GB Linux | Your current label | | Monthly runner minutes | 18,000 | 3,000 times 6 | | WarpBuild BYOC fee, Linux | $0.002 per minute | [Pricing page](/pricing) | | WarpBuild hosted warp-ubuntu-latest-x64-8x | $0.016 per minute | [Pricing page](/pricing) | | GitHub-hosted 8-core Linux larger runner | $0.022 per minute | [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13 | | Managed NAT data processing, US East (N. Virginia) | $0.045 per GB | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13 | | Managed NAT hourly charge, US East (N. Virginia) | $0.045 per hour | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13 | | EC2 and EBS for the instance types you pick | Your account rates | [Amazon EC2 pricing](https://aws.amazon.com/ec2/pricing/) | ### [The arithmetic](#the-arithmetic)Copy Anchor Link 18,000 minutes a month priced three ways: | Path | Platform fee per month | Compute billed by | Total on the WarpBuild invoice | | ------------------------------------------ | ---------------------------------- | ------------------------------- | ------------------------------ | | GitHub-hosted 8-core Linux larger runner | 18,000 times $0.022 equals $396.00 | GitHub, inside the same rate | Not applicable | | WarpBuild-hosted warp-ubuntu-latest-x64-8x | 18,000 times $0.016 equals $288.00 | WarpBuild, inside the same rate | $288.00 | | WarpBuild BYOC in your VPC | 18,000 times $0.002 equals $36.00 | Your AWS account, at your rates | $36.00 | The BYOC row is incomplete by design, because the compute line is yours. The full monthly figure is `18,000 * ($0.002 + your per-minute EC2 and EBS rate)`. To fill it in, take the hourly on-demand price of the instance types in your runner configuration, divide by 60, add the EBS cost for a 150 GB volume amortized across the hours it exists, and multiply. Spot capacity and reserved commitments both change that number, and both are levers a hosted runner cannot give you. Then price the network decision on top: * Public subnets, no static addressing: no gateway charge and no per-GB processing charge on the runner path. * Private subnets with NAT gateways: a fleet that moves 400 GB a month pays 400 times $0.045 equals $18.00 in data processing, plus 730 hours times $0.045 equals $32.85 per gateway. Three gateways across three availability zones make that $98.55 in fixed charges before a single byte moves. That is the whole trade in one line item. Static outbound addresses are worth about $100 a month per region in fixed charges plus per-GB processing, which is cheap for the workflows that need an allowlisted source and wasteful for the ones that do not. Split the fleet: one runner configuration in private subnets for the jobs that call an allowlisted third party, and one in public subnets for everything else. ### [Getting to a number for your own fleet](#getting-to-a-number-for-your-own-fleet)Copy Anchor Link Pull the last 30 days of workflow runs, group by label, and multiply minutes by the rates above. Then add the AWS side for the BYOC row using your own instance pricing. That is enough to stand up a stack, move one workflow, and compare a month of real data before committing the network design. If the decision is still open, [hosted runners compared with BYOC runners](/guides/hosted-vs-byoc-runners) sets the two models side by side. Cloud list prices change. Re-check the AWS and GitHub links above before quoting these totals internally. ## [FAQ](#faq)Copy Anchor Link **Do runners in my VPC need inbound network access?** No. The runner agent opens the connection outward, so nothing has to reach the instance from outside. Note that the default stack security group allows ingress from VPC subnet CIDRs, so removing every inbound rule is a hardening step you apply rather than a default you inherit. **What outbound destinations does a locked-down security group have to allow?** `api.warpbuild.com` for runner registration and lifecycle, `api.github.com` and `github.com` for API and git operations, and the package registries your builds pull from. Runner telemetry also needs outbound HTTPS to WarpBuild S3 storage. **Can I use an existing VPC instead of letting the stack create one?** Yes. Import mode lets you select an existing VPC, subnets, and security group when you create the stack, and it is available on AWS. Create mode provisions the network from the CloudFormation template instead. **Who controls the AWS permissions my build steps get?** You do. Permissions come from the instance profile you attach at the runner level, so each runner can carry a different scope. WarpBuild does not inject additional permissions into runner workloads. --- # Managing Secrets in GitHub Actions Workflows | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-secrets-management Description: Store every GitHub Actions secret at the narrowest scope that works, hand it to one step rather than one job, and delete the cloud keys OIDC replaces. # Managing Secrets in GitHub Actions Workflows Store every GitHub Actions secret at the narrowest scope that works, hand it to one step rather than one job, and delete the cloud keys OIDC replaces. Managing secrets in GitHub Actions comes down to three decisions: which scope holds each value, which step can read it, and which values can be deleted outright because a short lived credential replaces them. GitHub holds the encrypted value and injects the ones a workflow references into the job environment on the runner, and the [WarpBuild runner security documentation](/docs/ci/security) states that WarpBuild does not access or store any build secrets. This guide covers the scoping ladder with its documented limits, the OIDC exchange that removes stored cloud keys, what the runner side does with the value and what is destroyed with the machine, and a time model for the rotation work a stored key creates every quarter. ## [Diagnosis](#diagnosis)Copy Anchor Link Four shapes produce almost every secrets ticket, and three of them are scope problems rather than tooling problems. **One key in many repositories.** A cloud access key created during a migration ends up in 40 repositories, or in one organization secret shared with 40 repositories. Rotating it means touching every one of them in a single window, so it never gets rotated. **A job level env block.** A secret declared under `jobs..env` sits in the environment of every step in that job, including third party actions, so a compromised action reaches the same value that the deploy step uses. **An organization secret with an open access list.** Organization secrets are readable by every repository on the list. When the list is set to all repositories, a repository created next month inherits production credentials on its first workflow run. **A fork pull request that fails on a missing value.** With the exception of `GITHUB_TOKEN`, secrets are not passed to the runner when a workflow is triggered from a forked repository ([GitHub documentation on using secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets), checked on 2026-08-13). The job that needs a real credential belongs on a `pull_request_target` or `workflow_run` trigger with its own review, and the fork path runs the checks that need nothing. ### [The scoping ladder](#the-scoping-ladder)Copy Anchor Link Three scopes exist, and each exposes the value to a different set of readers. The limits below come from the [GitHub secrets reference](https://docs.github.com/en/actions/reference/security/secrets), checked on 2026-08-13, and the environment behavior from [managing environments for deployment](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments), checked on the same date. | Scope | What can read it | Gate in front of it | Documented limit | | ------------ | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | Repository | Any workflow in that repository, on any branch that runs a workflow | Whoever can merge a workflow file | 100 secrets per repository | | Environment | Only jobs that name the environment in jobs..environment | Environment protection rules: required reviewers, up to 6 people or teams; a wait timer; deployment branch and tag policies. Jobs reach the secret only after the configured rules pass | 100 secrets per environment | | Organization | Every repository on the secret's access list, including repositories added to that list later | Whoever manages the access list | 1,000 secrets per organization. Where a repository has access to more than 100, a workflow uses the first 100 sorted alphabetically by name | Two more limits shape the design. Individual secrets are limited to 48 KB, so a large service account JSON file or a signing certificate gets base64 encoded and often split. The alphabetical cutoff at 100 organization secrets is the quiet one: a workflow that references the 101st secret by name receives an empty string rather than an error, and the failure surfaces later as a confusing authentication rejection. Environment scope is the only rung with an approval gate, which is why deployment credentials belong there and build credentials do not. ### [Take the inventory first](#take-the-inventory-first)Copy Anchor Link Before moving anything, list what exists. The GitHub CLI reads all three scopes, and the environment list needs one API call. Copy Text ``` gh secret list --repo example-org/api gh secret list --org example-org gh api /repos/example-org/api/environments --jq '.environments[].name' ``` Run that across the repositories that deploy, then sort the output into three piles: values a cloud identity exchange can replace, values that must stay stored, and values nothing references any more. ## [Fix](#fix)Copy Anchor Link ### [1\. Delete what nothing references](#1-delete-what-nothing-references)Copy Anchor Link Search the workflow files for each secret name before you plan any migration. Secrets left behind by a retired pipeline are the cheapest ones to remove, and every deletion shortens the next rotation window. ### [2\. Move each remaining secret down to the narrowest scope](#2-move-each-remaining-secret-down-to-the-narrowest-scope)Copy Anchor Link A build credential that only the test job needs becomes a repository secret. A production database password becomes an environment secret behind required reviewers. An organization secret earns its scope only when three or more repositories genuinely share the same value and the access list names them explicitly rather than covering all repositories. ### [3\. Replace cloud keys with an OIDC exchange](#3-replace-cloud-keys-with-an-oidc-exchange)Copy Anchor Link This step deletes secrets rather than moving them. The job asks GitHub for a signed identity token describing the repository, branch, and workflow, and the cloud provider exchanges it for temporary credentials after checking the claims against a trust policy that was written once ([GitHub: security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect), checked on 2026-08-13). Copy Text ``` name: deploy on: push: branches: [main] permissions: {} jobs: deploy: runs-on: warp-ubuntu-latest-x64-4x environment: production permissions: contents: read id-token: write steps: - uses: actions/checkout@v5 - name: Exchange the GitHub identity token for AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy role-session-name: deploy-${{ github.run_id }} aws-region: us-east-1 - name: Apply the release run: ./scripts/deploy.sh ``` Two details decide whether this holds. The `permissions: {}` line at workflow level sets every scope to none, so the job block below it grants exactly `contents: read` and `id-token: write` and nothing else. The trust policy on the AWS side matches the `sub` claim with an exact string comparison against one repository and one ref, so editing the workflow file cannot widen it. [OIDC token exchange, defined](/glossary/oidc-token-exchange) covers the claim set, and [how to authenticate to AWS from GitHub Actions with OIDC](/answers/how-do-i-authenticate-to-aws-from-github-actions-with-oidc) covers the trust policy shape and the errors it produces. ### [4\. Hand what remains to one step](#4-hand-what-remains-to-one-step)Copy Anchor Link A secret declared at step level exists in that step alone. Pin every third party action to a full length commit SHA so an upstream tag move cannot introduce code that reads the environment around it. ### [5\. Give the survivors a rotation date](#5-give-the-survivors-a-rotation-date)Copy Anchor Link Whatever stays stored needs an owner and a cadence. The model further down prices what that cadence costs in engineering hours, which is the argument for shrinking the list in step 3. ## [Configuration](#configuration)Copy Anchor Link ### [A workflow that uses both patterns](#a-workflow-that-uses-both-patterns)Copy Anchor Link The build job carries a registry token scoped to the one step that publishes. The deploy job stores nothing and sits behind an environment. Copy Text ``` name: release on: push: tags: ["v*"] permissions: {} jobs: build: runs-on: warp-ubuntu-latest-x64-8x permissions: contents: read steps: - uses: actions/checkout@v5 - name: Run the test suite run: make test - name: Publish the package env: NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} run: npm publish --provenance deploy: needs: build runs-on: warp-ubuntu-latest-x64-4x environment: production permissions: contents: read id-token: write steps: - uses: actions/checkout@v5 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy role-session-name: release-${{ github.run_id }} aws-region: us-east-1 - run: ./scripts/deploy.sh ``` `NPM_PUBLISH_TOKEN` is readable by the publish step and by no other step in the workflow. The deploy job reaches its credentials only after the production environment's reviewers approve, because environment secrets and the environment level identity grant both wait on the protection rules. ### [What the runner side does with the value](#what-the-runner-side-does-with-the-value)Copy Anchor Link Once GitHub delivers a secret, the handling is documented per layer. The rows below come from the [runner security documentation](/docs/ci/security) and the [BYOC security hardening documentation](/docs/ci/byoc/aws/security). | Layer | Documented handling | Lifetime | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | Job environment on the runner | Every process the job starts can read it, including third party actions in the same job | The length of the job | | Runner virtual machine | Each runner runs in its own virtual machine, created on demand and destroyed after each build, never reused | Destroyed with the build | | Runner storage volume | Each runner has its own encrypted storage volume, created on demand and destroyed after each build | Destroyed with the machine | | Cache | Encrypted and stored in a location only your runner can reach | Until the cache entry is evicted | | WarpBuild control plane | WarpBuild does not access or store any build secrets | Nothing is held | | BYOC instance in your cloud account | A freshly provisioned instance terminated when the job finishes, carrying exactly the permissions you attach through your own security groups and IAM instance profiles. WarpBuild does not inject additional permissions into runner workloads | Terminated with the job | Every label in the catalog gets the handling in that table. A reviewer who wants the same statements in questionnaire order can work from the [security review checklist for GitHub Actions runners](/enterprise/ci-security-review), and the boundary question on its own is answered in [are build secrets visible to the runner platform](/answers/are-build-secrets-visible-to-the-runner-platform). ### [Runner labels used above](#runner-labels-used-above)Copy Anchor Link Per-minute rates from the [pricing page](/pricing): | Runner label | OS | vCPU | RAM | Storage | Price per minute | | ------------------------- | ------------ | ---- | ----- | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The number that matters in secrets management is engineering hours per rotation. The runner minutes a rotation consumes stay small at any fleet size, while the human time scales with the repository count. ### [Assumptions](#assumptions)Copy Anchor Link | Input | Value | Source | | -------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | | Repositories holding the same long lived cloud key | 40 | Your secret inventory | | Rotation cadence | Quarterly | Your security policy | | Engineer minutes to rotate one repository | 12 | Update the secret, re-run the verification workflow, confirm the deploy path | | Verification runs per repository per rotation | 1 | One workflow run per repository | | Verification job duration | 6 minutes | Your workflow run history | | Runner label and rate | warp-ubuntu-latest-x64-4x at $0.008 per minute | [Pricing page](/pricing) | ### [The arithmetic](#the-arithmetic)Copy Anchor Link Labor per rotation: 40 repositories times 12 minutes equals 480 minutes, which is 8 hours. Four rotations a year is 32 hours, and an unplanned rotation after a leaked value compresses the same 8 hours into one afternoon while someone else audits what the key touched. Runner minutes per rotation: 40 runs times 6 minutes equals 240 minutes. At $0.008 per minute that is $1.92 per rotation and $7.68 a year. | Line | 40 repositories with a stored key | 40 repositories on an OIDC exchange | | -------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Secrets stored for that provider | 40, or 1 organization secret on a 40 repository access list | 0 | | Rotation labor per year | 32 hours | 0 hours | | Runner minutes per rotation | 240 | 0 | | Runner cost per year for rotation runs | $7.68 | $0.00 | | Exposure window for a leaked value | Every repository sharing the key, until someone revokes it | One workflow run, until the credential expires on its own | | One time setup | None | One identity provider per cloud account, one role and trust policy per repository and target | The dollar line stays small at any fleet size. The 32 hours is what pays for the migration in step 3, and it repeats every year the stored key survives. ### [Where WarpBuild fits](#where-warpbuild-fits)Copy Anchor Link Compliance evidence for the runner side is SOC 2 Type 2 with three Trust Services Criteria: Security, Availability, and Confidentiality. Request the report and the supporting security documentation at [trust.warpbuild.com](https://trust.warpbuild.com/). Every price and limit on this page carries a source link and a checked-on date. GitHub reorganizes its Actions documentation paths regularly, so re-open the two GitHub references before quoting the limits in an internal policy, and re-run the arithmetic above against your own repository count and job duration. ## [FAQ](#faq)Copy Anchor Link **Should a secret live at repository, environment, or organization scope?** Use the narrowest scope that still lets the workflow run. A repository secret is readable by any workflow in that repository. An environment secret is readable only by jobs that name the environment, and only after the environment protection rules pass, which is the one scope with a gate in front of it. An organization secret is readable by every repository on its access list, including repositories added to that list later. **Which secrets can an OIDC exchange remove entirely?** The long lived cloud provider keys. AWS, GCP, and Azure all accept a signed identity token from GitHub and return temporary credentials, so the access key ID and secret access key stop being stored anywhere. Registry passwords, third party API tokens, and signing keys stay in the secret store unless the provider supports federation, and those are the values worth scoping to one step. **Can the runner platform read the secret while the job runs?** The WarpBuild runner security documentation states that WarpBuild does not access or store any build secrets, and that secrets stay in your source code repository and are only accessible to your runner environment. That environment is a virtual machine created on demand for one job and destroyed after the build, along with its encrypted storage volume. Inside the job, every process the job starts can read the value, which is why step scoping matters. --- # Scaling Self-Hosted Runners Without Owning the Cluster | WarpBuild URL: https://www.warpbuild.com/guides/github-actions-self-hosted-runner-scaling Description: Scaling self-hosted GitHub Actions runners means owning registration, autoscaling, image rebuilds, and cleanup. The managed and BYOC paths, priced. # Scaling Self-Hosted Runners Without Owning the Cluster Scaling self-hosted GitHub Actions runners means owning registration, autoscaling, image rebuilds, and cleanup. The managed and BYOC paths, priced. Last verified: 2026-08-13 Scaling self-hosted GitHub Actions runners is four standing jobs rather than one: registering runners with GitHub, growing and shrinking the fleet against queue depth, rebuilding the runner image on GitHub's 30-day software update clock, and cleaning up the machine and its registration record after every job. You can own all four on a cluster you operate, hand all four to managed runners behind a `runs-on` label, or split the difference with BYOC, where the scaling machinery is operated for you while the instances stay in your own cloud account. This guide covers what a self-managed pool has to solve, the responsibility split across the three shapes as a table, the workflow change each shape needs, and a worked month that prices the middle path. ## [Diagnosis](#diagnosis)Copy Anchor Link A pool that works on a Tuesday afternoon and stalls at 6 pm on a Friday is the normal failure mode. Four subsystems decide whether that happens, and each one fails differently. **Registration and deregistration.** A runner joins a repository, organization, or enterprise scope and answers jobs whose `runs-on` labels it carries, with access controlled through runner groups ([manage access](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/manage-access), checked on 2026-08-13). At one runner per job, registration happens as often as jobs do, so it becomes an API path with rate limits and credentials rather than a setup step. Actions Runner Controller solves it with a listener that holds an outbound long poll and registers each runner pod with a just-in-time configuration ([ARC concepts](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller), checked on 2026-08-13). A pool of virtual machines needs the same flow built and kept credentialed. **Autoscaling against queue depth.** Capacity has to follow a signal. On ARC that signal arrives on the listener connection, and the bounds are the static Helm values `minRunners` and `maxRunners`; GitHub documents that scheduled maximum and minimum configurations are not supported and suggests a cron job to update the configuration on a schedule ([deploy runner scale sets](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets), checked on 2026-08-13). Every policy you write sits between two failure modes: warm capacity that bills while idle, and scale to zero that pays a boot on every job. **Image maintenance on a fixed clock.** GitHub states that if you do not perform a software update within 30 days, the GitHub Actions service will not queue jobs to your runner ([runner software updates](https://docs.github.com/en/actions/reference/runners/self-hosted-runners#runner-software-updates-on-self-hosted-runners), checked on 2026-08-13). The `actions/runner` project shipped 11 stable releases in the 12 months to 2026-08-13 ([releases](https://github.com/actions/runner/releases)). Your compilers, SDKs, and container tooling ride on top of that cadence, and the ARC runner image carries only the packages the runner binaries need, so a real toolchain means an image you build and rebuild. **Cleanup after every job.** One job per runner is the isolation boundary most teams want, which means the machine is destroyed and its registration record removed when the job ends. Skip it and job state leaks into the next job, offline runner entries pile up in the scope, and instances survive their controller. The lifecycle a runner goes through under a managed control plane is written out in [the BYOC runner lifecycle](/byoc/runner-lifecycle). Upgrades sit behind all four. GitHub documents that Helm cannot upgrade ARC and that the procedure is an uninstall and reinstall, with a second cluster in another region as the documented way to keep runners available through the window ([upgrading ARC](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets#upgrading-arc)). GitHub also recommends a Kubernetes expert on staff and publishes a list of Kubernetes topics outside its support scope ([ARC support scope](https://docs.github.com/en/actions/concepts/runners/support-for-arc), checked on 2026-08-13). The component detail is in [Actions Runner Controller](/glossary/actions-runner-controller). ### [Why the pool is either idle or queuing](#why-the-pool-is-either-idle-or-queuing)Copy Anchor Link Take one month of GitHub Actions history: 45,000 Linux job minutes, and a measured peak of 40 concurrent jobs during the weekday merge window. | Quantity | Arithmetic | Result | | ---------------------------- | ------------------------- | --------------------- | | Job hours in the month | 45,000 divided by 60 | 750 hours | | Hours in the month | 30 times 24 | 720 hours | | Average concurrent jobs | 750 divided by 720 | 1.04 | | Peak concurrent jobs | Measured from queue depth | 40 | | Standing pool sized for peak | 40 times 720 | 28,800 instance hours | | Utilization of that pool | 750 divided by 28,800 | 2.6 percent | The gap between 1.04 and 40 is the whole problem. Size for the average and Friday evening queues. Size for the peak and hold 28,800 instance hours to serve 750 hours of work. Every autoscaler is an attempt to live somewhere between those two numbers, and the number it lands on is the one that decides the bill. ## [Fix](#fix)Copy Anchor Link Pick the shape by which of the four subsystems you want to keep, then set the runner labels to match. There are three shapes. **Self-managed pool.** A controller on a Kubernetes cluster you run, or virtual machines plus a scaler you wrote. You keep registration, autoscaling policy, images, cleanup, upgrades, and the cluster underneath them. **Managed runners behind a label.** The runner fleet is operated for you and reached by a `runs-on` label. Runners are ephemeral virtual machines, freshly allocated when a job needs one and destroyed when the workflow completes ([cloud runners](/docs/ci/cloud-runners), checked on 2026-08-13). **BYOC.** The control plane is operated for you and the instances run in your cloud account, inside a stack you create in a region of your choosing. Setup is three steps: connect the cloud account, create a stack, create a custom runner ([BYOC documentation](/docs/ci/byoc), checked on 2026-08-13). ### [The responsibility split](#the-responsibility-split)Copy Anchor Link | Job to be done | Self-managed pool | Managed runners | BYOC | | ------------------------------------------- | ------------------ | -------------------------- | --------------------------------------------------------- | | Runner registration and deregistration | Yours | Operated for you | Operated for you | | Autoscaling policy and queue-depth signal | Yours | Operated for you | Operated for you | | Runner image build and 30-day rebuild clock | Yours | Operated for you | Operated for you | | Per-job cleanup and isolation | Yours | Operated for you | Operated for you | | Control plane upgrades | Yours | Operated for you | Applied through the dashboard when a stack update appears | | Cloud account, IAM, and quota headroom | Yours | None to run | Yours | | Region choice | Yours | Managed | Yours, fixed per stack at creation | | Who pays for the compute | Your cloud invoice | Inside the per-minute rate | Your cloud invoice | | Spot instances and custom base images | Yours to build | Managed | Available on your instances | Sources: the [BYOC documentation](/docs/ci/byoc) and the [cloud runners catalog](/docs/ci/cloud-runners), both checked on 2026-08-13. ### [The decision, in order](#the-decision-in-order)Copy Anchor Link 1. **Does a policy require build compute to run inside your own cloud account?** Then BYOC. BYOC runs on AWS, GCP, and Azure, and the AWS setup path is written out in [running GitHub Actions runners in your own AWS account](/byoc/aws). 2. **Is the cluster itself doing work for you, such as pod-level isolation or node pools you already operate?** Then keep the self-managed pool, and budget the four subsystems as standing work. 3. **Otherwise, take the labels.** Registration, the autoscaler, the image clock, and cleanup all move off your plate at once. The axis-by-axis version of this choice is in [choosing between hosted and BYOC runners](/guides/hosted-vs-byoc-runners). ## [Configuration](#configuration)Copy Anchor Link Moving off a self-managed pool changes one line per job. The labels below are Linux x64 sizes from the [cloud runners catalog](/docs/ci/cloud-runners). Copy Text ``` name: ci on: push: branches: [main] jobs: unit: runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v5 - run: make test SHARD=${{ matrix.shard }} integration: needs: unit runs-on: warp-custom-use1-linux-8x steps: - uses: actions/checkout@v5 - run: make integration ``` The `unit` job runs on a managed label. The `integration` job runs in your own cloud account: `warp-custom-use1-linux-8x` is the Runner ID from the custom runners page, and the `warp-custom-` prefix is part of the ID. The steps under both jobs are identical, and the matrix no longer needs a `maxRunners` value raised anywhere to widen. Three settings do the scaling work on the BYOC side, all from the [BYOC documentation](/docs/ci/byoc): 1. **List several instance types on one custom runner.** When capacity for one type is unavailable in the region, the runner falls back to another. Choose types with similar performance so job times stay stable, and treat this as required on spot rather than optional. 2. **Name stacks by product and region.** The stack name, object storage location, and region cannot be changed after creation, so `payments-use1` ages better than `stack-1`. 3. **Leave static IPs off unless an allowlisted outbound address is a hard requirement.** Enabling them puts runners in private subnets behind managed network address translation, which adds data processing charges on every gigabyte a job moves. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Same month as the Diagnosis section: 45,000 Linux job minutes, an 8 vCPU and 32 GB shape, a measured peak of 40 concurrent jobs. Every assumption below is substitutable. | Input | Value | Source | | ----------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------- | | Linux job minutes per month | 45,000 | Your workflow run history | | Job hours | 750 | 45,000 divided by 60 | | Runner shape | 8 vCPU, 32 GB | Your runs-on labels | | Managed rate for that shape | $0.016 per minute | warp-ubuntu-latest-x64-8x on the [pricing page](/pricing) | | BYOC WarpBuild fee | $0.002 per minute | [Pricing page](/pricing) | | Example on-demand instance rate | $0.40 per hour | Substitute your rate from [AWS EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/) | | Example managed Kubernetes control plane rate | $0.10 per cluster hour | Substitute the current rate from [Amazon EKS pricing](https://aws.amazon.com/eks/pricing/) | | Self-managed pool utilization | 50 percent | Your own fleet metrics | | Boot and teardown overhead on per-job instances | 10 percent | Your own instance start times | The arithmetic, compute lines only: * **Managed runners.** 45,000 minutes times $0.016 equals **$720.00**. Nothing else appears. * **BYOC on-demand.** Instances bill while jobs run, so 750 job hours plus 10 percent overhead equals 825 instance hours, times $0.40 equals $330.00\. The WarpBuild fee is 45,000 times $0.002 equals $90.00, for **$420.00**. * **BYOC on spot.** BYOC runners cost $0.002 per minute in WarpBuild fees and the compute is billed by your own cloud account, where AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices. AWS discount checked on 2026-08-13\. At the published ceiling against the example rate, $0.04 per hour times 825 hours equals $33.00, for **$123.00**. * **Self-managed pool.** 750 job hours at 50 percent utilization equals 1,500 instance hours, times $0.40 equals $600.00, plus a control plane at $0.10 for 720 hours equals $72.00, for **$672.00** before a minute of engineering time. | Shape | Compute and fees | What it excludes | | ------------------------------------------- | ---------------- | -------------------------------------------------- | | Self-managed pool at 50 percent utilization | $672.00 | Image builds, autoscaler tuning, upgrades, on-call | | Managed runners | $720.00 | Nothing | | BYOC on-demand | $420.00 | Object storage in your stack, cloud account admin | | BYOC on spot at the AWS published ceiling | $123.00 | Object storage in your stack, cloud account admin | ### [The utilization the pool has to hit](#the-utilization-the-pool-has-to-hit)Copy Anchor Link Set the self-managed line equal to the BYOC on-demand line and solve for utilization. The pool needs 750 job hours divided by U instance hours at $0.40, plus $72.00 of control plane, to reach $420.00\. That gives U of about 86 percent, and it holds only while the engineering time is priced at zero. A pool absorbing a peak of 40 concurrent jobs against an average of 1.04 has to be extremely good at shrinking to get near that. The payroll side of the same question, with hours priced, is worked out in [what self-hosted runners actually cost](/guides/self-hosted-runner-total-cost). Two structural notes before this model reaches a spreadsheet. Re-check the AWS links before quoting the totals internally; cloud list prices move, and the utilization break-even is the part of this page that stays stable. ## [FAQ](#faq)Copy Anchor Link ### [What does scaling self-hosted GitHub Actions runners actually require?](#what-does-scaling-self-hosted-github-actions-runners-actually-require)Copy Anchor Link Four subsystems that have to keep working at the same time. Registration and deregistration against a repository, organization, or enterprise scope; an autoscaler driven by queue depth; a runner image rebuilt on GitHub's 30-day software update clock; and cleanup that removes the machine and its registration record after every job. Dropping any one of the four turns into stalled queues, drifting toolchains, or leaked instances. ### [What does the middle path cost?](#what-does-the-middle-path-cost)Copy Anchor Link BYOC runners cost $0.002 per minute in WarpBuild fees and the compute is billed by your own cloud account, where AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices. AWS discount checked on 2026-08-13\. At 45,000 Linux job minutes per month and an example on-demand rate of $0.40 per hour, that is $90.00 in fees plus $330.00 of instance time, against $720.00 for the same minutes on `warp-ubuntu-latest-x64-8x` at $0.016 per minute. ### [How busy does a self-managed pool have to be to beat that?](#how-busy-does-a-self-managed-pool-have-to-be-to-beat-that)Copy Anchor Link About 86 percent utilization at the example rates in the model above, before any engineering time is counted. A pool sized to absorb a peak of 40 concurrent jobs while the month averages 1.04 concurrent jobs sits far below that, which is why utilization rather than the instance rate decides whether a pool is worth operating. --- # Gradle Configuration Cache on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/gradle-configuration-cache-github-actions Description: Enable the Gradle configuration cache and the build cache on GitHub Actions, persist both directories across jobs, and key entries by Gradle version. # Gradle Configuration Cache on GitHub Actions Enable the Gradle configuration cache and the build cache on GitHub Actions, persist both directories across jobs, and key entries by Gradle version. The Gradle configuration cache stores the task graph that the configuration phase produces and replays it on the next run, while the build cache stores the outputs of cacheable tasks keyed by a hash of their inputs. On GitHub Actions both are opt-in, and neither one survives the job unless the directory holding it is restored, because every job starts on a clean machine with an empty project checkout. This guide separates configuration time from execution time, enables both caches, persists the two directory sets that hold them, and prices the result at real runner rates. It sits under the [persistent caches on GitHub Actions](/guides/persistent-caches-github-actions) hub, which covers the same problem for other ecosystems. ## [Diagnosis](#diagnosis)Copy Anchor Link Split the number before you change anything. Run the build with the profile report and read where the time actually goes: Copy Text ``` ./gradlew build --profile ``` Gradle writes an HTML report under `build/reports/profile` that separates configuration from task execution. A repository with 100 or more subprojects often spends 30 to 60 seconds in configuration alone, and that time is invisible in the GitHub Actions job log, which shows one duration for the whole step. Next, read what Gradle says about the configuration cache on each run. With the feature on, a run that reuses an entry prints `Reusing configuration cache.` A run that cannot reuse one prints a line beginning `Calculating task graph as no cached configuration is available for tasks:` followed by the reason, such as a changed build script or a system property that was not present when the entry was written. That reason line is the diagnosis. Collect it from ten consecutive runs before deciding whether the problem is persistence or invalidation. Two other suspects live nearby. The Gradle daemon starts cold on every job because the machine is new, which is [a separate cost from configuration](/answers/why-does-the-gradle-daemon-start-every-run) and is not fixed by any cache. Task execution that stays slow after a configuration cache hit points at the build cache instead. ## [Fix](#fix)Copy Anchor Link The two caches remove different phases, and enabling one does nothing for the other. | Configuration cache | Build cache | | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | | What it stores | The serialized task graph and the configuration state needed to run it | The outputs of cacheable tasks, keyed by a hash of their inputs | | Where it lives | .gradle/configuration-cache in the project directory | \~/.gradle/caches/build-cache-1, or a remote build cache node | | Command line flag | \--configuration-cache | \--build-cache | | Property | org.gradle.configuration-cache=true | org.gradle.caching=true | | Invalidated by | Build script and buildSrc changes, environment variables, system properties, files read at configuration time, a different Gradle version | A change to any declared input of the task | | Phase it removes | Configuration | Execution | Both are documented in the Gradle user guide, on the [configuration cache](https://docs.gradle.org/current/userguide/configuration%5Fcache.html) and [build cache](https://docs.gradle.org/current/userguide/build%5Fcache.html) pages. **1\. Turn both on in the repository, not in the workflow.** Put them in `gradle.properties` so local builds and GitHub Actions runs behave the same way: Copy Text ``` org.gradle.configuration-cache=true org.gradle.caching=true ``` **2\. Persist both directory sets.** `WarpBuilds/gradle-actions/setup-gradle@v5` restores Gradle User Home, which holds wrapper distributions, downloaded dependencies, and the local build cache. It does not carry the configuration cache, which lives inside the project directory, so that path needs its own entry. **3\. Stop invalidating the entry.** Anything read at configuration time becomes an input. A build script that reads `System.getenv("GITHUB_RUN_NUMBER")` at configuration time invalidates the entry on every single run. Read volatile values through Gradle's provider API so they are tracked at execution time, and keep timestamps and run identifiers out of configuration logic. ## [Configuration](#configuration)Copy Anchor Link The full workflow, with both caches persisted and the Gradle version in the key: Copy Text ``` name: build on: pull_request: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - name: Resolve Gradle version id: gradle run: | version=$(sed -n 's/.*gradle-\(.*\)-\(bin\|all\)\.zip/\1/p' gradle/wrapper/gradle-wrapper.properties) echo "version=$version" >> "$GITHUB_OUTPUT" - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: "21" - uses: WarpBuilds/gradle-actions/setup-gradle@v5 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - name: Cache the configuration cache uses: WarpBuilds/cache@v1 with: path: .gradle/configuration-cache key: gradle-cc-${{ steps.gradle.outputs.version }}-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle/libs.versions.toml', 'buildSrc/**') }} restore-keys: | gradle-cc-${{ steps.gradle.outputs.version }}- - run: ./gradlew build ``` Three details in that key carry the whole design. The Gradle version comes first after the prefix because an entry written by one Gradle version is unusable by another, so a wrapper bump should start a fresh entry line rather than download an entry Gradle will reject. The hash covers build logic and version catalogs, which is the set that invalidates configuration, and leaves application source out, since a source change does not change the task graph. The `restore-keys` prefix keeps the same Gradle version, so a build script edit falls back to yesterday's entry for the unchanged parts. `cache-read-only` on the setup action does the matching job for Gradle User Home: pull request builds restore the entry the default branch published and write nothing back, which keeps one authoritative entry instead of one per open branch. Two constraints are worth stating before you copy this into a matrix. WarpBuild Cache is documented as unsupported on Windows runners, so a Gradle job that depends on these restores belongs on a Linux or macOS label. Cache entries are also scoped by a version hash over the compression tool and cached paths, so a macOS leg and a Linux leg keep separate entries by design. The [caching documentation](/docs/ci/features/caching) covers that scoping, and the [setup actions documentation](/docs/ci/features/caching/setup-actions) lists the cache inputs on each fork. General JVM heap and runner sizing that applies across Maven, Gradle, and sbt builds sits on the [Java builds on GitHub Actions runners](/solutions/java-github-actions) page. One security note: a configuration cache entry contains values the build read at configuration time, so keep credentials out of configuration logic rather than storing them into an entry that is restored on every branch. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Per-minute rates come from the [pricing page](/pricing), checked on 2026-08-13. | Runner label | vCPU | RAM | Price per minute | | -------------------------- | ---- | ----- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | Assumptions for the model, taken from a 120-subproject build. Substitute the numbers your profile report produced. * 900 workflow runs per month on `warp-ubuntu-latest-x64-8x`. * Configuration takes 0.75 minutes cold and 0.10 minutes when the entry is reused. * Execution takes 6.50 minutes with no build cache and 2.00 minutes when task outputs are restored. * Stored entries total 3 GB: Gradle User Home, the local build cache, and the configuration cache entry. * Two restores per run, plus a save on the roughly 10 percent of runs that land on the default branch, so 1,980 cache operations per month. | Phase | No caches | Build cache only | Both caches | | ------------------- | --------- | ---------------- | ----------- | | Configuration | 0.75 min | 0.75 min | 0.10 min | | Execution | 6.50 min | 2.00 min | 2.00 min | | Total per run | 7.25 min | 2.75 min | 2.10 min | | Monthly minutes | 6,525 | 2,475 | 1,890 | | Monthly runner cost | $104.40 | $39.60 | $30.24 | Cache fees land on top of the last column: 3 GB of storage at $0.20 per GB-month is $0.60, and 1,980 operations at $0.0001 each is $0.20, so the both-caches path bills $31.04 per month against $39.60 for the build cache alone. On BYOC runners, cache storage and operations are included. Read the break-even the same way. That $0.80 monthly cache bill buys back 50 minutes at $0.016 per minute, which is 3.3 seconds per run across 900 runs. A configuration phase that clears more than 3.3 seconds per run pays for the whole cache line. The threshold falls on larger labels because a minute costs more there, and [sizing runners for Java builds](/guides/sizing-runners-for-java-builds) covers picking that label against heap and worker counts. Once both caches are on, WarpBuild CI observability, alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger, is where you confirm the configuration minutes stayed gone across weeks rather than one lucky run. For the surrounding pipeline, see [Gradle builds on WarpBuild runners](/solutions/gradle-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [What is the difference between the Gradle configuration cache and the build cache?](#what-is-the-difference-between-the-gradle-configuration-cache-and-the-build-cache)Copy Anchor Link The configuration cache stores the task graph produced by the configuration phase and replays it, so a hit removes configuration time. The build cache stores the outputs of cacheable tasks keyed by their inputs, so a hit removes execution time. They are separate features with separate flags, `--configuration-cache` and `--build-cache`, and separate directories. ### [Why does the configuration cache miss on every GitHub Actions run?](#why-does-the-configuration-cache-miss-on-every-github-actions-run)Copy Anchor Link Two reasons cover most cases. The entry lives in `.gradle/configuration-cache` inside the project directory, which is not part of Gradle User Home and is discarded with the runner unless you cache that path yourself. The second reason is invalidation: a build script change, a new environment variable, a system property, or a different Gradle version all force Gradle to calculate the task graph again. The reason line Gradle prints names which one applies. ### [Should the cache key include the Gradle version?](#should-the-cache-key-include-the-gradle-version)Copy Anchor Link Yes. A configuration cache entry is tied to the Gradle version that wrote it, so a wrapper bump makes every stored entry unusable. Parse the version out of `gradle/wrapper/gradle-wrapper.properties` and put it in the key, so a wrapper bump starts a new entry line instead of leaving a stale one to be downloaded and rejected. --- # Building for Graviton Targets on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/graviton-builds-github-actions Description: Run the GitHub Actions build and test jobs that produce your Graviton artifacts on native ARM64 runner labels, so architecture faults surface in the pipeline. # Building for Graviton Targets on GitHub Actions Run the GitHub Actions build and test jobs that produce your Graviton artifacts on native ARM64 runner labels, so architecture faults surface in the pipeline. Last verified: 2026-08-13 A pipeline that deploys to Graviton instances should compile and test on ARM64 machines, because an artifact built on x64 was validated against a different instruction set, a different memory ordering model, and a different set of prebuilt dependency binaries than the one production runs. On GitHub Actions that is a `runs-on` edit: send the build and test jobs to a native ARM64 label such as `warp-ubuntu-latest-arm64-8x`, and the pipeline starts exercising the architecture you ship to. This guide covers the diagnosis, the workflow change, the image and path differences worth knowing before you flip a label, and a cost model at a stated job volume. The full label and rate list lives on the [Linux ARM64 runner catalog](/runners/linux-arm64). If you are still weighing native builds against `GOARCH=arm64` style cross-compilation, [cross-compiling compared with native ARM64 builds](/guides/cross-compiling-vs-native-arm64-builds) is the page for that decision. ## [Diagnosis](#diagnosis)Copy Anchor Link The mismatch is quiet. Production runs on ARM64 instances while the build and test pipeline runs on x64, every job is green, and the artifact still deploys. What the pipeline never checked is whether the code it compiled behaves the same on the architecture it lands on, so architecture-specific faults surface after deploy instead of in a pull request. ### [Confirm the target is actually ARM64](#confirm-the-target-is-actually-arm64)Copy Anchor Link Start on the deploy side, because teams often inherit a Graviton fleet without the pipeline noticing. * EC2 instance families with a `g` in the type suffix run ARM64\. Read the launch template or Auto Scaling group rather than the console default. * ECS task definitions carry `runtimePlatform.cpuArchitecture: ARM64`. EKS node groups carry an ARM64 AMI family. * Lambda functions declare `Architectures: [arm64]`. * Container images that deploy to any of the above resolve the `linux/arm64` entry in the manifest, which is a different set of layers from the `linux/amd64` entry your x64 job built and smoke tested. Then run `uname -m` on one production host and in one pipeline job. When the first prints `aarch64` and the second prints `x86_64`, everything below applies. ### [What the x64 pipeline never validated](#what-the-x64-pipeline-never-validated)Copy Anchor Link | What differs on ARM64 | Where it shows up after deploy | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Prebuilt native dependency binaries | Package managers resolve a different artifact per platform. A dependency with no aarch64 wheel or no prebuilt .node binary compiles from source on the target or fails at install. | | Lockfile platform entries | Lockfiles record platform-specific optional dependencies for the platform that generated them, so an x64-only lockfile can be missing the ARM64 entry a bundler or image toolchain needs. | | Compiler and build flags | \-march=native, x86 intrinsics, and hand-written assembly in compression and crypto paths need an ARM64 branch. A guard that keys off the compiler rather than the target silently drops the optimized path. | | Memory ordering | The ARM64 model is weaker than the x64 model, so a data race that x64 hid behind stronger ordering becomes a reproducible failure or an intermittent one under load. | | Type and floating point details | char is unsigned on aarch64 and signed on x86-64, and floating point contraction differs, which moves results at the last bits and trips exact-equality assertions. | | Base image availability | A base image or service container with no linux/arm64 variant fails at pull time in the environment you deploy into. | ### [Rank the jobs before you move any](#rank-the-jobs-before-you-move-any)Copy Anchor Link Not every job belongs on ARM64\. The rule is narrow: any job that compiles, links, packages, or tests the artifact you deploy to Graviton moves, and everything else can stay where it is. A repository keeps its x64 jobs on x64 labels and moves only the work that has an architecture. Order the move by blast radius. The test job is the cheapest signal, because it fails loudly and takes minutes to evaluate. The image build job comes second. Any job that only lints, formats, or renders docs stays on x64 and saves you a migration you do not need. ## [Fix](#fix)Copy Anchor Link The change is one pull request per repository, and it is reviewable in a diff. ### [Send build and test to a native ARM64 label](#send-build-and-test-to-a-native-arm64-label)Copy Anchor Link Copy Text ``` name: service on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.24" - name: confirm the deploy architecture run: | uname -m go env GOARCH - run: go build ./... - run: go test -race ./... image: needs: test runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . platforms: linux/arm64 push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} - name: smoke test the image on the architecture that will run it run: docker run --rm ghcr.io/${{ github.repository }}:${{ github.sha }} --version ``` Two details in that workflow do the real work. The `test` job runs `go test -race` on an ARM64 host, so races that the x64 memory model masked now fail in the pull request. The `image` job builds and then runs the image on the same machine, which turns a missing `linux/arm64` base layer into a red job instead of a failed deployment. ### [The work directory differs on the Ubuntu 24.04 ARM64 image](#the-work-directory-differs-on-the-ubuntu-2404-arm64-image)Copy Anchor Link The Ubuntu 24.04 ARM64 image sets the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/` on the equivalent instance. Any step that writes an absolute runner path breaks on the first ARM64 run: volume mounts, coverage reports that record file paths, tool configuration with an absolute root, and shell helpers copied between repositories. Read the path from the environment instead of typing it. Copy Text ``` - name: mount the workspace into a container step run: | test -d "$GITHUB_WORKSPACE" docker run --rm -v "$GITHUB_WORKSPACE:/src" -w /src golang:1.24 go vet ./... ``` Inside `uses:` steps and expressions the same value is available as the `github.workspace` context. Grep the repository for `/home/runner/work` before you flip the label and you will find every offender in one pass. ### [When the failure only reproduces on ARM64](#when-the-failure-only-reproduces-on-arm64)Copy Anchor Link Some faults appear only on the target architecture and leave nothing useful in the log. The Action Debugger pauses the workflow and opens an SSH session on the runner, so you can attach a debugger to the aarch64 process, inspect the linked libraries, and re-run the failing test on the machine that produced it. Snapshot runners, remote Docker builders, and an MCP server sit alongside it on the same product surface. Running two architectures also doubles the number of jobs a push starts. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and Braintrust reports consistent sub-minute runner provisioning on both ARM64 and x86 WarpBuild runners, which is the property that keeps a two-architecture matrix from adding a queue. ## [Configuration](#configuration)Copy Anchor Link ### [ARM64 labels and rates against the x64 equivalents](#arm64-labels-and-rates-against-the-x64-equivalents)Copy Anchor Link Five sizes carry the Ubuntu 24.04 ARM64 image under the `latest` alias, and the same five exist for Ubuntu 26.04 under `warp-ubuntu-2604-arm64-`. The delta against the x64 label with the identical vCPU count is visible in the last column. | Size | ARM64 label | vCPU | RAM | ARM64 per minute | x64 label at the same size | x64 per minute | Difference per minute | | ---- | ---------------------------- | ---- | ----- | ---------------- | -------------------------- | -------------- | --------------------- | | 2x | warp-ubuntu-latest-arm64-2x | 2 | 8GB | $0.003 | warp-ubuntu-latest-x64-2x | $0.004 | $0.001 | | 4x | warp-ubuntu-latest-arm64-4x | 4 | 16GB | $0.006 | warp-ubuntu-latest-x64-4x | $0.008 | $0.002 | | 8x | warp-ubuntu-latest-arm64-8x | 8 | 32GB | $0.012 | warp-ubuntu-latest-x64-8x | $0.016 | $0.004 | | 16x | warp-ubuntu-latest-arm64-16x | 16 | 64GB | $0.024 | warp-ubuntu-latest-x64-16x | $0.032 | $0.008 | | 32x | warp-ubuntu-latest-arm64-32x | 32 | 128GB | $0.048 | warp-ubuntu-latest-x64-32x | $0.064 | $0.016 | Every ARM64 size carries 150GB of SSD storage. Size by the parallelism your build actually reaches rather than by the shape of the production instance, because a Graviton service sized for request concurrency and a build sized for compiler jobs are unrelated numbers. ### [Pin an image that still exists](#pin-an-image-that-still-exists)Copy Anchor Link The ARM64 images for Ubuntu 22.04 were deprecated on March 31, 2025, so pin a 24.04 or 26.04 label rather than carrying an old tag into the migration. The Ubuntu 24.04 ARM64 runners are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the tooling list is published in the [partner runner images reference for the ARM Ubuntu 24 image](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md). The [preinstalled software documentation](/docs/ci/preinstalled-software) maps every image to its upstream list, which is the fastest way to check a tool your workflow assumes before the first run. Two checks are worth doing in the same pass. Third-party actions sometimes publish x64 release assets only, so read the action release page before you assume it runs. Container jobs and service containers need an `arm64` variant of the image they name, which most official images publish and some internal base images do not. ### [Cache keys and the work that stays on x64](#cache-keys-and-the-work-that-stays-on-x64)Copy Anchor Link Cache entries are keyed by a hash covering the runner OS compression tool and the cached paths, so put `runner.arch` in the key and let the two fleets stop invalidating each other. Nested virtualization and `/dev/kvm` access are available on Linux x64 runners through the `nested-virtualization.enabled=true` label and are unavailable on ARM64 runners today. Android emulator jobs and anything else that boots a VM inside the runner keep an x64 label such as `warp-ubuntu-latest-x64-8x`. ### [Running the ARM64 fleet inside your own account](#running-the-arm64-fleet-inside-your-own-account)Copy Anchor Link Teams that want the runners next to the Graviton services they build for can place them in the same account. BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS, so the runner fleet is declared alongside the rest of your infrastructure. The full size and platform list is in the [cloud runners documentation](/docs/ci/cloud-runners). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Model the move at a fixed volume so the rate change and the minute change stay separate. ### [Assumptions](#assumptions)Copy Anchor Link * 3,000 build-and-test jobs per month, which is 150 jobs per weekday across 20 weekdays. * 9 minutes of runner time per job, giving 27,000 runner minutes per month. * Billing is per minute of runner time. * GitHub publishes its own per-minute rates for GitHub-hosted Linux ARM64 runners on the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. ### [Monthly cost at 27,000 runner minutes](#monthly-cost-at-27000-runner-minutes)Copy Anchor Link | Size | WarpBuild ARM64 per minute | Monthly on WarpBuild ARM64 | GitHub-hosted Linux ARM64 at the same core count | Monthly on GitHub-hosted ARM64 | WarpBuild x64 at the same size, monthly | | ---- | -------------------------- | -------------------------- | ------------------------------------------------ | ------------------------------ | --------------------------------------- | | 2x | $0.003 | $81.00 | $0.005 | $135.00 | $108.00 | | 4x | $0.006 | $162.00 | $0.008 | $216.00 | $216.00 | | 8x | $0.012 | $324.00 | $0.014 | $378.00 | $432.00 | | 16x | $0.024 | $648.00 | $0.026 | $702.00 | $864.00 | | 32x | $0.048 | $1,296.00 | $0.05 | $1,350.00 | $1,728.00 | Reading the 8x row: `warp-ubuntu-latest-arm64-8x` is $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner, a 14 percent lower list price ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). At the 2 vCPU size the same comparison is $0.003 against $0.005, a 40 percent lower list price, and at 32 vCPU it is $0.048 against $0.05, a 4 percent lower list price. The gap is widest at the small sizes, which is where most test jobs sit. ### [If you keep an x64 leg](#if-you-keep-an-x64-leg)Copy Anchor Link Some teams still ship an amd64 image for local development while production runs Graviton. That doubles the compile work, and at this volume the second leg on `warp-ubuntu-latest-arm64-8x` adds $324.00 per month while the x64 leg keeps its $432.00\. Before you accept both, check whether the x64 image needs a full test suite or only a build, because a build-only x64 leg usually costs a third of the minutes the full leg does. ### [The minutes side of the model](#the-minutes-side-of-the-model)Copy Anchor Link The rate table above holds the job length constant so the arithmetic stays readable. Job length is the number to measure in your own pipeline, using the run history for the same workflow before and after the label change. Every cost and performance number on this page carries a source link and a checked-on date, and the same numbers appear on the [pricing page](/pricing). Whether ARM64 wins on the invoice as well as on correctness is worked through separately in [is ARM64 cheaper than x64 for GitHub Actions](/answers/is-arm64-cheaper-than-x64-for-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [Do my GitHub Actions jobs have to run on ARM64 if production runs on Graviton?](#do-my-github-actions-jobs-have-to-run-on-arm64-if-production-runs-on-graviton)Copy Anchor Link The jobs that produce or validate the deployed artifact do. Compiling and testing on x64 exercises a different instruction set, a different memory ordering model, and a different set of prebuilt dependency binaries than the machine you deploy to, so anything architecture-specific stays hidden until the artifact reaches production. Jobs that touch no compiled output, such as linting or documentation checks, can stay on x64. ### [What usually breaks first when a pipeline that built on x64 starts running on ARM64?](#what-usually-breaks-first-when-a-pipeline-that-built-on-x64-starts-running-on-arm64)Copy Anchor Link Four things, in roughly this order. Lockfiles that recorded platform-specific optional dependencies for x64 only, base images and service containers with no `linux/arm64` variant, compiler and build flags such as `-march` or x86 intrinsics guarded by the wrong condition, and scripts with a hardcoded `/home/runner/work` path. The last one is specific to the Ubuntu 24.04 ARM64 image, which sets the work dir to `/runner/_work`. ### [Which WarpBuild labels match a Graviton deployment, and what do they cost?](#which-warpbuild-labels-match-a-graviton-deployment-and-what-do-they-cost)Copy Anchor Link `warp-ubuntu-latest-arm64-2x` through `warp-ubuntu-latest-arm64-32x`, running Ubuntu 24.04 at $0.003 to $0.048 per minute, with the same five sizes on Ubuntu 26.04 under `warp-ubuntu-2604-arm64` labels. Pick the size by the parallelism your build actually uses rather than by matching the production instance shape. The [Linux ARM64 runner catalog](/runners/linux-arm64) lists every row with storage and aliases. ### [Can I keep Android emulator or KVM jobs while the rest of the pipeline moves to ARM64?](#can-i-keep-android-emulator-or-kvm-jobs-while-the-rest-of-the-pipeline-moves-to-arm64)Copy Anchor Link Yes. Nested virtualization and `/dev/kvm` access are available on Linux x64 runners through the `nested-virtualization.enabled=true` label and are unavailable on ARM64 runners today, so those jobs keep an x64 label while the compile and test jobs move. Language-specific wiring for the jobs that do move is covered in the [Go on GitHub Actions solution page](/solutions/go-github-actions). --- # Choosing Between Hosted and BYOC Runners | WarpBuild URL: https://www.warpbuild.com/guides/hosted-vs-byoc-runners Description: Hosted WarpBuild runners bill one per-minute rate. BYOC runners bill $0.002 per minute plus your own cloud spend. How to choose, with a worked cost model. # Choosing Between Hosted and BYOC Runners Hosted WarpBuild runners bill one per-minute rate. BYOC runners bill $0.002 per minute plus your own cloud spend. How to choose, with a worked cost model. Last verified: 2026-08-13 WarpBuild hosted runners bill a single per-minute rate that covers the machine, while BYOC runners bill $0.002 per minute in WarpBuild fees and your own AWS, GCP, or Azure account pays for the instance. Pick hosted when you want no cloud infrastructure to operate, macOS runners, or snapshot runners, and pick BYOC when you need spot instances, static IPs, custom VM images, local SSD, or a region you choose inside your own account. ## [Diagnosis](#diagnosis)Copy Anchor Link The same runner fleet ships in two shapes. Hosted runners live in WarpBuild's cloud and arrive as a label you put in `runs-on`. BYOC runners live in your cloud account: you grant an IAM role or service account, WarpBuild creates a stack (a region, a VPC, and an object storage bucket for cache, logs, and artifacts), and runner instances start and stop inside that VPC. Most teams pick by habit rather than by the axes that actually differ. Six axes do the deciding. **Who pays for the compute.** On hosted runners the machine is inside the per-minute rate. On BYOC the instance appears on your cloud invoice at whatever your account pays, including any committed-use or enterprise discount you have already negotiated, and WarpBuild charges $0.002 per minute on top. **Region.** Hosted runners are offered in US and EU. A BYOC stack pins one region per cloud connection, chosen from the regions your own account supports, which is the axis that decides the question for teams with a data residency requirement. **Spot instances.** BYOC only. Spot is the reason the BYOC line can drop far below the hosted line for interruption-tolerant work, and it is the reason to configure fallback instance types. **Static IPs.** BYOC only. Enabling them places runners in private subnets behind managed network address translation, which adds data processing charges on every gigabyte the job moves, so they earn their place only where an allowlisted outbound address is a hard requirement. **Snapshot runners.** WarpBuild Cloud only, on Linux x64 and Linux ARM64\. A snapshot captures a runner VM mid-workflow and later jobs boot from it, which is the fastest way to skip a long dependency or database seeding step. **Custom VM images and local SSD.** BYOC only. You can bake your own base image with the toolchain preinstalled, and instance types carrying local NVMe volumes are detected and mounted to the runner work directory at boot on Linux images, with several disks combined into a RAID-0 array. ### [Decision table](#decision-table)Copy Anchor Link | Axis | WarpBuild hosted runners | BYOC on AWS, GCP, or Azure | | -------------------------------------------------------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------- | | Who pays for the compute | WarpBuild, inside the per-minute rate | Your cloud account, at your rates | | WarpBuild fee | Per-minute rate by runner size | $0.002 per minute per runner | | Region choice | US and EU | Any region your account supports, one per stack | | Spot instances | No | Yes | | Static IPs | No | Yes, in private subnets with address translation charges | | Snapshot runners | Yes, on Linux x64 and Linux ARM64 | No | | Custom VM images | Managed by WarpBuild | Yes, bring your own base image | | Local SSD auto-mount | Managed by WarpBuild | Yes, on instance types with local NVMe, Linux images | | macOS runners | Yes | No | | Windows runners | Yes | AWS and Azure | | Unlimited cache, container layer cache, nested virtualization, Tailscale networking, utilization metrics | Yes | Yes | | Terraform | Managed by WarpBuild | Yes on AWS | Source: the WarpBuild [feature matrix](/docs/ci/feature-matrix), checked on 2026-08-13. ### [Per-cloud gaps worth knowing before you commit](#per-cloud-gaps-worth-knowing-before-you-commit)Copy Anchor Link BYOC runs on AWS, GCP, and Azure, and the three are not identical today. BYOC on Azure is enabled for East US only. The import flow, which puts runners into a VPC you already operate rather than creating a new one, is supported on AWS only. Windows on BYOC GCP is not generally available. BYOC Windows images are documented for Windows Server 2022, and those instances do not support Hyper-V. Terraform support exists for BYOC on AWS, so if you want the runner fleet declared in the same modules as the rest of your infrastructure, AWS is the mature path. Capacity is bounded by your own cloud quotas rather than by a WarpBuild cap, which is covered in [BYOC capacity planning and cloud quotas](/guides/byoc-capacity-and-quotas). Two things do not change with the choice. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts dynamically. ## [Fix](#fix)Copy Anchor Link Answer four questions in order and stop at the first yes. 1. **Does a policy require build compute to run inside your own cloud account?** Then BYOC, and the rest of the model is about which region and which instance families. The same answer applies when a workflow needs an allowlisted static outbound IP. 2. **Do you need macOS runners?** Those jobs run on hosted macOS labels. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and macOS is hosted only, so a mobile pipeline is a mixed fleet by construction. 3. **Is a long setup step (dependency install, database seeding, toolchain build) the largest block of every job?** Snapshot runners remove it, and they are hosted only. 4. **Is the bill dominated by long Linux jobs that tolerate interruption?** Then BYOC with spot instances is the cheapest shape available, and the cost model below shows the break-even point. If every answer is no, start hosted. There is no cloud account to connect, no stack to create, and no quota to raise, and you can move a workload to BYOC later without touching anything except one line per job. ### [The mixed fleet is normal](#the-mixed-fleet-is-normal)Copy Anchor Link Nothing forces a repository into one mode. The runner type is chosen per job, so unit tests can run on a hosted Linux runner, integration tests on a BYOC runner sitting next to the database they talk to, and an iOS build on a hosted macOS runner, all in one workflow file. ### [What migration actually touches](#what-migration-actually-touches)Copy Anchor Link The workflow file changes in exactly one place: the `runs-on` value. BYOC runner IDs carry the `warp-custom-` prefix followed by the runner name you chose in the dashboard, so `runs-on: warp-ubuntu-latest-x64-8x` becomes `runs-on: warp-custom-use1-linux-8x` and the steps below it stay identical. Actions, caching, container layer caching, the Tailscale network addon, utilization metrics, and the Action Debugger behave the same on both sides. Billing changes: hosted minutes stop, the $0.002 per minute fee starts, and the instance shows up on your cloud invoice. Plan for the reverse direction too. Keeping a hosted label in the matrix for one job is a cheap way to keep a fallback that needs no cloud capacity. ## [Configuration](#configuration)Copy Anchor Link The workflow below runs three jobs across both modes plus a fourth that boots from a snapshot. Only the labels differ. Copy Text ``` name: ci on: push: branches: [main] jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - run: npm ci - run: npm test integration-tests: runs-on: warp-custom-use1-linux-8x steps: - uses: actions/checkout@v5 - run: ./scripts/integration.sh seeded-e2e: runs-on: warp-ubuntu-latest-x64-8x;snapshot.key=e2e-base steps: - uses: actions/checkout@v5 - run: ./scripts/e2e.sh ios-build: runs-on: warp-macos-latest-arm64-6x steps: - uses: actions/checkout@v5 - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build ``` `warp-custom-use1-linux-8x` is a Runner ID from the custom runners page in the dashboard. The `warp-custom-` prefix is part of the ID and is required. The snapshot label uses the semicolon form documented on the snapshot runners page, and it works on hosted Linux runners only. BYOC setup is three steps, documented in the [BYOC guide](/docs/ci/byoc): 1. **Connect the cloud account.** This creates the IAM role or service account WarpBuild uses to manage runners. Nothing runs yet. 2. **Create a stack.** Pick the region, VPC, and network configuration. The stack name, object storage location, and region cannot be changed after creation, so name stacks by product and region, for example `payments-use1`. 3. **Create a custom runner.** Choose instance types, disk, and IP configuration. List several instance types with similar performance so a job falls back when one type is unavailable in the region, which matters most on spot. Local NVMe volumes are detected and mounted at boot with no configuration. Leave static IPs off unless an allowlisted address is required. Step-by-step AWS instructions, including the Terraform path, are in [running GitHub Actions runners in your own AWS account](/byoc/aws). The spot configuration details are in [spot instances for GitHub Actions runners](/guides/spot-instances-github-actions-runners). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Hosted rates against GitHub list prices](#hosted-rates-against-github-list-prices)Copy Anchor Link Linux x64, matched shape for shape. WarpBuild rates come from the runner catalog and GitHub list prices were checked on 2026-08-13 on the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). | Runner | Shape | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | Lower list price | | -------------------------- | --------------- | -------------------- | ----------------------------------- | ----------------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | ubuntu-latest, private repositories | $0.006 | 33 percent | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064 | 32-core Linux larger runner | $0.082 | 22 percent | ### [The break-even instance rate](#the-break-even-instance-rate)Copy Anchor Link BYOC beats hosted when the hourly rate your cloud account pays for the instance is below the hosted per-minute rate minus the $0.002 BYOC fee, multiplied by 60\. That single line decides most of these conversations. | Runner size | Hosted per minute | BYOC fee per minute | Break-even cloud instance rate | | --------------- | ----------------- | ------------------- | ------------------------------ | | 2 vCPU, 8 GB | $0.004 | $0.002 | $0.12 per hour | | 4 vCPU, 16 GB | $0.008 | $0.002 | $0.36 per hour | | 8 vCPU, 32 GB | $0.016 | $0.002 | $0.84 per hour | | 16 vCPU, 64 GB | $0.032 | $0.002 | $1.80 per hour | | 32 vCPU, 128 GB | $0.064 | $0.002 | $3.72 per hour | Small runners rarely clear the bar on on-demand pricing: an instance under $0.12 per hour with 2 vCPU and 8 GB is uncommon. Large runners clear it easily, and spot clears it at every size. ### [A worked month](#a-worked-month)Copy Anchor Link Assumptions, all substitutable: | Input | Value | Source | | ------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Linux minutes per month | 60,000 | Your workflow run history | | Runner shape | 8 vCPU, 32 GB | Your runs-on labels | | Instance hours implied | 1,000 | 60,000 divided by 60 | | Example on-demand instance rate | $0.40 per hour | Substitute the current rate for your instance type from [AWS EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/) | | Spot discount ceiling | Up to 90 percent below On-Demand | [AWS Spot](https://aws.amazon.com/ec2/spot/), checked on 2026-08-13 | The arithmetic: * Hosted `warp-ubuntu-latest-x64-8x`: 60,000 times $0.016 equals **$960.00**. * The GitHub-hosted 8-core larger runner at list price: 60,000 times $0.022 equals **$1,320.00** (GitHub billing reference, checked on 2026-08-13). * BYOC WarpBuild fee: 60,000 times $0.002 equals **$120.00**. * BYOC instance line at the example on-demand rate: 1,000 hours times $0.40 equals **$400.00**, for **$520.00** total. * BYOC instance line at the AWS published spot ceiling of a 90 percent discount below On-Demand, which is $0.04 per hour against the example rate: **$40.00**, for **$160.00** total. BYOC runners cost $0.002 per minute in WarpBuild fees and the compute is billed by your own cloud account, where AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices. AWS discount checked on 2026-08-13\. That is a published ceiling from AWS rather than a WarpBuild measurement, and the rate you see depends on instance family, region, and the hour. Two lines sit outside the table. The object storage bucket in your stack holds cache, logs, and artifacts, and is billed by your cloud provider. Runners with static IPs sit in private subnets, where AWS charges $0.045 per hour for managed network address translation plus $0.045 per GB processed in US East (N. Virginia) ([Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13), which is enough to erase the instance saving on artifact-heavy jobs. ### [What the model says](#what-the-model-says)Copy Anchor Link At the example inputs, the hosted line is $960.00, the BYOC on-demand line is $520.00, and the BYOC spot line is $160.00, against $1,320.00 for the GitHub-hosted equivalent at list price. These totals apply only to the stated rates and duration; substitute your cloud prices and measured job times before choosing a placement. Weigh the operational side against the difference. BYOC adds a cloud account to keep quota headroom in, an image to maintain if you go custom, and a stack to upgrade when new features need it. Price that work in hours per month and compare it against the gap between the two lines at your own minute volume, because the gap scales with minutes and the operational work does not. Teams with a residency requirement or a static IP requirement skip the arithmetic, since only one mode meets it. WarpBuild pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, on either mode, and the full rate card is on the [pricing page](/pricing). SSO is available for a flat $250 per month, whatever the user count, and it applies the same way to hosted and BYOC. Signup includes $10 free credits, which covers 1,250 minutes on `warp-ubuntu-latest-x64-4x` at $0.008 per minute and is enough to run both shapes against the same workflow before deciding. Re-check the AWS and GitHub links before quoting these totals internally. Cloud list prices move, and the break-even table is the part of this page that stays stable. ## [FAQ](#faq)Copy Anchor Link **Is BYOC cheaper than hosted runners?** It depends on what your cloud account charges for the instance. The WarpBuild fee on BYOC is $0.002 per minute, so BYOC wins on an 8 vCPU Linux shape once your effective instance rate sits below $0.84 per hour, which is the hosted rate of $0.016 per minute minus the BYOC fee, multiplied by 60\. On a 2 vCPU shape the break-even rate is $0.12 per hour, which on-demand instances rarely beat. **Can one repository use hosted and BYOC runners at the same time?** Yes. The runner type is chosen per job by the `runs-on` label, so a workflow can run unit tests on `warp-ubuntu-latest-x64-4x`, integration tests on a `warp-custom-` runner in your own account, and an iOS build on a macOS runner in the same file. **Do snapshot runners work on BYOC?** No. Snapshot runners are a WarpBuild Cloud feature on Linux x64 and Linux ARM64\. Spot instances, static IPs, custom VM images, and local SSD auto-mount run the other way and are BYOC only. **Which clouds and regions does BYOC cover?** BYOC runs on AWS, GCP, and Azure. A stack pins one region per cloud account connection, BYOC on Azure is enabled for East US only today, importing an existing VPC is supported on AWS only, Windows on BYOC GCP is not generally available, and BYOC Windows images are documented for Windows Server 2022. --- # Idle Time Inside GitHub Actions Jobs | WarpBuild URL: https://www.warpbuild.com/guides/idle-time-in-github-actions-jobs Description: Idle minutes inside a GitHub Actions job bill like busy ones. Spot them from low CPU against a long duration, name the four sources, and price the fix. # Idle Time Inside GitHub Actions Jobs Idle minutes inside a GitHub Actions job bill like busy ones. Spot them from low CPU against a long duration, name the four sources, and price the fix. Last verified: 2026-08-13 Idle time inside a GitHub Actions job is wall clock the job spends holding a machine that is doing no work, and it bills at the same per-minute rate as the busy minutes around it. You find it by pairing a long duration against a low CPU number for the same job, then reading the runner utilization chart against the step boundaries to name what the job was waiting on. This guide covers the four sources that produce almost all of it, the metric signature each one leaves, the workflow changes that remove them, and a worked monthly cost for the idle minutes at a stated job volume and runner size. ## [Diagnosis](#diagnosis)Copy Anchor Link Three quantities compete for the same complaint that a job is slow. Queue time is the wait before the job starts and is reported separately per runner label in Queue Timings. Busy execution is the machine working. Idle execution is the machine held and not working. Only the last two land in the job duration and on the invoice, and the split between them is what this page is about; the [guide to finding the slow step](/guides/find-the-slow-step-github-actions) covers the queue versus execution split in full. The starting signal is one row in the Jobs report, which aggregates per unique repository, workflow, and job name with run count, duration p75 and p90, queue time p75 and p90, and CPU and memory p75 and p90, per the [Reports documentation](/docs/ci/features/reports). Sort by duration p90 descending, then read the CPU column of the top rows. | Duration p90 | CPU p90 | Reading | | ------------ | ---------------------------------- | ------------------------------------------------------------------- | | Minutes | Under 10 percent | Blocked. The job is waiting on something outside itself | | Minutes | Near 100 divided by the vCPU count | One thread is working and the rest of the machine is parked | | Minutes | 30 to 70 percent | Partly parallel work with idle stretches between the parallel parts | | Minutes | 80 percent or higher | Core bound. Size is the lever and idle is not the story | Two limits on that column. CPU and memory come from the telemetry agent, so jobs without observability enabled show a dash instead of a number, and metrics and logs are collected only for jobs longer than about one minute, per the [Observability documentation](/docs/ci/features/observability). Collection can also be paused, which stops metrics along with system logs and GitHub Actions logs, so an empty chart is worth checking against the pause state before you read it as an idle machine. The bigger trap is the direction of the inference. The Jobs report CPU column is a percentile over runs of each run's peak utilization, so one busy step sets the peak for the entire run. Low CPU p90 with a long duration is conclusive enough to open the job. High CPU p90 clears nothing, and a job that peaks at 92 percent can still hold eight idle minutes on either side of that peak. To see the shape over time, open the Usage view for a single runner instance, where the utilization chart and the GitHub Actions logs sit in the same view so a flat region can be matched to the step that was running when it started. The Recommendations view also filters and highlights instances that sit at low resource utilization, which is the fleet-wide version of the same read. Four sources cover almost every flat region you will find, and each leaves a different signature across the CPU and network traces. | Source | CPU trace | Network trace | Usual step | | --------------------------------------- | --------------------------------------- | ----------------------------------- | -------------------------------------------------------- | | Waiting on a service | Flat near zero for one contiguous block | Flat, with a short burst at the end | A wait or health check before an integration suite | | Fixed sleeps | Flat near zero in round-numbered blocks | Flat | Retry helpers and shell scripts with sleep | | Polling loops | Near zero with a small sawtooth | Regular spikes at the poll interval | aws ... wait, kubectl rollout status, custom until loops | | Single-threaded step on a large machine | Stable at 100 divided by the vCPU count | Flat | Migrations, single-process linters, sequential packaging | The fourth one reads as idle in the cost sense even though the machine is doing work, because you are paying for every core while one of them runs. The ceiling is arithmetic, and the rates come from the [pricing page](/pricing): | Label | vCPU | One busy thread shows | USD per minute | | -------------------------- | ---- | --------------------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 50 percent | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 25 percent | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 12.5 percent | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 6.3 percent | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 3.1 percent | $0.064 | A step holding a steady 6 percent on a 16 vCPU runner is the signature of one saturated core, and a step holding a steady 6 percent on a 2 vCPU runner is a genuinely quiet machine. Read the number against the label rather than on its own. ## [Fix](#fix)Copy Anchor Link **Step 1: confirm the minutes are inside the job.** Compare queue time p90 against duration p90 for the same window. If queue wait moved, the work is capacity rather than idle. **Step 2: rank by duration p90 and read CPU ascending.** The long jobs with the lowest CPU are the candidates. Export the table to CSV from the report if you want to sort several months of rows in a spreadsheet. **Step 3: name the step.** Open the Usage view for one instance of a run near that p90, find the flat region, and read the GitHub Actions logs in the same view to see which step held it. **Step 4: classify against the four signatures above, then apply the matching lever.** * **Waiting on a service.** Move the wait into the platform. Health check options on a service container make the job wait for the container to report healthy before the first step runs, per the [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidservices), checked on 2026-08-13\. For anything the runner starts itself, replace the fixed wait with a readiness probe that returns the moment the port answers. * **Fixed sleeps.** Delete them. A `sleep 30` inserted to make a flaky step pass bills 30 seconds on every run, including the runs that needed none of it. Where a retry is genuinely required, back off from a short interval and cap the total wait. * **Polling loops.** The poll itself is usually cheap and the wait is not. Move the wait off the expensive machine into its own job on a small label, so the deployment gate stops renting 16 vCPU to run `curl` every ten seconds. * **Single-threaded step on a large machine.** Two levers. Shard the work across parallel jobs so the cores are used by concurrency, following [right sizing GitHub Actions runners](/guides/runner-right-sizing-github-actions) for the size decision. Or split the step into its own job on a smaller label, which is worth measuring first because it trades machine cost against an extra boot and a state handoff. **Step 5: re-measure against run count.** The Jobs report gives the run count next to the percentiles, so a job with 150 runs a week produces a trustworthy new p90 within days while a job with 12 runs a week needs a month. Every runner WarpBuild operates carries the same agent, so the telemetry has the same shape on all four platforms. A flat CPU trace with high disk I/O is a different diagnosis; see [why a job shows high IO wait](/answers/why-does-my-job-show-high-io-wait) for that one. ## [Configuration](#configuration)Copy Anchor Link Start by finding the sleeps, which are the cheapest source to remove and the easiest to miss in review: Copy Text ``` grep -rnE '(^|[^[:alnum:]_])sleep [0-9]+' .github/workflows scripts ``` Replace the ones guarding a service with a probe that exits as soon as the port answers: Copy Text ``` #!/usr/bin/env bash # scripts/wait-for-port.sh HOST PORT [TIMEOUT_SECONDS] set -euo pipefail host="$1" port="$2" deadline=$(( SECONDS + ${3:-120} )) until (exec 3<>"/dev/tcp/${host}/${port}") 2>/dev/null; do if (( SECONDS >= deadline )); then echo "timed out waiting for ${host}:${port}" >&2 exit 1 fi sleep 0.2 done echo "${host}:${port} answered after ${SECONDS}s" ``` The workflow below carries all three changes: health checked service containers so the job starts with its dependencies up, the probe in place of a fixed wait, and the deployment poll moved off the large runner into its own job: Copy Text ``` name: integration on: pull_request: jobs: integration: runs-on: warp-ubuntu-latest-x64-16x timeout-minutes: 30 services: postgres: image: postgres:17 env: POSTGRES_PASSWORD: postgres ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 3s --health-retries 20 redis: image: redis:7 ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 3s --health-retries 20 steps: - uses: actions/checkout@v5 - name: Start the application run: ./scripts/start-app.sh & - name: Wait for the application port run: ./scripts/wait-for-port.sh 127.0.0.1 8080 120 - name: Run the suite run: pytest -n 16 tests/ wait-for-rollout: needs: integration runs-on: warp-ubuntu-latest-x64-2x timeout-minutes: 20 steps: - name: Wait for the service to stabilize run: aws ecs wait services-stable --cluster prod --services api ``` The poll job keeps the same gate and pays $0.004 per minute instead of $0.032 while it waits. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link **Assumptions.** The `integration` job runs 600 times a month on `warp-ubuntu-latest-x64-16x` at $0.032 per minute from the [pricing page](/pricing), billed per minute. Duration p50 is 12.0 minutes. The segment timings below come from the Usage view for one run near that percentile. | Segment | Minutes | CPU during the segment | Source | | ------------------------------ | ------- | ---------------------- | ------------------------ | | Waiting for Postgres and Redis | 2.4 | 1 percent | Waiting on a service | | Fixed sleeps in retry helpers | 1.1 | 1 percent | Fixed sleeps | | aws ecs wait services-stable | 2.0 | 3 percent | Polling loop | | Schema migration | 3.0 | 6 percent | Single thread on 16 vCPU | | Test suite | 3.5 | 88 percent | Busy | **Baseline.** 600 runs at 12.0 minutes is 7,200 minutes at $0.032, which is $230.40 a month. The three blocked segments total 5.5 minutes per run, so 3,300 of those minutes and $105.60 of that bill are a 16 vCPU machine holding still, which is 46 percent of the invoice for this job. **After the three changes.** The readiness probe takes the service wait from 2.4 minutes to 0.4\. The fixed sleeps go to zero. The 2.0 minute deployment poll leaves the large runner for its own job. The main job is now 6.9 minutes: 600 runs at 6.9 minutes is 4,140 minutes at $0.032, which is $132.48\. The poll job bills 2.0 minutes plus about 0.3 minutes of boot at $0.004, which is 600 runs at 2.3 minutes for $5.52\. The pair costs $138.00 a month against $230.40, a saving of $92.40. Wall clock moves less than the bill does, because the poll still runs after the tests. End to end the pipeline drops 2.8 minutes per run, which is 1,680 minutes a month returned to the people waiting on the pull request, while billed minutes on the large runner drop by 5.1 per run. The migration is the open item. 3.0 minutes at 600 runs is 1,800 minutes and $57.60 a month spent renting 16 cores for one. The same 3.0 minutes plus a 0.3 minute boot on `warp-ubuntu-latest-x64-2x` bills $7.92, so the move is worth $49.68 a month if the step runs at the same speed on the smaller label. Measure that on your own workload before committing, because a migration that slows down under a smaller cache or a different disk gives the minutes straight back. For a list price baseline: `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price, per the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), GitHub list price checked on 2026-08-13\. The same 4,140 minutes of the fixed job bill $173.88 at that rate against $132.48 here. Idle minutes bill at the full rate on any runner, so the lower list price reduces what idle costs without removing any of it, and the segment table is where the removal happens. For the rest of the levers once the idle minutes are gone, see [reducing GitHub Actions costs](/guides/reduce-github-actions-costs). ## [FAQ](#faq)Copy Anchor Link ### [How do I tell idle time from queue time in a GitHub Actions job?](#how-do-i-tell-idle-time-from-queue-time-in-a-github-actions-job)Copy Anchor Link Queue time is the wait before the job starts and it sits outside the job in the Queue Timings report. Idle time is inside the job, so it lands in the duration numbers and on the invoice. If duration p90 grew while queue time p90 stayed flat, the extra minutes are inside the job and the runner utilization chart will show where. ### [What CPU number means a GitHub Actions job is idle?](#what-cpu-number-means-a-github-actions-job-is-idle)Copy Anchor Link A job whose CPU p90 sits in single digits while its duration p90 runs into minutes is blocked rather than working. A high CPU p90 does not clear a job, because that column is a percentile of each run's peak and one busy step sets the peak for the whole run, so open the per-instance chart before deciding. ### [Do idle minutes cost money?](#do-idle-minutes-cost-money)Copy Anchor Link Yes. Billing is per minute of runner time, so a step that sleeps bills the same as a step that compiles. On `warp-ubuntu-latest-x64-16x` at $0.032 per minute, 5.5 idle minutes across 600 runs a month is 3,300 minutes and $105.60. --- # Incremental Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/incremental-builds-github-actions Description: Incremental builds fail on GitHub Actions because each job gets a clean VM. Boot from a WarpBuild snapshot so build outputs and compiler state survive. # Incremental Builds on GitHub Actions Incremental builds fail on GitHub Actions because each job gets a clean VM. Boot from a WarpBuild snapshot so build outputs and compiler state survive. Last verified: 2026-08-13 Incremental builds fail on GitHub Actions because every job is provisioned on a clean VM that is destroyed when the job ends, so the build outputs and the fingerprint database an incremental compiler compares against are gone before the next run starts. The fix is to give the job a disk that already holds that state: a [WarpBuild snapshot runner](/runners/snapshot) captures the runner VM mid-workflow and later jobs boot from that image with the build tree, its timestamps, and the compiler caches in place. This guide covers why the failure happens, the exact `runs-on` and `snapshot-save` configuration that fixes it, the boundaries of the mechanism, and a time model comparing a full rebuild against an incremental run at a stated change size. ## [Diagnosis](#diagnosis)Copy Anchor Link An incremental build is a comparison. The build system reads the inputs, reads a record of what it produced last time, and rebuilds the difference. Three separate things have to be present on disk for that comparison to work, and a stateless runner supplies none of them. ### [The record of the last build does not exist](#the-record-of-the-last-build-does-not-exist)Copy Anchor Link Every incremental tool keeps a local database next to its outputs. TypeScript writes `.tsbuildinfo`. Cargo writes `target/*/.fingerprint`. Gradle keeps a file-hash cache under `~/.gradle`. Bazel keeps an action cache in its output base. ccache and sccache keep an object store keyed on hashed preprocessed source. CMake with Ninja keeps `.ninja_deps` and `.ninja_log` in the build directory. A GitHub Actions job starts from a base image with an empty workspace. The build system finds no database, so it does the only correct thing available to it and builds every target from scratch. Nothing is broken. The input it needs was deleted with the previous machine. ### [A fresh checkout rewrites every timestamp](#a-fresh-checkout-rewrites-every-timestamp)Copy Anchor Link This is the failure that survives a cache action, so it is worth separating out. `actions/checkout` writes a new working tree, and git sets file mtimes to the moment of the write. Every source file is now newer than every object file you restored. Tools that compare timestamps rebuild the whole tree on that basis: make, ninja, and Cargo all fall in this group. Tools that hash content survive better, which is why a restored ccache directory still produces object-cache hits and why Gradle and Bazel can reuse work after a restore. The split is worth knowing before you spend a week tuning cache keys, because on a timestamp-driven build no cache key change will help. ### [Machine state has no path into a cache action](#machine-state-has-no-path-into-a-cache-action)Copy Anchor Link The rest of the state is not a set of files you can name in a `path:` list. * A Gradle or Bazel daemon with a warm JVM, a populated in-memory file hash map, and a loaded project model. It dies with the machine. * The Docker daemon's local image store, holding base images and toolchain containers a build pulled during an earlier run. * Absolute paths baked into debug info, sccache configuration, and generated build files, which only line up when the tree sits back at the path it was written to. * Inode identity, which hardlink-based output stores rely on. ### [The restore itself is billed work](#the-restore-itself-is-billed-work)Copy Anchor Link The final term is transfer. A cache action compresses the build tree, uploads it, and downloads it again on the next run, and all of that time is charged as runner minutes. The transfer scales with the size of the tree while the change scales with the pull request, so a one-file change on a 6.5 GB build tree pays the same restore as a rewrite of the whole module. The same asymmetry drives checkout time, which the [guide to large repositories on GitHub Actions](/guides/large-repository-github-actions) covers separately. Measure the split before choosing a fix. From a recent run, take three numbers: seconds in the restore step, seconds between the end of the restore and the first line of compiler output, and the count of targets the build system decided to rebuild. If that last number is close to your total target count on a small pull request, the problem is the comparison, and no amount of cache tuning will move it. ## [Fix](#fix)Copy Anchor Link Stop restoring state into a machine and boot the machine that already has it. The pattern that the [snapshot runner documentation](/docs/ci/features/snapshot-runners) recommends splits the work by branch. Pushes to the default branch boot from the base image with `snapshot.enabled=true`, run a full build, clean the machine, and write a snapshot under an alias. Pull requests boot from that alias with `snapshot.key=` and compile only what their diff invalidated. The build tree comes back on the disk it was written to, at the same absolute path, with its original mtimes and inode identity, and with the fingerprint database sitting next to the outputs it describes. That is the full set of conditions the comparison needs. | At the first compile step | Stateless runner plus a cache action | Snapshot runner | | ---------------------------------- | ------------------------------------ | ------------------------------------------------ | | Source mtimes | Rewritten by checkout | Preserved from the run that produced the outputs | | Object files and generated headers | Restored when the key hits | Already on disk | | Fingerprint database | Restored, sensitive to path | In place at its original path | | Build daemon | Cold, rescans the tree | Cold, and its on-disk caches are intact | | Pulled container images | Pulled again | In the local image store | | Cost at job start | Grows with tree size | Flat 45 to 60 second boot plus $0.04 | ### [The boundaries](#the-boundaries)Copy Anchor Link Four limits decide whether this pattern fits a given job. `/tmp` does not persist. The directory is cleaned on reboot and a snapshot boot is a reboot, so a build that writes intermediates to `/tmp` gets nothing back. Point the build directory under `$HOME` or inside the workspace. Snapshots are deleted after 15 days. An alias that stops being refreshed expires, and the next job carrying that key boots from the base image and pays a full build once. Refresh the alias on a schedule if the branch that writes it goes quiet. Boot takes 45 to 60 seconds, which is slower than a base image boot. On a job whose setup is under a minute, the boot plus the $0.04 restore fee costs more than the state is worth, and the [guide to GitHub Actions cold starts](/guides/github-actions-cold-starts) is the better starting point for that shape of job. Stateless runners remain the better choice for release builds. A snapshot carries whatever earlier runs left on disk, which is exactly what you want on a pull request and exactly what you do not want on a tagged artifact. Keep release workflows on a plain `warp-ubuntu-latest-x64-16x` label with no snapshot directive so they build from a clean tree. Platform placement is the last check. Snapshots are supported on WarpBuild Cloud Ubuntu runners only. BYOC runs on AWS, GCP, and Azure, and snapshots are unsupported there too. A snapshot label on a BYOC, Windows, or macOS runner is silently ignored, so the job runs normally with no warning and no improvement in duration. Grep your workflows for that mistake before debugging anything else. ## [Configuration](#configuration)Copy Anchor Link A CMake and Ninja project, with a full build on `main` and incremental builds on pull requests: Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-16x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-16x;snapshot.key=service-build-main' }} steps: - name: Checkout code uses: actions/checkout@v5 - name: Configure run: cmake -S . -B $HOME/build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo - name: Build run: cmake --build $HOME/build --parallel 16 - name: Test run: ctest --test-dir $HOME/build --output-on-failure - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "service-build-main" fail-on-error: true wait-timeout-minutes: 60 ``` Two details in that file carry the incremental behavior. The build directory sits under `$HOME` rather than `/tmp`, so it survives the boot. And snapshots are only ever written from `main`, so a pull request cannot poison the image that other pull requests boot from, and the alias is rebuilt from the base image on every push to the default branch, which bounds drift. The `snapshot-save` action takes three inputs. `alias` is required and names the image later jobs boot from. `fail-on-error` defaults to `true` and turns a failed capture into a failed job. `wait-timeout-minutes` defaults to 30 and bounds the wait for capture to finish; large build trees are the reason to raise it to 60. ### [Branch on the boot source](#branch-on-the-boot-source)Copy Anchor Link A job booted from a snapshot carries `WARPBUILD_SNAPSHOT_KEY` set to the alias. Use it for work that only a cold machine needs: Copy Text ``` - name: Install system packages if: env.WARPBUILD_SNAPSHOT_KEY == '' run: sudo apt-get update && sudo apt-get install -y ninja-build ccache ``` ### [Key the alias to the things that invalidate everything](#key-the-alias-to-the-things-that-invalidate-everything)Copy Anchor Link One alias per build configuration keeps the image honest. A toolchain bump, a compiler flag change, or a switch of build type invalidates the entire tree anyway, so encoding those in the alias avoids a run that boots warm and then rebuilds from scratch without telling you: Copy Text ``` runs-on: warp-ubuntu-latest-x64-16x;snapshot.key=service-gcc14-relwithdebinfo ``` ### [Keep lockfile-keyed dependencies in a cache action](#keep-lockfile-keyed-dependencies-in-a-cache-action)Copy Anchor Link Package directories belong in a cache action rather than the image, so a lockfile bump invalidates one small entry instead of forcing a fresh full build. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4`, documented on the [WarpBuild caching page](/docs/ci/features/caching): Copy Text ``` - name: Restore vcpkg binary cache uses: WarpBuilds/cache@v1 with: path: ~/.cache/vcpkg/archives key: ${{ runner.os }}-vcpkg-${{ hashFiles('vcpkg.json') }} restore-keys: | ${{ runner.os }}-vcpkg- ``` Two operational notes. And run as many jobs as your workflows need, because generally available Linux and Windows runners do not have plan-level concurrency caps; a hundred pull request jobs can boot from one alias at the same time. Snapshot runners sit alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Assumptions, stated so you can substitute numbers from your own step timings: * A C++ service repository with 4,200 translation units and a 6.5 GB build tree, including objects, generated headers, and a compiler cache. * Runner `warp-ubuntu-latest-x64-16x`, 16 vCPU and 64 GB, at $0.032 per minute from the [pricing page](/pricing). * Change size: a pull request touching 12 sources and 2 shared headers, which the build graph resolves to 180 translation units, or 4.3 percent of the tree. * 600 pull request runs per month, and 22 pushes to `main` that each write a fresh snapshot. Start with the compile step alone, which is what the change size controls: | Compile step | Translation units built | Minutes | | --------------------------------------- | -------------------------------------------------- | ------- | | Full rebuild from an empty tree | 4,200 | 24.00 | | Clean VM with a restored compiler cache | 4,200 driver invocations, mostly object-cache hits | 7.20 | | Snapshot boot, incremental | 180 | 2.50 | The middle row is the one teams are surprised by. The files are back, so the compiler cache answers most invocations without doing real work, and the build system still walks all 4,200 units because the checkout rewrote their timestamps. Now the whole job: | Stage | Clean VM plus cache action | Snapshot runner | | --------------------- | -------------------------- | --------------- | | Boot | 0.25 min | 1.00 min | | Checkout | 0.50 min | 0.20 min | | Restore build tree | 2.60 min | 0.00 min | | Reconcile and rehash | 1.40 min | 0.30 min | | Compile | 7.20 min | 2.50 min | | Save state, amortized | 0.80 min | 0.18 min | | Total per run | 12.75 min | 4.18 min | The difference is 8.57 minutes per run. Across 600 runs that is 5,142 minutes per month, or about 86 hours, and those minutes sit at the front of a job where somebody is waiting on a check. ### [Priced out](#priced-out)Copy Anchor Link | Line | Clean VM plus cache action | Snapshot runner | | ----------------- | ------------------------------------ | ------------------------------------- | | Runner minutes | 7,650 at $0.032 = $244.80 | 2,508 at $0.032 = $80.26 | | Cache storage | 6.5 GB at $0.20 per GB-month = $1.30 | $0.00 | | Cache operations | 720 at $0.0001 = $0.07 | $0.00 | | Snapshot restores | $0.00 | 600 at $0.04 = $24.00 | | Snapshot storage | $0.00 | 720 snapshot-hours at $0.025 = $18.00 | | Monthly total | $246.17 | $122.26 | Two sensitivities decide whether that holds for you. Runner size moves the top line and leaves the $42.00 of snapshot fees fixed, so the same workload on `warp-ubuntu-latest-x64-4x` at $0.008 per minute removes only $41.14 of runner cost and the trade goes flat. Run count moves the runner line while snapshot storage stays at $18.00 per alias per month, so a repository running 60 jobs against an alias pays $0.30 per job for storage while one running 6,000 pays $0.003. ### [Against GitHub-hosted list prices](#against-github-hosted-list-prices)Copy Anchor Link GitHub-hosted runners have no snapshot mechanism, so the clean VM column is the only column available there. Rates below are from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13, next to the WarpBuild rate for the same shape: | Shape | GitHub-hosted per minute | WarpBuild label | WarpBuild per minute | | --------------- | ------------------------ | -------------------------- | -------------------- | | 8 vCPU, 32 GB | $0.022 | warp-ubuntu-latest-x64-8x | $0.016 | | 16 vCPU, 64 GB | $0.042 | warp-ubuntu-latest-x64-16x | $0.032 | | 32 vCPU, 128 GB | $0.082 | warp-ubuntu-latest-x64-32x | $0.064 | At the 16 vCPU shape, the same 7,650 monthly minutes of the clean VM path cost $321.30 on the GitHub-hosted larger runner against $244.80 on `warp-ubuntu-latest-x64-16x`, and against $122.26 for the snapshot path including its restore and storage fees. The model holds step timings identical across every column so that only the mechanism and the list price move. ## [FAQ](#faq)Copy Anchor Link ### [Why do incremental builds not work on GitHub Actions?](#why-do-incremental-builds-not-work-on-github-actions)Copy Anchor Link Every job is provisioned on a clean VM and the VM is destroyed when the job ends. The record an incremental build compares against, meaning object files, generated headers, and the fingerprint database the build system writes next to them, never survives to the next run, so the build system correctly decides that everything is out of date and compiles the whole tree. ### [Does a cache action make a build incremental again?](#does-a-cache-action-make-a-build-incremental-again)Copy Anchor Link Partly. A cache action restores the files, and tools that key on hashed content, such as ccache and sccache, reuse them. Tools that compare timestamps, such as make, ninja, and Cargo, see a working tree whose mtimes were rewritten by `actions/checkout` and treat every source file as newer than its output, so they rebuild anyway. ### [Is it safe to use snapshot runners on a public repository?](#is-it-safe-to-use-snapshot-runners-on-a-public-repository)Copy Anchor Link Only with a cleanup step. On a public repository anyone who can open a pull request can reference your alias and boot a runner from that image, and on a private repository WarpBuild provisions runners at the organization level, so a runner built for a snapshot job can be handed to another job in the organization. Run `rm -rf $HOME/.ssh $HOME/.aws` and `git clean -ffdx` before `snapshot-save` on every workflow that captures an image. ### [When should a build stay on a stateless runner?](#when-should-a-build-stay-on-a-stateless-runner)Copy Anchor Link Release and tagged builds that have to be reproducible from source. A snapshot accumulates whatever earlier runs left on disk, so a release job should boot from the base image and rebuild from a clean tree. Snapshots also carry a 45 to 60 second boot, are deleted after 15 days, and are supported on WarpBuild Cloud Ubuntu runners only. --- # Code Signing iOS Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/ios-code-signing-github-actions Description: Create a temporary keychain in the job, import the certificate from repository secrets, install the profile, then archive on a warp-macos runner label. # Code Signing iOS Builds on GitHub Actions Create a temporary keychain in the job, import the certificate from repository secrets, install the profile, then archive on a warp-macos runner label. Last verified: 2026-08-13 Code signing an iOS build on GitHub Actions means putting three things on a macOS runner that starts without any of them: a distribution certificate with its private key, a matching provisioning profile, and an unlocked keychain that `codesign` can read without an interactive prompt. The workflow does that in the job itself by creating a temporary keychain, importing a base64 certificate from repository secrets, writing the profile into the Xcode profile directory, and letting the ephemeral runner destroy all of it when the job ends. ## [Diagnosis](#diagnosis)Copy Anchor Link Signing failures on GitHub Actions cluster into four causes, and each one prints a recognizable string. Read the string first, because the fixes differ. | Symptom in the log | Usual cause | Where the fix lives | | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------- | | errSecInternalComponent or User interaction is not allowed | Keychain missing, locked, or imported without a partition list | Keychain setup step | | No profiles for 'com.example.App' were found | Profile not installed, or the bundle identifier and profile do not match | Profile install step | | Provisioning profile "X" doesn't include signing certificate "Y" | Certificate and profile come from different rotations | Secret rotation | | The certificate used to sign ... has either expired or has been revoked | Certificate past its validity window | Secret rotation | | unable to load certificate after base64 --decode | Secret is empty in this job | Secret scoping | **Missing or locked keychain.** A macOS runner has no interactive user and no unlocked login keychain. `codesign` reaches for the private key, macOS asks for authorization, and no one is there to click through it, so the step hangs until the job timeout fires or exits with `errSecInternalComponent`. Two commands prevent it: `security unlock-keychain` after creating the keychain, and `security set-key-partition-list` so `codesign` and `/usr/bin/security` are pre-authorized to use the imported key. **Provisioning profile mismatch.** Xcode matches a profile to a build by bundle identifier, team, capabilities, and the certificate embedded in the profile. A profile regenerated after a certificate rotation stops matching the older certificate still sitting in a secret, and the error names the profile rather than the secret. Check both halves together: `security find-identity -v -p codesigning` for what the keychain holds, and the profile's `DeveloperCertificates` array for what the profile expects. **Expired certificates.** Apple distribution certificates carry a fixed validity window, and a pipeline that signed cleanly for a year fails on a Tuesday with no workflow change in the diff. The certificate outlives the memory of whoever uploaded it, which is why the checklist further down records an expiry column next to every secret. **Secrets scoped to the wrong environment.** GitHub resolves an empty secret to an empty string rather than failing, so the first visible error arrives several steps later when `base64 --decode` writes a zero-byte file. Three scoping rules produce this: a secret defined on a GitHub Environment reaches only jobs that declare that `environment:`, an organization secret reaches only repositories on its access list, and a `pull_request` run triggered from a fork receives no secrets at all. A fork contributor opening a pull request against a workflow that signs on every push sees the same red job every time. One more pattern shows up in review of real iOS repositories: signing runs in jobs that never needed it. A unit test job on a simulator compiles fine with `CODE_SIGNING_ALLOWED=NO`, and every certificate import in that job is machine time and a second copy of the private key on a second machine. ## [Fix](#fix)Copy Anchor Link Keep signing inside one job, build every artifact from a secret at the start of the job, and let the runner lifecycle handle disposal. **Create the keychain in the job.** Generate a random password with `openssl rand`, create the keychain under `$RUNNER_TEMP`, unlock it, and add it to the user search list. Nothing here depends on state from a previous run. **Import the certificate from a secret.** Base64 encode the `.p12` locally, store it and its password as secrets, decode into `$RUNNER_TEMP` in the job, run `security import` with `-T /usr/bin/codesign`, then delete the decoded file. The `-T` flag and the follow-up `set-key-partition-list` call are what stop the authorization prompt. **Install the profile explicitly.** Decode the `.mobileprovision` secret into the Xcode profile directory. Xcode 16 and later read `~/Library/Developer/Xcode/UserData/Provisioning Profiles`, and older toolchains read `~/Library/MobileDevice/Provisioning Profiles`, so write to the directory that matches the Xcode you select in the job. **Sign manually.** Pass `CODE_SIGN_STYLE=Manual` and `PROVISIONING_PROFILE_SPECIFIER` to `xcodebuild` and point `OTHER_CODE_SIGN_FLAGS` at your keychain. Automatic signing wants an App Store Connect session and produces a different failure on a headless machine. **Verify before you archive.** A one-line `security find-identity -v -p codesigning` step fails in seconds when the certificate is wrong, ahead of a compile that bills for tens of minutes. **Move the runner label.** WarpBuild runners register with GitHub as self-hosted runners carrying `warp-` labels, so changing the value of `runs-on` is the whole migration for a signing job. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the macOS catalog carries multiple sizes and configurations per chip, so the archive job and the test job can sit on different sizes in one workflow file. The ephemeral runner facts are what make the whole pattern safe to repeat on every release. Each runner runs in its own virtual machine, created on demand and destroyed after each build. Each runner has its own encrypted storage volume, also created on demand and destroyed after each build. WarpBuild does not access or store any build secrets: they stay in your source code repository and reach only the runner environment. The isolation model is described in the [runner security documentation](/docs/ci/security), and the SOC 2 Type 2 report covering Security, Availability, and Confidentiality is available through the [WarpBuild trust center](https://trust.warpbuild.com/). ## [Configuration](#configuration)Copy Anchor Link The release workflow below runs on a 12 vCPU macOS label and does the full sequence: keychain, certificate, profile, verification, archive, export, upload, teardown. Copy Text ``` name: ios-release on: workflow_dispatch: push: tags: ["v*"] jobs: archive: runs-on: warp-macos-26-arm64-12x environment: release steps: - uses: actions/checkout@v4 - name: Select Xcode 27.0 run: sudo xcode-select -s "$(ls -d /Applications/Xcode_27*.app | tail -1)" - name: Create a temporary keychain and import the certificate env: SIGNING_CERT_P12: ${{ secrets.SIGNING_CERT_P12 }} SIGNING_CERT_PASSWORD: ${{ secrets.SIGNING_CERT_PASSWORD }} run: | KEYCHAIN="$RUNNER_TEMP/build.keychain-db" KEYCHAIN_PASSWORD="$(openssl rand -base64 24)" echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security set-keychain-settings -lut 3600 "$KEYCHAIN" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security list-keychains -d user -s "$KEYCHAIN" login.keychain-db echo "$SIGNING_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12" security import "$RUNNER_TEMP/cert.p12" \ -k "$KEYCHAIN" \ -P "$SIGNING_CERT_PASSWORD" \ -T /usr/bin/codesign \ -T /usr/bin/security rm -f "$RUNNER_TEMP/cert.p12" security set-key-partition-list \ -S apple-tool:,apple:,codesign: \ -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null - name: Install the provisioning profile env: PROVISIONING_PROFILE: ${{ secrets.PROVISIONING_PROFILE }} run: | PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" mkdir -p "$PROFILES" echo "$PROVISIONING_PROFILE" | base64 --decode \ > "$PROFILES/build.mobileprovision" - name: Verify the identity is visible to codesign run: security find-identity -v -p codesigning "$KEYCHAIN" - name: Archive run: | xcodebuild archive \ -scheme App \ -configuration Release \ -destination "generic/platform=iOS" \ -archivePath "$RUNNER_TEMP/App.xcarchive" \ -derivedDataPath "$RUNNER_TEMP/DerivedData" \ CODE_SIGN_STYLE=Manual \ DEVELOPMENT_TEAM=${{ vars.DEVELOPMENT_TEAM }} \ PROVISIONING_PROFILE_SPECIFIER="${{ vars.PROFILE_NAME }}" \ OTHER_CODE_SIGN_FLAGS="--keychain $KEYCHAIN" - name: Export the IPA run: | xcodebuild -exportArchive \ -archivePath "$RUNNER_TEMP/App.xcarchive" \ -exportOptionsPlist ExportOptions.plist \ -exportPath "$RUNNER_TEMP/export" - uses: actions/upload-artifact@v4 with: name: ipa path: ${{ runner.temp }}/export/*.ipa - name: Tear down the keychain if: always() run: security delete-keychain "$KEYCHAIN" || true ``` The pull request job stays on the smaller label and never sees a certificate: Copy Text ``` test: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v4 - run: | xcodebuild test \ -scheme AppTests \ -destination "platform=iOS Simulator,name=iPhone 16" \ CODE_SIGNING_ALLOWED=NO ``` `warp-macos-15-arm64-6x` also answers to the alias `warp-macos-latest-arm64-6x`, and `warp-macos-26-arm64-12x` carries Xcode 27.0 with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes. Xcode 27.0 is a WarpBuild addition on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 image is in beta, and a dedicated macOS 27 image follows once that image is released. The full label list lives in the [cloud runners documentation](/docs/ci/cloud-runners). ### [Signing artifact checklist](#signing-artifact-checklist)Copy Anchor Link Every artifact the job touches, where it lives, and how long it survives: | Artifact | Where it lives | How long it survives | | ----------------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------- | | Distribution certificate and private key, base64 .p12 | Repository or environment secret in GitHub | Until you rotate it or the certificate expires | | Certificate password | Repository or environment secret in GitHub | Until you rotate the certificate | | Decoded cert.p12 on the runner | $RUNNER\_TEMP on the job's encrypted volume | Deleted by rm in the same step, and the volume is destroyed after the build | | Temporary keychain build.keychain-db | $RUNNER\_TEMP on the job's encrypted volume | Deleted by the teardown step, and the volume is destroyed after the build | | Keychain password | Generated in the job by openssl rand | Lives in the job's process environment only | | Provisioning profile, base64 .mobileprovision | Repository or environment secret in GitHub | Until the profile expires or is regenerated | | Installed build.mobileprovision | Xcode profile directory in $HOME | Destroyed with the VM after the build | | App Store Connect API key .p8 | Repository or environment secret in GitHub | Until revoked in App Store Connect | | DerivedData and the .xcarchive | $RUNNER\_TEMP on the job's encrypted volume | Destroyed with the VM after the build | | Signed .ipa | Workflow artifact in GitHub | Repository artifact retention, 90 days by default | Three configuration facts decide whether the workflow above runs green on the first attempt. 1. **Secrets are scoped where you declared them.** The job declares `environment: release`, so environment secrets resolve. Drop that line and the same secrets read as empty strings. 2. **Fork pull requests receive no secrets.** Keep the signing job on `push` to tags or on `workflow_dispatch`, and leave fork pull requests to the unsigned test job. 3. **Runner storage is ephemeral.** Anything a later job needs goes to an artifact or a cache, because the next job starts on a different machine. For dashboard access control on top of that, SSO is available for a flat $250 per month, whatever the user count. When a signing step behaves differently on the runner than on a laptop, the Action Debugger pauses the workflow and opens an SSH session on the runner machine, which is the shortest route to inspecting a keychain or a profile in place. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The signing commands themselves are cheap. `security create-keychain`, `security import`, and `set-key-partition-list` finish in a couple of seconds. The cost of code signing on GitHub Actions is the archive job that wraps them, plus every re-run caused by an expired secret. ### [macOS labels and rates](#macos-labels-and-rates)Copy Anchor Link | Label | macOS | vCPU | Memory | Storage | Rate per minute | Alias | | ----------------------- | -------- | ---- | ------ | --------- | --------------- | --------------------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | | Rates come from the [WarpBuild pricing page](/pricing) and the cloud runners documentation, verified on 2026-08-13. The like-for-like comparison: `warp-macos-latest-arm64-6x` (6 vCPU, 14GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14GB), which is 22 percent lower list price, with one more vCPU and the same memory on the WarpBuild side. GitHub list price read from the [GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. ### [Worked monthly model](#worked-monthly-model)Copy Anchor Link Assumptions, all stated so you can substitute your own: * One iOS repository with one signed nightly build per working day, 20 working days in the month, 18 minutes per archive and export job. * Four App Store release builds in the month at 30 minutes each. * Unsigned pull request test jobs are excluded, since they carry no signing work. * Billing is per minute of job time. Signed macOS minutes: 20 x 18 = 360, plus 4 x 30 = 120, for **480 minutes per month**. | Configuration | Minutes | Monthly cost | Arithmetic | | ------------------------------------------------ | ------- | ------------ | ------------ | | GitHub-hosted macOS ARM64 larger runner | 480 | $48.96 | 480 x $0.102 | | warp-macos-15-arm64-6x | 480 | $38.40 | 480 x $0.08 | | warp-macos-26-arm64-12x at the same minute count | 480 | $76.80 | 480 x $0.16 | The 6 vCPU row is $10.56 per month below the GitHub-hosted row, or $126.72 over twelve months at the same volume. The 12 vCPU row is only worth its rate when the wider machine removes minutes: against the 6 vCPU label it has to finish the job in half the minutes to break even, and against the GitHub-hosted rate it has to finish in 63.75 percent of them. Measure the archive job on both labels for a week before you commit. Now price the failure mode. One archive job re-run per week from an expired certificate or a mis-scoped secret adds 4 x 18 = 72 minutes to the month, which is $5.76 at $0.08 per minute and $11.52 at $0.16\. That is the whole argument for the `security find-identity` verification step: it converts an 18-minute failed compile into a 10-second failed check. Release weeks are where concurrency shows up. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. For unusually high macOS concurrency, write to [support@warpbuild.com](mailto:support@warpbuild.com) before the release week rather than during it. For the runner catalog and the image details behind these labels, start with [WarpBuild macOS runners for GitHub Actions](/runners/macos). For the pipeline around the signing job, see [iOS builds on GitHub Actions with macOS runners](/solutions/ios-github-actions), and for the same job expressed as lanes, see [fastlane lanes on GitHub Actions](/solutions/fastlane-github-actions). For what the runner keeps after a job, see [do GitHub Actions runners store my source code](/answers/do-github-actions-runners-store-my-source-code). ## [FAQ](#faq)Copy Anchor Link **Why does codesign fail with "User interaction is not allowed" on a runner?** The signing key lives in a keychain that is either locked or missing a partition list, so `codesign` asks for an authorization the headless job cannot answer. Create the keychain in the job, unlock it, and run `security set-key-partition-list` with `-S apple-tool:,apple:,codesign:` before `xcodebuild archive`. **How do I get a signing certificate onto a GitHub Actions runner safely?** Store the `.p12` base64 encoded in a repository or environment secret and decode it into `$RUNNER_TEMP` inside the job. Each WarpBuild job runs on a freshly provisioned VM with its own encrypted storage volume that is destroyed after the build, and WarpBuild does not access or store build secrets, which stay in your repository and reach only the runner environment. **Do I need a cleanup step to delete the signing keychain?** The runner VM and its encrypted volume are destroyed when the job finishes, so nothing survives on the machine. An explicit `security delete-keychain` step with `if: always()` is still worth keeping, because it makes the same workflow safe to run on a long-lived machine and it fails loudly if the keychain path drifted. **Which macOS runner label should the signing job use?** Use `warp-macos-26-arm64-12x` at $0.16 per minute for an archive and export job that compiles Release configuration, and `warp-macos-15-arm64-6x` at $0.08 per minute for pull request test jobs that pass `CODE_SIGNING_ALLOWED=NO` and never touch a certificate. --- # Running iOS Simulator Tests on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/ios-simulator-tests-github-actions Description: Run iOS simulator tests on GitHub Actions with xcodebuild on a macOS runner. Pin the image that carries your runtime, then shard the suite at $0.08 per minute. # Running iOS Simulator Tests on GitHub Actions Run iOS simulator tests on GitHub Actions with xcodebuild on a macOS runner. Pin the image that carries your runtime, then shard the suite at $0.08 per minute. Last verified: 2026-08-13 iOS simulator tests run on GitHub Actions through `xcodebuild test` on a macOS runner, and two decisions set both the wall clock and the invoice: which simulator runtime the runner image carries, and how the suite is divided across jobs. On WarpBuild you put a `warp-macos-` label in `runs-on`, pin the image that ships the runtime your destinations name, build once with `build-for-testing`, then run `test-without-building` in a matrix at $0.08 per minute for the 6 vCPU size. ## [Diagnosis](#diagnosis)Copy Anchor Link Simulator jobs fail or run long for four reasons, and they are easy to tell apart from the log. **The runtime is missing from the image.** A destination specifier such as `platform=iOS Simulator,name=iPhone 17,OS=27.0` resolves only when that runtime is installed. When it is absent, `xcodebuild` exits with "Unable to find a destination matching the provided destination specifier" and lists the destinations it did find. Teams then paper over it by calling `xcodebuild -downloadPlatform iOS` inside the job, which works and adds several billed minutes to every single run. The correct fix is choosing a runner image that already carries the runtime. **Simulator boot time is billed time.** A cold `xcrun simctl boot` runs for a real fraction of a minute before the first test method executes, and a first-run device also creates its data container. A job that boots three destinations pays that cost three times. Multiply by a matrix and it stops being noise: five jobs booting one simulator each spend roughly five minutes a run on boot alone, which is 500 minutes a month at 100 runs. **The machine bounds parallel destinations, whatever the flag asks for.** `-parallel-testing-enabled YES` clones the simulator once per worker, and every clone is a full set of processes with its own memory. Asking for six workers on a 6 vCPU, 14GB runner produces a failure that reads as flakiness: tests that pass locally time out in the job, and the log carries "Lost connection to the test manager" or a testmanagerd restart. The usual cause is an oversubscribed runner rather than a bad test. **Shards are imbalanced.** The usual matrix splits test classes alphabetically or by target, which produces one shard carrying the slow UI tests and three that finish early. Wall clock is set by the slowest shard, so an imbalanced split buys a fraction of the improvement the shard count suggests, while the invoice charges for every shard including the ones that idle at the end. Per-job fixed overhead makes this worse: checkout, artifact download, and boot are repaid once per shard. One more pattern shows up in almost every repository that adopts a matrix: each shard runs `xcodebuild test`, which compiles the whole test bundle again. Four shards then compile the app four times, and compilation is the most expensive part of the job. ## [Fix](#fix)Copy Anchor Link The repair is four changes to the workflow and one change to the runner label. **Pin the image that carries your runtime.** WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the macOS catalog offers multiple sizes and configurations per chip. The macOS 26 labels ship Xcode 27.0 (build 27A266a) with these bundled simulator runtimes: | Platform | Simulator runtime | Carried by | | -------- | ----------------- | ----------------------------------------------- | | iOS | 27.0 (24A434) | warp-macos-26-arm64-6x, warp-macos-26-arm64-12x | | tvOS | 27.0 (24J360) | warp-macos-26-arm64-6x, warp-macos-26-arm64-12x | | watchOS | 27.0 (24R362) | warp-macos-26-arm64-6x, warp-macos-26-arm64-12x | | visionOS | 27.0 (24M362) | warp-macos-26-arm64-6x, warp-macos-26-arm64-12x | Runtime and build identifiers come from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. Xcode 27.0 is a WarpBuild addition on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. The `warp-macos-latest-arm64-6x` alias resolves to the macOS 15 image in step with GitHub's own `macos-latest` pointer, so a job that needs the 27.0 runtimes names the macOS 26 label explicitly. The rest of the tooling on each image matches its upstream GitHub image, listed in the [preinstalled software documentation](/docs/ci/preinstalled-software). **Build once and test many times.** Run `xcodebuild build-for-testing` in a single job, upload `Build/Products` and the generated `.xctestrun` file as an artifact, then have every shard run `test-without-building`. Compilation happens once per run instead of once per shard, which is the single largest saving available in a sharded layout. **Boot the destination once per job and wait for it properly.** `xcrun simctl bootstatus "iPhone 17" -b` blocks until the device is actually ready, which removes the retry loops teams add when the first test starts against a half-booted simulator. **Set parallelism to the size of the machine.** Two workers on the 6 vCPU, 14GB label and four on the 12 vCPU, 28GB label is a reasonable starting point, then adjust from the runner metrics. WarpBuild CI observability reports per-job CPU and memory utilization, which is how you tell a job that needed the wider machine from one that idled, and the Action Debugger gives an interactive session on a runner when a simulator failure reproduces only in the job. **Balance shards by measured duration.** Sort test classes by their historical time from the result bundle and deal them into shards round-robin, longest first, rather than splitting alphabetically. Feed the list to `-only-testing:` arguments per shard. Two operational notes. Jobs that need no Xcode at all, such as SwiftLint, changelog checks, and App Store metadata validation, belong on a Linux label. And macOS fan-out has a ceiling: generally available Linux and Windows runners do not have plan-level concurrency caps for Linux and Windows runners, while concurrency on the macOS fleet has been governed by per-organization quotas since July 27, 2026, with jobs beyond the quota waiting for capacity. If a release week needs a wider matrix than the quota allows, write to [support@warpbuild.com](mailto:support@warpbuild.com) first. ## [Configuration](#configuration)Copy Anchor Link The build job compiles the test bundle once on the macOS 26 image and publishes the products. Copy Text ``` name: ios-tests on: pull_request: branches: [main] concurrency: group: ios-tests-${{ github.ref }} cancel-in-progress: true jobs: build-for-testing: runs-on: warp-macos-26-arm64-12x steps: - uses: actions/checkout@v4 - name: Record the toolchain and available runtimes run: | xcodebuild -version xcrun simctl list runtimes xcodebuild -showdestinations -scheme AppTests | head -40 - name: Restore SPM checkouts uses: actions/cache@v4 with: path: ~/Library/Caches/org.swift.swiftpm key: spm-${{ hashFiles('**/Package.resolved') }} - name: Build for testing run: | xcodebuild build-for-testing \ -scheme AppTests \ -destination "platform=iOS Simulator,name=iPhone 17,OS=27.0" \ -derivedDataPath DerivedData \ CODE_SIGNING_ALLOWED=NO - uses: actions/upload-artifact@v4 with: name: test-products path: | DerivedData/Build/Products retention-days: 1 ``` The shard job downloads those products, boots one simulator, and runs its slice with parallel destinations enabled. Copy Text ``` test: needs: build-for-testing runs-on: warp-macos-26-arm64-6x strategy: fail-fast: false matrix: shard: - "-only-testing:AppTests/CheckoutTests -only-testing:AppTests/CartTests" - "-only-testing:AppTests/AccountTests -only-testing:AppTests/SearchTests" - "-only-testing:AppUITests/OnboardingUITests" - "-only-testing:AppUITests/PurchaseUITests" steps: - uses: actions/download-artifact@v4 with: name: test-products path: DerivedData/Build/Products - name: Boot the destination once run: | xcrun simctl boot "iPhone 17" || true xcrun simctl bootstatus "iPhone 17" -b - name: Run this shard run: | xcodebuild test-without-building \ -xctestrun DerivedData/Build/Products/*.xctestrun \ -destination "platform=iOS Simulator,name=iPhone 17,OS=27.0" \ -parallel-testing-enabled YES \ -maximum-concurrent-test-simulator-destinations 2 \ -resultBundlePath Shard.xcresult \ ${{ matrix.shard }} - uses: actions/upload-artifact@v4 if: always() with: name: results-${{ strategy.job-index }} path: Shard.xcresult ``` Three details decide whether this runs clean. The `.xctestrun` file has to come from the same Xcode as the shard job, so the build job and the shard jobs stay on the same macOS image family. `-maximum-concurrent-test-simulator-destinations` bounds in-machine cloning and is the knob to lower first when tests time out. And runner storage is ephemeral, so anything a later job needs travels as an artifact. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link macOS rates from the WarpBuild [pricing page](/pricing) and the runner catalog, checked on 2026-08-13: | Label | macOS image | vCPU | Memory | Storage | Rate per minute | | ----------------------- | ----------- | ---- | ------ | --------- | --------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | ### [Assumptions](#assumptions)Copy Anchor Link * One iOS repository, 300 pull request runs per month, which is 15 runs a day across 20 working days. * The suite takes 40 minutes of test execution when it runs on one simulator. * Fixed work per run: 1 minute of checkout and package resolution, 6 minutes of compilation, 1 minute of simulator boot. * Fixed work per shard: 1 minute of checkout, 1 minute of artifact download, 1 minute of simulator boot. * Four parallel workers inside one machine finish the 40 test minutes in 14 minutes rather than 10, because the clones share one disk and one CPU package. * Billing is per minute of job time. Cancelled runs, reruns, and artifact storage are excluded so the runner minute is the only variable. ### [Three layouts](#three-layouts)Copy Anchor Link | Layout | Label | Billed minutes per run | Wall clock per run | Cost per run | 300 runs per month | | ----------------------------- | ----------------------- | ---------------------- | ------------------ | ------------ | ------------------ | | Serialized, one job | warp-macos-15-arm64-6x | 48 | 48 minutes | $3.84 | $1,152.00 | | Build once plus 4 shards | warp-macos-15-arm64-6x | 60 | 21 minutes | $4.80 | $1,440.00 | | One job, 4 in-machine workers | warp-macos-26-arm64-12x | 22 | 22 minutes | $3.52 | $1,056.00 | The arithmetic behind each row: * Serialized: 1 + 6 + 1 + 40 = 48 minutes, all of it on the critical path. 48 x $0.08 = $3.84, and 300 x $3.84 = $1,152.00. * Sharded: the build job is 1 + 6 + 1 upload = 8 minutes, and each of the four shards is 1 + 1 + 1 + 10 = 13 minutes. Billed total 8 + (4 x 13) = 60 minutes at $4.80 a run, wall clock 8 + 13 = 21 minutes. 300 x $4.80 = $1,440.00. * In-machine workers: 1 + 6 + 1 + 14 = 22 minutes on the 12 vCPU label. 22 x $0.16 = $3.52, and 300 x $3.52 = $1,056.00. Sharding across jobs cuts 27 minutes off every pull request and adds $288.00 a month against the serialized row. Running the workers inside one wider machine lands within a minute of sharding's wall clock, 22 versus 21, for $384.00 a month less than it, which makes it the first thing to measure when the suite targets a single destination. Sharding across jobs wins when the shards target different runtimes, because one job cannot boot iOS and tvOS destinations in parallel efficiently on a 6 vCPU machine. Now the imbalance tax. Keep the sharded row and split the 40 test minutes as 16, 12, 8, and 4 instead of evenly. The longest shard job becomes 3 + 16 = 19 minutes, wall clock becomes 8 + 19 = 27 minutes, and billed minutes stay at 60\. An imbalanced split costs 6 minutes of feedback time per run and nothing on the invoice, which is why shard balancing is worth automating from result bundle timings rather than guessing. ### [Against GitHub-hosted list prices](#against-github-hosted-list-prices)Copy Anchor Link `warp-macos-latest-arm64-6x` (6 vCPU, 14GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14GB), which is 22 percent lower list price with one more vCPU and the same memory. GitHub list price read from the [GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. Carried through the sharded row, 300 runs at 60 billed minutes is 18,000 macOS minutes a month: $1,836.00 at GitHub's list rate against $1,440.00 at the WarpBuild rate, a difference of $396.00 a month at identical workflow shape. SSO is available for a flat $250 per month, whatever the user count, and it does not change the per-minute rates. Once the workflow is live, replace the model with measured numbers. The reports view and reporting API expose runner label, execution time, billed time, and cost per job, so a script can rebalance the matrix from measured shard timings and costs. For the full macOS catalog and regional detail, start at [WarpBuild macOS runners for GitHub Actions](/runners/macos). For image-by-image toolchain coverage, see [Xcode versions and simulator runtimes per macOS image](/runners/macos/xcode-images). For the compilation half of the problem, see [cut Xcode build times on GitHub Actions](/guides/xcode-build-times-github-actions), and for fan-out beyond the test job, see [run iOS jobs in parallel on GitHub Actions](/guides/parallel-ios-jobs-github-actions). ## [FAQ](#faq)Copy Anchor Link **Why does xcodebuild fail with "Unable to find a destination matching the provided destination specifier"?** The simulator runtime named in the destination is missing from the image the runner booted. Print `xcrun simctl list runtimes` and `xcodebuild -showdestinations -scheme YourScheme` in the job, then pin a label whose image carries the runtime. On WarpBuild, `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` ship the iOS 27.0 runtime with Xcode 27.0\. Downloading a runtime at job time with `xcodebuild -downloadPlatform iOS` works and adds billed minutes to every run. **Which simulator runtimes ship on the WarpBuild macOS 26 image?** iOS 27.0 (24A434), tvOS 27.0 (24J360), watchOS 27.0 (24R362), and visionOS 27.0 (24M362). They are bundled with Xcode 27.0 (build 27A266a) on the `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` labels, on top of the Xcode versions in the upstream GitHub macOS 26 image. **Does sharding a simulator suite across a matrix reduce the bill?** No. Sharding reduces wall clock and increases billed minutes, because every shard repays checkout, artifact download, and simulator boot. In the model on this page a 48-minute serialized job becomes 21 minutes of wall clock and 60 billed minutes across five jobs, which moves the run from $3.84 to $4.80 at $0.08 per minute. **How many simulator destinations can run in parallel on one macOS runner?** Each parallel worker is a cloned simulator with its own processes, so memory and vCPU set the ceiling. Start with two workers on the 6 vCPU, 14GB label and four on the 12 vCPU, 28GB label, controlled by `-parallel-testing-enabled YES` and `-maximum-concurrent-test-simulator-destinations`. Across jobs, macOS concurrency is governed by per-organization quotas, so a very wide matrix can queue. --- # Caching Jest Runs on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/jest-cache-github-actions Description: Jest re-transforms every module on a fresh GitHub Actions runner. Pin cacheDirectory, restore it with a key that survives dependency bumps, and size maxWorkers. # Caching Jest Runs on GitHub Actions Jest re-transforms every module on a fresh GitHub Actions runner. Pin cacheDirectory, restore it with a key that survives dependency bumps, and size maxWorkers. Last verified: 2026-08-13 Jest keeps a transform cache on disk, and a GitHub Actions runner is destroyed when its job ends, so every run re-transforms every module the suite imports. Pin `cacheDirectory` to a stable path, restore and save it with a cache action keyed on the lockfile and the transformer configuration, and set `--maxWorkers` to the vCPU count of the runner label. This guide covers what actually sits in the Jest cache directory, the key design that keeps an entry useful after a dependency bump, the workflow file with the worker count matched to the runner size, and the arithmetic on a suite of a stated size. For the wider question of carrying state between runs, see the [guide to persistent caches for GitHub Actions runs](/guides/persistent-caches-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link Measure before editing the workflow. A slow `jest` step has three common causes, and they respond to different fixes. ### [Every module is transformed again on every run](#every-module-is-transformed-again-on-every-run)Copy Anchor Link Jest transforms each module it loads, applying `babel-jest`, `ts-jest`, or an SWC transformer, and writes the result to disk. The on-disk entry is keyed by a hash over the file contents, the transformer, the transformer configuration, and the Jest version, so a warm directory turns transformation into a file read. Confirm how much of the step is transformation. Run the suite twice locally, once after clearing the cache: Copy Text ``` npx jest --showConfig | grep cacheDirectory npx jest --clearCache time npx jest --ci --maxWorkers=8 time npx jest --ci --maxWorkers=8 ``` The gap between the two timings is the transform work a warm cache removes. That number, and the module count from the run summary, are the inputs to the model at the end of this page. ### [The default cache directory is outside anything you cache](#the-default-cache-directory-is-outside-anything-you-cache)Copy Anchor Link Jest puts the cache under the operating system temp directory. On a Linux runner that path sits inside `/tmp` and carries a user-derived suffix, so it is awkward to name in a workflow file and no dependency cache covers it. Point `cacheDirectory` at the workspace instead, which makes the path stable across runners and across local machines. ### [Three artifacts share that directory](#three-artifacts-share-that-directory)Copy Anchor Link | Artifact | What it holds | What invalidates it | | --------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | jest-transform-cache- | One file per transformed module plus a companion hash file | Module contents, transformer, transformer config, or Jest version changes | | haste-map-.json | The crawl of the file tree: paths, sizes, modification times, module and mock names | Files added, removed, or touched | | Sequencer timing file | Per test file duration and pass or fail state from the previous run | The next completed run overwrites it | The transform cache is the part worth carrying between runs. The haste map is cheap to rebuild on a small repository and expensive on a large one, and it rides along in the same directory at no extra effort. The sequencer file is what lets Jest start the slowest test files first, which shortens the tail on a wide worker pool. ### [The worker pool is one worker short](#the-worker-pool-is-one-worker-short)Copy Anchor Link Jest runs one worker fewer than the reported CPU count. That default protects an interactive laptop and wastes a core on a GitHub Actions runner, where the job owns the machine. On a small shape the loss is severe: a 2 vCPU runner runs a single worker. Separating a transform-bound step from a memory-bound one is where WarpBuild CI observability helps. It sits alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger in the product surface, and it correlates runner system metrics with job logs, so a step that pins every core looks different from a step where workers are being killed and restarted. ## [Fix](#fix)Copy Anchor Link Three changes, in this order. **1\. Pin the cache directory.** Add it to `jest.config.js` and to `.gitignore`: Copy Text ``` module.exports = { cacheDirectory: '/.jest-cache', }; ``` **2\. Restore and save that directory.** `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` and takes the same inputs, as the [caching documentation](/docs/ci/features/caching) describes. Use the split `restore` and `save` form so the save runs only on the default branch. The Jest cache is a separate entry from the package manager store. `WarpBuilds/setup-node@v6` already caches `~/.npm`, the yarn cache folder, or the pnpm store, and those paths hold packed tarballs rather than transformed modules. Keep both entries, because they change on different schedules: the store moves when the lockfile moves, the transform cache moves whenever source files move. The [answer on caching node\_modules](/answers/how-do-i-cache-node-modules-in-github-actions) covers the store side. **3\. Set the worker count.** Pass `--maxWorkers` explicitly and match it to the runner label rather than to whatever the container reports. ## [Configuration](#configuration)Copy Anchor Link The whole workflow: Copy Text ``` name: test on: pull_request: push: branches: [main] jobs: jest: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm cache-dependency-path: package-lock.json - run: npm ci - name: Restore Jest cache id: jest-cache uses: WarpBuilds/cache/restore@v1 with: path: .jest-cache key: jest-${{ runner.os }}-node22-${{ hashFiles('package-lock.json', 'babel.config.js', 'jest.config.js') }}-${{ github.run_id }} restore-keys: | jest-${{ runner.os }}-node22-${{ hashFiles('package-lock.json', 'babel.config.js', 'jest.config.js') }}- jest-${{ runner.os }}-node22- - name: Test run: npx jest --ci --maxWorkers=8 - name: Save Jest cache if: github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: .jest-cache key: ${{ steps.jest-cache.outputs.cache-primary-key }} ``` ### [The key that survives a dependency bump](#the-key-that-survives-a-dependency-bump)Copy Anchor Link Hash three files together. The lockfile belongs in the key because the transformer is a dependency: `babel-jest`, `ts-jest`, the SWC binary, and every Babel preset arrive through the lockfile, and a version change alters the transform output for every module. `babel.config.js` and `jest.config.js` belong there because a preset or a `transform` entry changes the same output without touching the lockfile. The `github.run_id` suffix makes the primary key unique per run, so a save always writes a fresh entry that includes whatever the run transformed. Restores fall through the two shorter prefixes: the first returns the newest directory built from the same dependency and config set, the second returns the newest directory for the same runner and Node version after a dependency bump. That second tier is the one that matters, and it is safe because Jest keys each module internally. Restoring a directory built before a Babel upgrade costs nothing beyond storage: Jest recomputes its own hash per module, misses on the modules the upgrade affected, and reuses the rest. A stale entry degrades to a partial hit rather than a wrong result. Only the newest entry is ever restored, so superseded entries stop being read and expire 7 days after their last use. The `delete-cache` input on the cache action prunes them sooner when a repository holds more than it needs. ### [Workers matched to the runner size](#workers-matched-to-the-runner-size)Copy Anchor Link | Runner label | vCPU | RAM | \--maxWorkers | Heap headroom per worker | | -------------------------- | ---- | ------ | ------------- | ------------------------ | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 2 | 4 GB | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 4 | 4 GB | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 8 | 4 GB | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 16 | 4 GB | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 32 | 4 GB | Every Linux size holds 4 GB per worker at full width, which is enough for most suites. When workers are killed and restarted, set `workerIdleMemoryLimit` so Jest recycles a worker on its own terms, or drop `--maxWorkers` by a step. `--maxWorkers` also accepts a percentage, so `--maxWorkers=100%` tracks the label without a second edit when the size changes. The same worker arithmetic applies on each: read the vCPU count of the label from the [cloud runners documentation](/docs/ci/cloud-runners) and pass it through. Past the largest single shape, split the suite instead of buying width. `jest --shard=1/4` divides test files across jobs, each with its own restored cache directory; the [guide to matrix sharding on GitHub Actions](/guides/matrix-sharding-github-actions) covers the fan-out, and the [guide to sizing runners for Node test suites](/guides/sizing-runners-for-node-test-suites) covers where one large runner beats four small ones. In a monorepo, give each package its own `cacheDirectory` and hash that workspace's lockfile in its key, so a change in one workspace leaves the other entries warm. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Substitute your own module count and timings from the diagnosis section. The assumptions below describe one suite: * 1,200 test files importing 6,500 distinct modules, TypeScript transformed by `babel-jest`. * Jobs on `warp-ubuntu-latest-x64-8x` with `--maxWorkers=8`. * 30 runs per weekday over 22 weekdays, so 660 runs per month. * Cold transform pass across all 6,500 modules: 3.20 minutes. * Warm path: 0.45 minutes to download and decompress a 340 MB entry, plus 0.15 minutes to transform the modules a pull request actually changed. * The save runs on the default branch only, roughly 66 times per month at 0.50 minutes, which amortizes to 0.05 minutes per run. | Path | Minutes per run | Monthly runner minutes | Monthly runner cost | | ------------- | --------------- | ---------------------- | ------------------- | | No Jest cache | 3.20 | 2,112 | $33.79 | | Cached | 0.65 | 429 | $6.86 | Add the cache fees. About 15 entries stay resident at any moment, since a superseded entry lives out its 7 day expiry, and 15 entries at 340 MB is 5.1 GB, which bills at $0.20 per GB-month for $1.02\. The 660 restores and 66 saves are 726 operations at $0.0001 each, another $0.07\. The cached path lands at $7.95 per month against $33.79 uncached, a difference of $25.84\. On BYOC runners, cache storage and operations are included, so only the runner minutes column applies. Two levers stack, and it helps to keep them separate. The cache removes minutes; the list price sets what each remaining minute costs. Holding the cached 429 minutes fixed, `warp-ubuntu-latest-x64-8x` costs $0.016 per minute against $0.022 for the 8-core Linux larger runner at the same 8 vCPU and 32 GB shape, which is 27 percent lower list price and $6.86 against $9.44 for the month ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). WarpBuild rates come from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners). ## [FAQ](#faq)Copy Anchor Link ### [Where does Jest store its cache on a GitHub Actions runner?](#where-does-jest-store-its-cache-on-a-github-actions-runner)Copy Anchor Link Under the operating system temp directory by default, which on a Linux runner means a path inside `/tmp` that carries a user-derived suffix. Run `jest --showConfig` and read the `cacheDirectory` value to see the resolved path. Because that path is unstable and lives outside the workspace, set `cacheDirectory` explicitly in `jest.config.js` to a path such as `/.jest-cache` before you point a cache action at it. ### [What cache key keeps the Jest cache valid when dependencies change?](#what-cache-key-keeps-the-jest-cache-valid-when-dependencies-change)Copy Anchor Link Hash the lockfile together with the Jest and transformer configuration files, because the transformer version and its presets are dependencies and they change the transform output. Add a per-run suffix to the primary key so every save writes a fresh entry, then list two shorter prefixes under `restore-keys` so a config change still restores the previous directory. A partially stale directory is safe, because Jest computes its own per module key and re-transforms only the modules whose inputs changed. ### [How many Jest workers should I run on a GitHub Actions runner?](#how-many-jest-workers-should-i-run-on-a-github-actions-runner)Copy Anchor Link Jest defaults to one worker fewer than the reported CPU count, which leaves a core idle on a runner that runs nothing but the job. Set `--maxWorkers` to the vCPU count of the runner label, so 8 on `warp-ubuntu-latest-x64-8x` and 16 on `warp-ubuntu-latest-x64-16x`. If workers are killed by the out-of-memory killer, drop the count or set `workerIdleMemoryLimit` so Jest restarts a worker once its heap passes the limit. --- # Large Artifact Uploads on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/large-artifact-uploads-github-actions Description: Artifact uploads run slow because upload-artifact zips the whole path at compression level 6 before any byte moves. Cut the file count, bytes, and minutes. # Large Artifact Uploads on GitHub Actions Artifact uploads run slow because upload-artifact zips the whole path at compression level 6 before any byte moves. Cut the file count, bytes, and minutes. GitHub Actions artifact uploads run slow because `actions/upload-artifact` assembles everything under the path into one Zip archive at compression level 6 before a single byte leaves the runner, and that pass pays a fixed cost for every file in the tree. Three levers fix it: shrink the file count the archive step sees by tarring first, drop the compression level when the payload is already compressed, and split one monolithic artifact into artifacts scoped to their consumers. This page covers where the minutes actually go inside an upload step, the workflow change that removes them, the configuration that keeps a matrix from colliding on artifact names, and a transfer model that prices artifact size against upload minutes with the registry alternative in the same table. It sits under the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link An upload step charges for three things, and they scale on different axes. **Archive construction.** All files under the path are assembled into a single immutable Zip archive, and the [upload-artifact documentation](https://github.com/actions/upload-artifact) states there is currently no way to download an artifact in another format or to download individual contents from it. The archive is built with Zlib at a `compression-level` that [defaults to 6](https://github.com/actions/upload-artifact), the same as GNU Gzip, on a scale of 0 to 9\. Higher levels compress better and take longer. The documentation recommends 0 for large files that do not compress well, because the deflate pass costs CPU and returns almost nothing. **Per-file overhead.** The archive pass opens, stats, deflates, and writes a central directory entry for every file it finds. That cost is fixed per file and independent of file size. A `dist` tree with 62,000 small files and source maps pays 62,000 of them; one 1.8 GB tarball pays one. This is why a 300 MB JavaScript build directory can take longer to upload than a 2 GB single-file installer. **Transfer.** Only the third cost moves bytes, and it scales with post-compression size and with runner throughput. On most upload steps that feel slow, this is the smallest of the three. The consumer side doubles the bill. Because there is no partial download, a job that needs a 4 MB JUnit report out of a 620 MB monolithic artifact downloads and unzips all 620 MB. Fan-out multiplies that: one producer and five consumers means six passes over the same archive for one commit. Four documented constraints shape what you can do about it, all checked on 2026-08-13 against the [upload-artifact README](https://github.com/actions/upload-artifact): * Each job has a limit of 500 artifacts. * Uploading to the same artifact name more than once fails, because artifacts created by v4 are immutable. Matrix legs need distinct names. * File permissions are dropped during a zipped upload. All directories land at 755 and all files at 644, so an executable stops being executable after download. * From v4.4 onward, hidden files are excluded by default and need `include-hidden-files: true` to travel. Measure four numbers before changing a line. | Question | How to get it | What it decides | | --------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------- | | Bytes under the upload path | du -sb dist in the step before the upload | The transfer share | | File count under the path | find dist -type f \| wc -l | The per-file overhead share | | Time inside the upload step | Step duration in the job log, or the Duration P75 and P90 columns in [Reports](/docs/ci/features/reports) | The baseline the model gets compared to | | Consumers per artifact | Count the download-artifact steps naming it | The fan-out multiplier | The Jobs section of Reports aggregates duration and queue time per repository, workflow, and job name, with CSV export, which is the fastest way to see whether the upload job or the download jobs carry the regression. ## [Fix](#fix)Copy Anchor Link **1\. Tar the tree before the upload step.** One stream replaces one Zip entry per file, so the archive cost moves from file count to bytes. Tar also keeps permissions and case sensitivity that the Zip path drops. **2\. Compress once, with a codec you choose.** `tar -I 'zstd -3'` produces a compact bundle quickly. Then set `compression-level: 0` on the upload so the action stores the bundle instead of deflating already compressed data a second time. **3\. Split the monolith into artifacts scoped to consumers.** A deploy job wants the app bundle. A reviewer wants the JUnit XML. A coverage gate wants one LCOV file. Three artifacts let each consumer download only its own payload, which the no-partial-download rule makes impossible inside one artifact. **4\. Narrow the path.** Exclude source maps, `.git`, intermediate object directories, and test fixtures the consumers never open. Every excluded file removes both an archive entry and a download byte. **5\. Set `retention-days` to match the purpose.** A within-run handoff needs a day or two. Ninety days is the default, and the retention window is covered in [how long GitHub Actions artifacts are kept](/answers/how-long-are-github-actions-artifacts-kept). **6\. Skip artifact storage entirely for container payloads.** When the build output is going to become an image anyway, build and push it to your registry from a [remote Docker builder](/docs/ci/docker-builders) and have downstream jobs pull it. Layers that did not change are not re-pushed or re-pulled, which is a different scaling curve from re-zipping the whole tree every run. The promotion pattern around that is in [build once, deploy many in GitHub Actions](/guides/build-once-deploy-many-github-actions). ## [Configuration](#configuration)Copy Anchor Link The workflow below archives once, uploads two targeted artifacts, and hands the bundle to a deploy job. Copy Text ``` name: build on: pull_request: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - run: ./build.sh - name: Archive the app bundle run: tar -I 'zstd -3 -T0' -cf app-bundle.tar.zst -C dist . - name: Upload the app bundle uses: actions/upload-artifact@v4 with: name: app-bundle path: app-bundle.tar.zst compression-level: 0 retention-days: 7 - name: Upload the test report uses: actions/upload-artifact@v4 with: name: test-report path: reports/junit/*.xml if-no-files-found: error retention-days: 30 deploy: needs: build runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/download-artifact@v4 with: name: app-bundle - run: | mkdir -p dist tar -I zstd -xf app-bundle.tar.zst -C dist ``` Three details carry the design. `compression-level: 0` matters because the tarball is already zstd compressed, and deflating it again spends CPU for a rounding error of size. `-T0` lets zstd use every core on the label, which is where a larger runner buys back archive time. And `if-no-files-found: error` on the report upload turns a silently empty artifact into a failed job, since the default is `warn`. Matrix builds need one more rule. Artifact names must be unique across the run, so every leg carries the matrix values in its name: Copy Text ``` strategy: matrix: runner: [warp-ubuntu-latest-x64-8x, warp-macos-latest-arm64-6x] target: [debug, release] steps: - uses: actions/upload-artifact@v4 with: name: bundle-${{ matrix.runner }}-${{ matrix.target }} path: app-bundle.tar.zst compression-level: 0 ``` A later job pulls the whole set with `actions/download-artifact@v4` and a `pattern: bundle-*` plus `merge-multiple: true`, so fan-in costs one step instead of one step per leg. Platform coverage decides how much of this you need. The permission-loss rule bites hardest on the macOS legs: an `.app` bundle uploaded as a plain Zip artifact comes back with every file at 644, so a signed binary stops being executable and codesign verification fails downstream. Tarring first is what keeps that leg working, and the packaging side of it is in [publishing build artifacts from GitHub Actions](/guides/build-artifacts-publishing-github-actions). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Model inputs, all replaceable with numbers from your own run history: * A `dist` tree of 1,800 MB across 62,000 files. * Archive at level 6: 2.5 ms of fixed cost per file plus 45 MB per second of deflate throughput. Output 620 MB. * Archive with `tar -I 'zstd -3 -T0'`: 0.4 ms per file plus 380 MB per second. Output 640 MB. * The store-only upload pass at `compression-level: 0` moves 350 MB per second. * 120 MB per second of sustained transfer to and from artifact storage. * Unzip writes 62,000 files at 1.2 ms each; tar extraction writes them at 0.5 ms each. * One producer, five consumers, 700 runs per month. * `warp-ubuntu-latest-x64-8x` at $0.016 per minute, from the [pricing page](/pricing), checked on 2026-08-13. | Line | One artifact at level 6 | Split artifacts, tar and zstd | | ------------------------------- | ---------------------------------- | ------------------------------------------------------------ | | Files entering the archive step | 62,000 | 1 | | Archive time | 3.25 min | 0.49 min | | Store-only upload pass | not applicable | 0.03 min | | Bytes transferred up | 620 MB | 640 MB | | Upload transfer time | 0.09 min | 0.09 min | | Producer step total | 3.34 min | 0.61 min | | Consumer work | 5 downloads and unzips at 1.33 min | 1 bundle extract at 0.61 min, 4 report downloads at 0.01 min | | Consumer total | 6.65 min | 0.65 min | | Runner minutes per run | 9.99 | 1.26 | | Cost per run | $0.16 | $0.02 | | 700 runs per month | $111.89 | $14.11 | The split column removes 8.73 minutes of runner time per run and $97.78 per month at this shape. Most of that comes from the consumer row rather than the producer row, because the no-partial-download rule made four jobs pay for a payload they never opened. Now the registry alternative in the same units. A remote Docker builder at 16 vCPU and 32 GB lists at $0.06 per minute on the [pricing page](/pricing), checked on 2026-08-13, and it is billed for the session rather than for the runner it serves. | Handoff path | Producer minutes | Consumer minutes | Cost per run | 700 runs per month | | ------------------------------------------ | ----------------------------- | ---------------- | ------------ | ------------------ | | One monolithic artifact at level 6 | 3.34 runner | 6.65 runner | $0.16 | $111.89 | | Split artifacts, tar and zstd | 0.61 runner | 0.65 runner | $0.02 | $14.11 | | Registry push from a remote Docker builder | 0.40 runner plus 0.40 builder | 0.75 runner | $0.04 | $29.68 | The registry row costs more per run than split artifacts for a single in-run handoff, and it wins on a different axis. Layers that did not change are not pushed and not pulled, so the marginal run moves a delta rather than a fresh 640 MB bundle, and the same digest is addressable from a deploy workflow days later instead of expiring with the artifact retention window. Pick the registry when the payload is going to become an image anyway or when consumers live outside the run; pick split artifacts when the payload is reports, binaries, and bundles that only the run itself reads. Label choice is the last variable, and it only helps the archive row. Rates below come from the [pricing page](/pricing), checked on 2026-08-13: | Runner label | vCPU | RAM | Price per minute | | --------------------------- | ---- | ----- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | | warp-macos-latest-arm64-6x | 6 | 14 GB | $0.08 | The Zip pass inside `upload-artifact` is single-stream, so moving up a size does nothing for the level 6 column. A `tar -I 'zstd -3 -T0'` pass is parallel, so it does scale with vCPU, which is the specific reason the fix above is worth more on a 16x label than the original step was. One egress note for teams whose artifacts live in object storage rather than in GitHub. On the enterprise tier, runners pull large artifacts from ECR, S3, and similar stores with zero egress cost to you, whether the runners sit in your cloud or ours; the scope is on [zero egress for the enterprise tier](/enterprise/zero-egress). ## [FAQ](#faq)Copy Anchor Link ### [Why is artifact upload slow in GitHub Actions?](#why-is-artifact-upload-slow-in-github-actions)Copy Anchor Link Because the upload step does two jobs. It assembles every file under the path into one Zip archive at Zlib compression level 6 by default, then transfers the result. The archive pass pays a fixed cost per file, so a tree of tens of thousands of small files spends most of the step walking and deflating rather than moving bytes across the network. ### [Should I set compression-level to 0 for artifact uploads?](#should-i-set-compression-level-to-0-for-artifact-uploads)Copy Anchor Link Set it to 0 when the payload is already compressed, such as a `tar.zst` bundle, a video, or a signed installer. The upload-artifact documentation recommends 0 for large files that do not compress well, because the deflate pass costs CPU time and returns almost no size reduction. Keep the default of 6 for plaintext, and reserve 9 for cases where storage matters more than minutes. ### [Is it faster to tar a directory before uploading it as an artifact?](#is-it-faster-to-tar-a-directory-before-uploading-it-as-an-artifact)Copy Anchor Link For trees with many files, yes. A tar pass writes one stream instead of one Zip entry per file, so the archive step scales with bytes rather than with file count. Tar also preserves file permissions and case sensitivity, which the Zip path drops by setting all directories to 755 and all files to 644. --- # GitHub Actions on Large Repositories | WarpBuild URL: https://www.warpbuild.com/guides/large-repository-github-actions Description: Large repositories run slow on GitHub Actions because every job rebuilds a cold working set. Measure the setup phases, then keep the tree warm. # GitHub Actions on Large Repositories Large repositories run slow on GitHub Actions because every job rebuilds a cold working set. Measure the setup phases, then keep the tree warm. Last verified: 2026-08-13 GitHub Actions is slow on a large repository because every job starts on a clean VM and rebuilds the entire working set before the build begins: fetch the history, write tens of thousands of files to disk, unpack a multi-gigabyte cache archive, and reinstall dependencies. The fix is to measure those setup phases separately from the build, then carry the warm working tree between runs with snapshot runners and size the runner label for disk throughput and memory rather than for cores alone. ## [Diagnosis](#diagnosis)Copy Anchor Link Four phases carry the cost, and they respond to different changes. Split them into separate workflow steps so the GitHub Actions log prints a duration for each one, then read which phase dominates. **Clone and fetch.** The transfer from GitHub scales with history depth, submodule count, and LFS object volume. A repository with a decade of history moves gigabytes of packfiles per job, and a matrix of twenty jobs multiplies that by twenty against the same GitHub endpoint. This page treats the fetch as one line item; the fetch itself, including depth, submodules, LFS, and mirror strategies, is covered in [speeding up git checkout in GitHub Actions](/guides/faster-git-checkout-github-actions). **Working tree materialization.** After the objects arrive, git writes the files. This phase is dominated by file count rather than by repository size in bytes, and it runs largely single threaded. A tree of 300,000 small files costs more here than a tree of 400 large ones at the same total volume, and no amount of extra vCPU changes that. **Cache archive size.** A cache entry for a large repository is a tar of a dependency store, a build output directory, or both. Restoring it means downloading the archive and decompressing it onto the same disk the checkout is using. Above a few gigabytes the restore stops being a fast path and starts competing with the checkout for disk bandwidth. Watch for the case where the archive is written on every pull request instead of on the default branch, since that doubles the traffic and evicts the entry everything else restores from. **Disk pressure.** Every WarpBuild Linux label carries 150GB SSD, from `warp-ubuntu-latest-x64-2x` through `warp-ubuntu-latest-x64-32x`. Moving up a size adds vCPU and RAM while the disk stays at 150GB. Runner storage is ephemeral on every platform and is deleted when the runner terminates, so the budget resets each job. Write the disk budget out once for your heaviest job. The example below is the repository used through the rest of this page. Substitute your own numbers. | Item on disk | Example size | | -------------------------------------- | ------------ | | .git directory after fetch | 5 GB | | Working tree | 14 GB | | Dependency store | 8 GB | | Build output directory | 40 GB | | Container images pulled during the job | 18 GB | | Job total | 85 GB | Against 150GB that leaves room, until the base image and preinstalled toolchains take their share and a second build configuration lands in the same output directory. Jobs that fail late with a write error, or that slow down as the disk fills, are usually reading this table rather than a CPU graph. ### [Measure the phases before changing anything](#measure-the-phases-before-changing-anything)Copy Anchor Link Here is the shape the measurement usually produces on `warp-ubuntu-latest-x64-8x` at 8 vCPU and 32 GB. Cold means a clean runner with no cache restored. Warm means the job booted from a snapshot that already holds the tree. The build column is held constant on purpose, because build time belongs to a different lever. | Phase | Cold minutes | Warm minutes | | --------------------------------------- | ------------ | ------------ | | Clone and fetch from GitHub | 3.8 | 0.5 | | Working tree materialization | 1.7 | 0.2 | | Dependency install | 3.0 | 0.3 | | Cache archive download and unpack, 6 GB | 2.5 | 0.0 | | Setup subtotal | 11.0 | 1.0 | | Build and test | 14.0 | 14.0 | | Job total | 25.0 | 15.0 | One run tells you very little on a repository this size, since a cold registry, a busy mirror, or an unlucky cache miss moves any single number. Read the aggregate instead. The [WarpBuild reports documentation](/docs/ci/features/reports) describes the Jobs section, which aggregates every unique repository, workflow, and job name and reports Duration P75 and P90, Queue Time P75 and P90, and, with CI observability enabled, CPU P75 and P90 and Memory P75 and P90\. Filter by runner label, export the CSV, and compare the same job before and after the change. A job whose CPU P90 sits low while Duration P90 stays high is waiting on disk or network, which points at the phases above rather than at the machine size. ## [Fix](#fix)Copy Anchor Link Three levers apply here, and they are independent of each other. **Carry the working set between runs.** Snapshot runners capture the runner VM disk mid-workflow and boot a later job from that image, so the `.git` directory, the working tree, the dependency store, and pulled container images are already present. That collapses the 11.0 minute setup column above to about 1.0 minute, because the fetch has only new commits to transfer and the install step reconciles an existing tree. Snapshot runners are one part of the product surface, alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger. Three boundaries decide whether this lever is available to a given job. Snapshots run on WarpBuild Cloud Ubuntu runners only, and the labels are silently ignored on macOS, Windows, and BYOC runners. Every snapshot is deleted after 15 days. A snapshot boot takes 45 to 60 seconds, which is slower than a default runner boot, so the pattern pays on jobs with real setup work and loses on short ones. **Size the label for the phase that dominates.**. On Linux, disk stays at 150GB across every size, so a move up buys vCPU, RAM, and the throughput ceiling of a larger instance class. Two of those matter for large repositories. Storage and network throughput ceilings on cloud instances scale with instance size, so a 16x or 32x label can shorten an archive unpack or a large fetch on a job that never saturates its cores. And RAM decides whether a 14 GB working tree stays in page cache across the steps that read it repeatedly, which is the difference between 32 GB on the 8x label and 64 GB on the 16x label. | Label | vCPU | RAM | Storage | Rate per minute | | -------------------------- | ---- | ------ | --------- | --------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Rates come from the [WarpBuild pricing page](/pricing), verified on 2026-08-13\. ARM64 carries the same sizes and the same 150GB SSD at $0.003 to $0.048 per minute. Windows labels carry 256GB SSD and the 12 vCPU macOS label carries 270GB SSD, which is the option when the working set itself will not fit on a Linux runner, though snapshots are unavailable on both. **Lower the rate at the same size.** Runner images carry the same tooling as GitHub-hosted runners, so a job moves by editing one `runs-on` value. Same-size list prices, GitHub rates read from the [GitHub Actions per-minute rate reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and checked on 2026-08-13: | vCPU | GitHub-hosted label | GitHub rate | WarpBuild label | WarpBuild rate | Difference | | ---- | ------------------------------------- | ----------- | -------------------------- | -------------- | --------------------------- | | 2 | ubuntu-latest on private repositories | $0.006 | warp-ubuntu-latest-x64-2x | $0.004 | 33 percent lower list price | | 4 | 4-core Linux larger runner | $0.012 | warp-ubuntu-latest-x64-4x | $0.008 | 33 percent lower list price | | 8 | 8-core Linux larger runner | $0.022 | warp-ubuntu-latest-x64-8x | $0.016 | 27 percent lower list price | | 16 | 16-core Linux larger runner | $0.042 | warp-ubuntu-latest-x64-16x | $0.032 | 24 percent lower list price | | 32 | 32-core Linux larger runner | $0.082 | warp-ubuntu-latest-x64-32x | $0.064 | 22 percent lower list price | Two more facts shape a large-repository rollout. Run as many jobs as your workflows need, since generally available Linux and Windows runners do not have plan-level concurrency caps, which is what lets a wide test matrix start at once instead of queueing behind a plan limit. And if the runners have to sit inside your own cloud account, BYOC runs on AWS, GCP, and Azure, with Terraform support for BYOC on AWS; snapshot labels are ignored there, so a BYOC job leans on checkout tuning and cache archives. ## [Configuration](#configuration)Copy Anchor Link The pattern separates the writer from the readers. Pushes to the default branch boot clean with `snapshot.enabled=true` and publish a fresh image. Every other run boots from that alias with `snapshot.key=` and finds the tree already on disk. Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=platform-worktree' }} steps: - name: Report boot source run: echo "booted from ${WARPBUILD_SNAPSHOT_KEY:-base image}" - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build run: pnpm build - name: Test run: pnpm test - name: Remove credentials before the snapshot if: github.ref == 'refs/heads/main' run: rm -rf $HOME/.ssh $HOME/.aws $HOME/.npmrc $HOME/.docker/config.json - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "platform-worktree" fail-on-error: false wait-timeout-minutes: 60 ``` Six details decide whether this behaves the way the table above predicts. 1. **The label carries the setting.** Append `snapshot.enabled=true` or `snapshot.key=` to the runner label after a semicolon. `snapshot.enabled=true` always boots from the base image and is the label for the job that produces a clean image. `snapshot.key=` boots from the existing snapshot for that alias, falling back to the base image when none exists. 2. **What resets on every boot.** The machine reboots, so no process from the earlier job is running, no port is listening, and no container is up. `/tmp` is cleaned on reboot, so keep anything you want to survive under `$HOME` or inside the workspace. GitHub hands the job a fresh runner registration with its own `GITHUB_*` environment, secrets, and token, so the snapshot supplies the disk while GitHub supplies the job identity. 3. **Checkout still runs.** `actions/checkout` reuses an existing checkout when the directory already holds the right repository, so on a warm boot the fetch transfers only the commits added since the snapshot was written. Keep the step in the workflow. 4. **Skip `git clean -ffdx` in this pattern.** The command removes gitignored paths, which is exactly the dependency store and build output the snapshot exists to carry. Remove credential files by path instead, as the workflow above does, and read the security notes in the [snapshot runners documentation](/docs/ci/features/snapshot-runners) before rolling this out on a public repository or across an organization. 5. **Raise the save timeout for a large disk.** `wait-timeout-minutes` defaults to 30, which is generous for a dependency tree and tight for tens of gigabytes of build output. `fail-on-error: false` keeps a failed capture from turning the pipeline red. 6. **Refresh the alias often.** Snapshots are deleted after 15 days, and a stale alias drifts from the branch it was taken on. A job pointing at an expired or missing alias still runs; it boots from the base image and pays the full setup on that run. The build side of a warm tree, meaning compiler state and incremental output, is covered in [incremental builds on GitHub Actions](/guides/incremental-builds-github-actions). Fanning a large repository across several sized labels is covered in [monorepo pipelines on GitHub Actions](/solutions/monorepo-github-actions), and the full snapshot reference including break-even tables per size is on [snapshot runners for GitHub Actions](/runners/snapshot). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Assumptions, all stated so you can substitute your own: * One repository at the size in the disk budget above: 5 GB of history and a 14 GB working tree. * 1,200 jobs per month on `warp-ubuntu-latest-x64-8x`. * Cold job 25.0 minutes, warm job 15.0 minutes, from the phase table. * One snapshot alias kept alive for the whole month. * Build and test time is identical in both columns, so the model measures setup only. Minutes: 1,200 x 25.0 = 30,000 cold minutes per month against 1,200 x 15.0 = 18,000 warm minutes. | Line | Cold runners | Warm from one snapshot alias | | ------------------------------------ | ------------ | ---------------------------- | | Billed minutes per job | 25.0 | 15.0 | | Runner cost per job at $0.016 | $0.400 | $0.240 | | Snapshot restore per job | $0.000 | $0.040 | | Cost per job | $0.400 | $0.280 | | 1,200 jobs | $480.00 | $336.00 | | Snapshot storage, one alias, 30 days | $0.00 | $18.00 | | Monthly total | $480.00 | $354.00 | Snapshot restore bills $0.04 per job that boots from a snapshot and snapshot storage bills $0.025 per snapshot-hour, which is $0.60 per day and $18.00 per 30 day month for one alias. Spread across 1,200 jobs that storage line is $0.015 per job. Both rates are on the [pricing page](/pricing), verified on 2026-08-13, and both appear as their own columns on the CI Billing report next to runner cost. The break-even test is one division. A restore costs $0.04 regardless of size, so on the 8x label at $0.016 per minute it has to remove 2.5 minutes of work to pay for itself. This job removes 10.0 minutes, which clears the bar by a wide margin. On `warp-ubuntu-latest-x64-2x` at $0.004 per minute the same restore has to remove 10.0 minutes, which is why small runners with light setup should stay stateless. For an external reference point, the same 25.0 minute cold job on the 8-core GitHub-hosted Linux larger runner at 8 vCPU and 32 GB lists at $0.022 per minute: 30,000 minutes is $660.00 per month. GitHub list price from the [GitHub Actions per-minute rate reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. GitHub-hosted runners have no snapshot equivalent, so that column has no restore or storage line and no warm variant. The wall clock number is the one engineers feel. Ten minutes returned on 1,200 jobs is 12,000 minutes per month, or 200 hours of waiting on pull requests that the team gets back. Confirm it with Duration P75 and P90 for that job in the Jobs report rather than with the fastest run you happen to see, and check Queue Time P75 and P90 in the same view so a change in wait time does not get read as a change in build time. Where the model breaks: a repository whose pull requests routinely invalidate the dependency store puts the install phase back into the warm column, an alias refreshed only weekly drifts far enough that the fetch phase grows again, and a job under 2.5 minutes of removable setup on the 8x label loses to the restore fee. Rerun the arithmetic per workflow with your own numbers from the CI Billing report before adding a snapshot label across a repository. ## [FAQ](#faq)Copy Anchor Link **Why are GitHub Actions jobs slow on a large repository?** Because every job starts on a clean VM and pays for the whole working set before the build starts: the fetch from GitHub, writing tens of thousands of files to disk, unpacking a multi-gigabyte cache archive, and reinstalling dependencies. Measure those four phases as separate steps first, since the one that dominates decides which fix is worth the effort. **Do snapshot runners work for a large repository on macOS or Windows?** No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. A snapshot label on a macOS, Windows, or BYOC runner is silently ignored and the job runs normally at full setup cost, so those jobs rely on a narrow checkout and a cache archive instead. **Should a large-repository job move to a 16x or 32x runner?** Move up when the job is waiting on storage and network throughput or thrashing page cache, since throughput ceilings on cloud instances scale with instance size and `warp-ubuntu-latest-x64-16x` carries 64 GB of RAM against 32 GB on the 8x label. A job that sits at low peak CPU while it unpacks an archive is buying throughput rather than cores. **How much disk does a WarpBuild Linux runner have?** Every Linux label carries 150GB SSD, from `warp-ubuntu-latest-x64-2x` through `warp-ubuntu-latest-x64-32x`, so moving up a size adds vCPU and RAM while the disk stays the same. Windows labels carry 256GB SSD and the 12 vCPU macOS label carries 270GB SSD. --- # Long Running Jobs on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/long-running-jobs-github-actions Description: A GitHub Actions job that runs for hours meets three ceilings. Find the one that ended yours, split the work into checkpointed stages, and price both shapes. # Long Running Jobs on GitHub Actions A GitHub Actions job that runs for hours meets three ceilings. Find the one that ended yours, split the work into checkpointed stages, and price both shapes. Last verified: 2026-08-13 A GitHub Actions job that runs for hours ends in one of three ways: the `timeout-minutes` value cancels it, a platform execution limit cancels it, or one step hangs and consumes the rest of the job's budget before the later steps get to run. The fix is to stop shipping the work as a single job and split it into stages that checkpoint their state and hand it forward, so a failure at hour four costs one stage rather than the whole run. This guide names the three ceilings and how to tell which one you hit, gives the workflow YAML for a checkpointed pipeline, and prices one long job against the staged version of the same work. ## [Diagnosis](#diagnosis)Copy Anchor Link Start by separating the three ceilings, because the remedy differs for each and the log looks similar for two of them. Values below were checked on 2026-08-13 against [GitHub's limits reference](https://docs.github.com/en/actions/reference/limits), the [workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidtimeout-minutes), and the [automatic token authentication guide](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication). | Ceiling | Value | What it bounds | | ----------------------------------- | ----------- | ------------------------------------------------- | | Job timeout-minutes default | 360 minutes | Any job with no timeout-minutes key | | Step timeout-minutes maximum | 360 minutes | The step level key | | Job execution, GitHub-hosted runner | 6 hours | Execution on GitHub's fleet | | Job execution, self-hosted runner | 5 days | Execution on a registered runner | | Job queue time, self-hosted runner | 24 hours | Waiting before a runner picks the job up | | Workflow run time | 35 days | The run as a whole, including waits and approvals | | GITHUB\_TOKEN lifetime | 24 hours | Credentials held by a running job | ### [The timeout you did not set](#the-timeout-you-did-not-set)Copy Anchor Link `jobs..timeout-minutes` defaults to 360\. A job that legitimately needs seven hours is cancelled at minute 360 with no configuration mistake anywhere in the file. Read the run duration first, because a job that ends at exactly 360 minutes ended on the default, and the answer on [how to set a timeout on a GitHub Actions job](/answers/how-do-i-set-a-timeout-on-a-github-actions-job) covers the job and step keys that replace it. ### [The platform limit under the timeout](#the-platform-limit-under-the-timeout)Copy Anchor Link The lowest applicable ceiling wins, so raising `timeout-minutes` past the runner's execution limit changes nothing. Which limit applies depends on the pool. GitHub's own fleet stops a job at 6 hours. Registered runners get 5 days, and WarpBuild registers itself as a self-hosted runner in the `Default` runner group (id `1`) of your organization, per the [public repositories documentation](/docs/ci/public-repos), so a WarpBuild job runs under the 5 day ceiling rather than the 6 hour one. That headroom is worth less than it sounds. Five days on `warp-ubuntu-latest-x64-16x` at $0.032 per minute is 7,200 minutes and $230.40 for one job nobody is watching, which is why every stage in the configuration below carries its own `timeout-minutes`. The `GITHUB_TOKEN` ceiling is the one that surprises people on long jobs. The token issued for a run expires after at most 24 hours, so a job planning to push a tag at hour 30 loses its credentials before the execution limit ever fires. The [job timeout glossary entry](/glossary/job-timeout) lists that ceiling next to the others. ### [The step that hangs](#the-step-that-hangs)Copy Anchor Link A job with a job level timeout and no step level timeouts spends its entire remaining budget inside whichever step blocks. A network fetch against a dead mirror, an integration suite waiting on a service that never binds, or a prompt on stdin all present the same way: one step in the timing view holds the tail of the run and the steps after it never start. Two checks separate a long job from a stuck one. Compare the step timings across the last five runs and look for the step whose duration jumped rather than grew. Then confirm the clock was running at all, because a job waiting for a runner has not started executing and no timeout applies to it; the [common issues documentation](/docs/ci/common-issues) covers runner group access, bot permissions, and workflow restrictions, which are the usual reasons a job sits unclaimed. ## [Fix](#fix)Copy Anchor Link Split the work at boundaries where the finished state is cheap to hand forward, and give every stage a timeout it can actually hit. Three transports move state between stages, and the right one depends on the size of what survives. * **Artifacts.** `actions/upload-artifact` and `actions/download-artifact` work from any runner and any platform. Upload and download time bills as runner minutes, so this fits reports, binaries, and test fixtures rather than a 30 GB build tree. * **Cache actions.** Keyed restores fit dependency directories that change on a lockfile bump. Cache storage bills $0.20 per GB-month and cache operations $0.0001 each, from the [pricing page](/pricing). * **Snapshot runners.** A [snapshot runner](/runners/snapshot) captures the whole runner disk mid-workflow and later jobs boot from that image, which is the transport that fits an entire working tree. Restores bill $0.04 per job and storage $0.025 per snapshot-hour. Booting from a snapshot takes 45 to 60 seconds, snapshots are deleted after 15 days, and the feature is supported on WarpBuild Cloud Ubuntu runners only, per the [snapshot runners documentation](/docs/ci/features/snapshot-runners). The `/tmp` directory is cleaned on reboot and a snapshot boot is a reboot, so the work directory belongs under `$HOME`. Four rules make the split hold up in practice. **Cut where the state is small or the disk is large.** A boundary that produces a 40 MB artifact wants `upload-artifact`. A boundary that leaves a compiled tree, a populated Docker image store, and a warm dataset on disk wants a snapshot, since none of that fits a `path:` list. **Make every stage idempotent and guard it with a checkpoint marker.** Each stage writes `$HOME/.checkpoints/.done` containing `github.sha`, and the next stage fails fast when the marker is missing or holds a different commit. A re-run keeps the same `GITHUB_SHA` and `GITHUB_REF` as the original event ([re-running workflows and jobs](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs), checked on 2026-08-13), so a marker keyed on the commit still validates on the retry and rejects yesterday's alias. **Size the timeout per stage from its own p95.** A single 360 minute value spread across four stages tells you nothing about which one hung. **Keep the stage count low.** Every stage pays a boot, a restore, and a queue wait. Four stages out of a five hour job is a reasonable ratio; twenty is a pipeline that spends its savings on overhead. Work that runs long because it is repeating itself needs a different fix. Boot the machine that already holds the previous result, following [incremental builds on GitHub Actions](/guides/incremental-builds-github-actions), and start from the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions) for the ordinary caching, sizing, and parallelism moves before splitting anything. ## [Configuration](#configuration)Copy Anchor Link A nightly simulation pipeline, roughly 320 minutes of work, cut into four stages. Stage one boots from the base image and saves an alias; each later stage boots from the alias the previous stage wrote and saves its own. Copy Text ``` name: nightly-simulation on: schedule: - cron: "0 2 * * *" workflow_dispatch: jobs: compile: runs-on: warp-ubuntu-latest-x64-16x;snapshot.enabled=true timeout-minutes: 100 steps: - uses: actions/checkout@v5 - name: Build the simulator run: make -j16 -C $HOME/work simulator - name: Write checkpoint run: | mkdir -p $HOME/.checkpoints echo "${{ github.sha }}" > $HOME/.checkpoints/compile.done - name: Cleanup credentials run: rm -rf $HOME/.ssh $HOME/.aws - uses: WarpBuilds/snapshot-save@v1 with: alias: nightly-compile fail-on-error: true wait-timeout-minutes: 60 dataset: needs: compile runs-on: warp-ubuntu-latest-x64-16x;snapshot.key=nightly-compile timeout-minutes: 105 steps: - name: Verify checkpoint run: | test "$(cat $HOME/.checkpoints/compile.done)" = "${{ github.sha }}" - name: Generate the dataset run: $HOME/work/bin/gen-dataset --out $HOME/work/data - name: Write checkpoint run: echo "${{ github.sha }}" > $HOME/.checkpoints/dataset.done - uses: WarpBuilds/snapshot-save@v1 with: alias: nightly-dataset fail-on-error: true wait-timeout-minutes: 60 simulate: needs: dataset runs-on: warp-ubuntu-latest-x64-16x;snapshot.key=nightly-dataset timeout-minutes: 120 steps: - name: Verify checkpoint run: | test "$(cat $HOME/.checkpoints/dataset.done)" = "${{ github.sha }}" - name: Run the simulation run: $HOME/work/bin/simulate --data $HOME/work/data --out $HOME/work/runs - name: Write checkpoint run: echo "${{ github.sha }}" > $HOME/.checkpoints/simulate.done - uses: WarpBuilds/snapshot-save@v1 with: alias: nightly-simulate fail-on-error: true wait-timeout-minutes: 60 report: needs: simulate runs-on: warp-ubuntu-latest-x64-16x;snapshot.key=nightly-simulate timeout-minutes: 95 steps: - name: Verify checkpoint run: | test "$(cat $HOME/.checkpoints/simulate.done)" = "${{ github.sha }}" - name: Summarize run: $HOME/work/bin/report --runs $HOME/work/runs --out report.html - uses: actions/upload-artifact@v4 if: always() with: name: simulation-report path: report.html ``` Four details carry the behavior. `snapshot.enabled=true` boots from the base image and turns the feature on, while `snapshot.key=` turns it on and boots from that alias when one exists, which is the pairing the snapshot documentation describes. `fail-on-error: true` turns a capture that did not finish into a failed stage, so a later stage never boots from a stale alias behind a green predecessor. The work directory sits under `$HOME` rather than `/tmp`. And the credential cleanup runs before the first capture, since anything on disk at capture time is in the image every later stage boots. When stage three fails, `gh run rerun RUN_ID --failed` restarts it from the `nightly-dataset` alias with the same commit SHA. A run stays re-runnable for 30 days and accepts at most 50 re-runs. This shape is Linux only. Snapshots are supported on WarpBuild Cloud Ubuntu runners only, so a macOS or Windows pipeline hands state forward through artifacts and caches instead. Snapshot runners sit alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger in the product surface, and every stage above stays a plain GitHub Actions job. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Assumptions, stated so you can substitute your own timings: * 320 minutes of work per nightly run, 30 runs a month, on `warp-ubuntu-latest-x64-16x` at $0.032 per minute from the [pricing page](/pricing). * Six of the 30 runs fail. In the single job shape a failure lands on average at minute 250 and the retry repeats all 320 minutes. In the staged shape the failure lands 50 minutes into stage four, and the retry repeats only that stage. * The staged shape splits into four stages averaging 80 minutes, each paying a one minute boot, and three snapshot restores per run. * Three aliases stay live continuously at $0.025 per snapshot-hour, which is 3 x 730 hours = 2,190 snapshot-hours a month. | Line | One long job | Four checkpointed stages | | ----------------------------------- | ----------------------- | ------------------------ | | Minutes, 24 clean runs | 24 x 320 = 7,680 | 24 x 324 = 7,776 | | Minutes, 6 failed runs plus retries | 6 x (250 + 320) = 3,420 | 6 x (293 + 81) = 2,244 | | Total minutes | 11,100 | 10,020 | | Runner cost at $0.032 | $355.20 | $320.64 | | Snapshot restores at $0.04 | $0.00 | 96 restores = $3.84 | | Snapshot storage at $0.025 per hour | $0.00 | 2,190 hours = $54.75 | | Monthly total | $355.20 | $379.23 | The staged pipeline costs $24.03 more a month at this failure rate, and that is the honest reading of the trade. What it buys is recovery time. A failed night runs 570 minutes end to end in the single job shape and 374 in the staged one, because the retry is one 81 minute stage rather than a fresh 320 minute run, so the result lands 196 minutes earlier and no stage sits anywhere near a platform ceiling. The break-even moves with the failure count. Each failure costs the single job 250 extra minutes and the staged pipeline 50, a difference of $6.40\. The staged pipeline carries $62.62 a month of fixed overhead, being $7.87 of boots and restores (126 boot-minutes at $0.032 plus 96 restores at $0.04) plus $54.75 of storage, so it turns cheaper past roughly 10 failed runs a month. Below that, split for the ceilings and the feedback loop rather than for the invoice. ### [Against GitHub-hosted list prices](#against-github-hosted-list-prices)Copy Anchor Link `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), GitHub list price checked on 2026-08-13). The same 11,100 minutes of the single job shape bill $466.20 at that rate against $355.20 here. Neither column changes the ceilings, and the 6 hour execution limit on GitHub's fleet leaves a 320 minute job with 40 minutes of headroom before a slow night becomes a cancelled one. ## [FAQ](#faq)Copy Anchor Link ### [What is the longest a GitHub Actions job can run?](#what-is-the-longest-a-github-actions-job-can-run)Copy Anchor Link Six hours on a GitHub-hosted runner and five days on a self-hosted runner, with the whole workflow run capped at 35 days, per [GitHub's limits reference](https://docs.github.com/en/actions/reference/limits) checked on 2026-08-13\. A job also stops at its own `timeout-minutes` value, which defaults to 360 minutes, so the default cancels a job on a self-hosted runner long before the platform ceiling does. ### [How do I resume a GitHub Actions job that failed after four hours?](#how-do-i-resume-a-github-actions-job-that-failed-after-four-hours)Copy Anchor Link There is no resume for a single job. Split the work into stages chained with `needs`, have each stage write a checkpoint the next one boots from, and re-run the failed stage with `gh run rerun RUN_ID --failed`. A re-run keeps the same `GITHUB_SHA` and `GITHUB_REF` as the original event, so a checkpoint keyed on the commit still validates. ### [Does splitting a long job into stages make it cheaper?](#does-splitting-a-long-job-into-stages-make-it-cheaper)Copy Anchor Link Not on its own. Each stage adds a boot and a restore, so a four-stage split of a 320 minute job bills about 4 extra minutes per run plus $0.12 in snapshot restores. The saving comes from failures, since a failed stage repeats 81 minutes instead of 320, and the split pays for itself past roughly 10 failed runs a month at the rates in the model above. --- # Notarizing macOS Apps in GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/macos-notarization-github-actions Description: notarytool submits a signed archive to Apple, waits for a verdict, then staples the ticket. Here is the split-job workflow and what the wait costs. # Notarizing macOS Apps in GitHub Actions notarytool submits a signed archive to Apple, waits for a verdict, then staples the ticket. Here is the split-job workflow and what the wait costs. Last verified: 2026-08-13 Notarizing a macOS app in GitHub Actions means signing the bundle with a Developer ID Application certificate under the hardened runtime, submitting a zip of it to Apple's notary service with `xcrun notarytool`, waiting for the verdict, and stapling the returned ticket to the bundle. The signing and stapling commands finish in seconds, and the wait for the verdict is the part that shows up on the bill, because a job blocked on Apple's service still bills macOS runner minutes. This guide covers the notarization sequence and its wait behavior, what a rejection looks like in the log, a workflow that splits the submission from the wait, and the arithmetic for both shapes at macOS runner rates. Labels and rates come from the [WarpBuild macOS runner catalog](/runners/macos). ## [Diagnosis](#diagnosis)Copy Anchor Link The sequence has seven stages. Only one of them is slow, and only one of them can be moved off an expensive machine. | Stage | Command | Returns | Wait behavior | | ------- | ----------------------------------------------------------------------------- | ------------------------------------------- | --------------------------------------------------- | | Sign | codesign --options runtime --timestamp --sign "Developer ID Application: ..." | Signed bundle | Seconds, plus one call to Apple's timestamp service | | Package | ditto -c -k --keepParent App.app App.zip | Zip that preserves symlinks and permissions | Seconds | | Submit | xcrun notarytool submit App.zip | Submission id, status In Progress | Returns when the upload finishes | | Wait | xcrun notarytool wait or submit --wait | Accepted or Invalid | Blocks on Apple's service with no default timeout | | Log | xcrun notarytool log log.json | JSON issues array | Seconds | | Staple | xcrun stapler staple App.app | Ticket written into the bundle | Seconds | | Verify | xcrun stapler validate, spctl -a -t exec -vvv | Gatekeeper verdict | Seconds | **The wait is a service response with no upper bound in the workflow.** `notarytool submit --wait` holds the job open until Apple answers, and Apple sets that duration. Measure the interval in your own repository across a month of releases and use the median in the model further down. Pass `--timeout` on the wait so a stalled submission fails the job instead of running to the workflow limit. **A rejection arrives as a JSON document.** `xcrun notarytool log ` writes the issues array, and each entry names a path, a severity, and a message: Copy Text ``` { "logFormatVersion": 1, "jobId": "8d1e4a72-3f2b-4a0e-9d3c-71b0c9a5e412", "status": "Invalid", "statusSummary": "Archive contains critical validation errors", "issues": [ { "severity": "error", "path": "App.zip/App.app/Contents/MacOS/App", "message": "The executable does not have the hardened runtime enabled." }, { "severity": "error", "path": "App.zip/App.app/Contents/Frameworks/Helper.framework", "message": "The signature does not include a secure timestamp." } ] } ``` Four messages account for most first-time rejections, and Apple lists them with their remedies in [resolving common notarization issues](https://developer.apple.com/documentation/security/resolving-common-notarization-issues): the hardened runtime is absent, the signature carries no secure timestamp, the signing certificate is not a Developer ID Application certificate, or the executable still requests `com.apple.security.get-task-allow` from a debug build. Every one of them is decided at signing time, so a rejection means the archive job was wrong and the minutes spent waiting were spent for nothing. **Accepted is not shippable on its own.** The ticket lives on Apple's servers until it is stapled into the bundle, so a user machine with no path to that service blocks the app. Apple documents the stapling step in [customizing the notarization workflow](https://developer.apple.com/documentation/security/customizing-the-notarization-workflow). **Workflows still calling `altool` fail outright.** The notary service stopped accepting `altool` submissions on November 1, 2023, and `notarytool` is the supported client, as described in [Apple technote TN3147](https://developer.apple.com/documentation/technotes/tn3147-migrating-to-the-latest-notarization-tool). ## [Fix](#fix)Copy Anchor Link Six rules turn a notarization job that works on a laptop into one that works on a runner and does not bill for idle time. **Sign for the notary rules.** Local verification passes on signatures the notary service rejects. Pass `--options runtime` and `--timestamp` on every `codesign` call, sign nested frameworks and helpers before the outer bundle, and keep debug entitlements out of the Release build. **Package with `ditto`.** A zip built by `zip -r` drops symlinks inside the bundle and the submission fails on structure rather than on signing. **Supply credentials as secrets.** Store the App Store Connect API key `.p8` base64 encoded, with its key id and issuer id, and decode it into `$RUNNER_TEMP` inside the job. Each WarpBuild runner runs in its own virtual machine that is created on demand and destroyed after the build, each runner has its own encrypted storage volume with the same lifetime, and WarpBuild does not access or store build secrets, which stay in your repository and reach only the runner environment. The isolation model is in the [runner security documentation](/docs/ci/security). **Split the submission from the wait.** `notarytool submit` without `--wait` returns the submission id immediately, which lets the wide runner that compiled the app finish its job. A second job on a smaller label runs `notarytool wait` and staples. **Verify before publishing.** `xcrun stapler validate` and `spctl -a -t exec -vvv` both run in seconds and both fail loudly, ahead of a release step that is expensive to undo. **Gate the whole flow on tags.** Notarization on every push multiplies the wait by the push count and puts the Developer ID key on every machine that runs it. Moving these jobs onto WarpBuild is a label change. WarpBuild runners register with GitHub as self-hosted runners carrying `warp-` labels, so editing `runs-on` is the migration. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, which is what makes the Linux polling variant below possible in the same workflow file. The `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` images ship Xcode 27.0 with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes while GitHub's upstream macOS 27 image is in beta, and a dedicated macOS 27 image follows once that image is released. When a signing step behaves differently on the runner than on a developer machine, the Action Debugger pauses the workflow and opens a session on the runner, which is the shortest route to inspecting a keychain or a signature in place. ## [Configuration](#configuration)Copy Anchor Link The archive job compiles and signs on a 12 vCPU label, submits, and stops. The second job waits and staples on a 6 vCPU label. Copy Text ``` name: macos-release on: push: tags: ["v*"] jobs: archive: runs-on: warp-macos-26-arm64-12x environment: release outputs: submission-id: ${{ steps.submit.outputs.submission-id }} steps: - uses: actions/checkout@v4 - name: Import the Developer ID certificate env: DEVELOPER_ID_P12: ${{ secrets.DEVELOPER_ID_P12 }} DEVELOPER_ID_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }} run: | KEYCHAIN="$RUNNER_TEMP/build.keychain-db" KEYCHAIN_PASSWORD="$(openssl rand -base64 24)" echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security list-keychains -d user -s "$KEYCHAIN" login.keychain-db echo "$DEVELOPER_ID_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12" security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \ -P "$DEVELOPER_ID_P12_PASSWORD" -T /usr/bin/codesign rm -f "$RUNNER_TEMP/cert.p12" security set-key-partition-list -S apple-tool:,apple:,codesign: \ -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null - name: Build run: | xcodebuild -scheme App -configuration Release \ -derivedDataPath "$RUNNER_TEMP/DerivedData" \ CODE_SIGNING_ALLOWED=NO build ditto "$RUNNER_TEMP/DerivedData/Build/Products/Release/App.app" \ "$RUNNER_TEMP/App.app" - name: Sign with the hardened runtime and a secure timestamp env: IDENTITY: ${{ vars.DEVELOPER_ID_IDENTITY }} run: | FRAMEWORKS="$RUNNER_TEMP/App.app/Contents/Frameworks" if [ -d "$FRAMEWORKS" ]; then find "$FRAMEWORKS" -mindepth 1 -maxdepth 1 -exec \ codesign --force --options runtime --timestamp \ --keychain "$KEYCHAIN" --sign "$IDENTITY" {} \; fi codesign --force --options runtime --timestamp \ --entitlements App.entitlements \ --keychain "$KEYCHAIN" --sign "$IDENTITY" \ "$RUNNER_TEMP/App.app" codesign --verify --deep --strict --verbose=2 "$RUNNER_TEMP/App.app" - name: Package and submit id: submit env: ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} run: | ditto -c -k --keepParent "$RUNNER_TEMP/App.app" "$RUNNER_TEMP/App.zip" echo "$ASC_KEY_P8" | base64 --decode > "$RUNNER_TEMP/asc.p8" ID="$(xcrun notarytool submit "$RUNNER_TEMP/App.zip" \ --key "$RUNNER_TEMP/asc.p8" \ --key-id "$ASC_KEY_ID" \ --issuer "$ASC_ISSUER_ID" \ --output-format json \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')" rm -f "$RUNNER_TEMP/asc.p8" echo "submission-id=$ID" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v4 with: name: unstapled-app path: ${{ runner.temp }}/App.zip - name: Tear down the keychain if: always() run: security delete-keychain "$KEYCHAIN" || true staple: needs: archive runs-on: warp-macos-15-arm64-6x environment: release steps: - uses: actions/download-artifact@v4 with: name: unstapled-app path: ${{ runner.temp }}/in - name: Wait for the verdict env: ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} SUBMISSION_ID: ${{ needs.archive.outputs.submission-id }} run: | echo "$ASC_KEY_P8" | base64 --decode > "$RUNNER_TEMP/asc.p8" CREDS=(--key "$RUNNER_TEMP/asc.p8" --key-id "$ASC_KEY_ID" --issuer "$ASC_ISSUER_ID") if ! xcrun notarytool wait "$SUBMISSION_ID" "${CREDS[@]}" --timeout 45m; then xcrun notarytool log "$SUBMISSION_ID" "${CREDS[@]}" "$RUNNER_TEMP/log.json" cat "$RUNNER_TEMP/log.json" rm -f "$RUNNER_TEMP/asc.p8" exit 1 fi rm -f "$RUNNER_TEMP/asc.p8" - name: Staple, verify, repackage run: | ditto -x -k "$RUNNER_TEMP/in/App.zip" "$RUNNER_TEMP/out" xcrun stapler staple "$RUNNER_TEMP/out/App.app" xcrun stapler validate "$RUNNER_TEMP/out/App.app" spctl -a -t exec -vvv "$RUNNER_TEMP/out/App.app" ditto -c -k --keepParent "$RUNNER_TEMP/out/App.app" \ "$RUNNER_TEMP/App-stapled.zip" - uses: actions/upload-artifact@v4 with: name: stapled-app path: ${{ runner.temp }}/App-stapled.zip ``` Three details carry the pattern. The `submission-id` job output is what lets the second job find the submission, so it has to be written to `$GITHUB_OUTPUT` in the submit step. The artifact is the `ditto` zip rather than the bundle directory, because an artifact upload of a directory drops the symlinks that make the bundle valid. Both jobs declare `environment: release`, and dropping that line resolves the environment secrets to empty strings, which surfaces later as an authentication error from `notarytool`. `notarytool wait` is macOS-only, and the underlying service is a REST API, so the wait can also run on a Linux runner that polls until the status leaves `In Progress`, with stapling still done on macOS. The endpoint and its bearer token format are documented in the [Notary API reference](https://developer.apple.com/documentation/notaryapi): Copy Text ``` poll: needs: archive runs-on: warp-ubuntu-latest-x64-2x steps: - name: Poll until the submission leaves In Progress env: ASC_TOKEN: ${{ secrets.ASC_NOTARY_JWT }} SUBMISSION_ID: ${{ needs.archive.outputs.submission-id }} run: | for _ in $(seq 1 90); do STATUS="$(curl -sS -H "Authorization: Bearer $ASC_TOKEN" \ "https://appstoreconnect.apple.com/notary/v2/submissions/$SUBMISSION_ID" \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["attributes"]["status"])')" [ "$STATUS" = "In Progress" ] || break sleep 30 done test "$STATUS" = "Accepted" ``` ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link macOS labels and rates, from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13: | Label | macOS | vCPU | Memory | Storage | Rate per minute | Alias | | ----------------------- | -------- | ---- | ------ | --------- | --------------- | --------------------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | | For the rate the wait runs at: `warp-macos-latest-arm64-6x` (6 vCPU, 14 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB), which is 22 percent lower list price with one more vCPU and the same memory on the WarpBuild side. GitHub list price read from the [GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. Assumptions for the model, all stated so you can substitute your own: one desktop application repository, 8 tagged releases per month, 12 minutes of compile and sign work, a 9 minute median wait for the verdict measured over the previous month, 2 minutes to staple and verify, and 30 seconds each side for the artifact upload and download that the split introduces. | Shape | Machines and minutes | Cost per release | Monthly at 8 releases | | ----------------------------- | ------------------------------------------------------ | ---------------- | --------------------- | | One job with submit --wait | 23.0 min on warp-macos-26-arm64-12x | $3.68 | $29.44 | | Split across two macOS labels | 12.5 min at $0.16, 11.5 min at $0.08 | $2.92 | $23.36 | | Split with the wait on Linux | 12.5 min at $0.16, 9.2 min at $0.004, 2.5 min at $0.08 | $2.24 | $17.92 | The middle row costs $6.08 per month less than the first, and the third row $11.52 less, which is $72.96 and $138.24 over twelve months at the same release volume. The transfer overhead is the only thing the split adds, so it pays back once the wait passes about a minute, and it pays back harder every time Apple is slow: each extra minute of waiting costs $0.16 in the first shape, $0.08 in the second, and $0.004 in the third. Then price the rejected submission, since that is the failure the diagnosis table exists to prevent. A rejection discovered after the wait costs the wasted wait plus a full re-run of the archive job: on the single-job shape that is 9 wait minutes and 12.5 archive minutes, both at $0.16, so $3.44 before the second submission starts waiting. The `codesign --verify --deep --strict` step and the four rules in the Fix section run before the submission and cost seconds. For the full label list and image details, see [WarpBuild macOS runners for GitHub Actions](/runners/macos). For the job that takes the signed build the rest of the way, see [TestFlight uploads from GitHub Actions](/guides/testflight-uploads-github-actions), for the desktop packaging case see [Electron builds on GitHub Actions](/solutions/electron-github-actions), and for what a signature proves once it is attached, see [code signing](/glossary/code-signing). ## [FAQ](#faq)Copy Anchor Link ### [Why does notarytool return Invalid when codesign already verified the app?](#why-does-notarytool-return-invalid-when-codesign-already-verified-the-app)Copy Anchor Link `codesign` verifies that a signature is well formed. The notary service applies additional rules, and the four that reject most first submissions are a missing hardened runtime, a missing secure timestamp, a certificate that is not a Developer ID Application certificate, and a leftover `com.apple.security.get-task-allow` entitlement from a debug build. Run `xcrun notarytool log` with the submission id to get the JSON issues array, which names the offending path and message. ### [Do I have to keep the macOS runner busy while Apple processes the submission?](#do-i-have-to-keep-the-macos-runner-busy-while-apple-processes-the-submission)Copy Anchor Link No. `xcrun notarytool submit` returns a submission id as soon as the upload finishes, so the wide runner that compiled the app can end its job there. A second job calls `xcrun notarytool wait` on a 6 vCPU macOS label at $0.08 per minute, or polls the Notary API over HTTPS from a Linux runner at $0.004 per minute and staples on macOS afterwards. ### [The submission was Accepted, so why does Gatekeeper still block the app on a user machine?](#the-submission-was-accepted-so-why-does-gatekeeper-still-block-the-app-on-a-user-machine)Copy Anchor Link An accepted submission puts a ticket on Apple's servers, and a machine with no network path to that service has no way to see it. Run `xcrun stapler staple` on the app or disk image inside the workflow, then assert with `xcrun stapler validate` and `spctl` before the artifact is published. --- # Freeing Disk Space on macOS Runners | WarpBuild URL: https://www.warpbuild.com/guides/macos-runner-disk-space-github-actions Description: A macOS Xcode job fills the runner volume with DerivedData, archives and simulator data. Measure the budget, clean up in order, then price the larger label. # Freeing Disk Space on macOS Runners A macOS Xcode job fills the runner volume with DerivedData, archives and simulator data. Measure the budget, clean up in order, then price the larger label. Last verified: 2026-08-13 A macOS job runs out of disk because the runner volume is 120GB on the 6 vCPU labels and 270GB on the 12 vCPU labels, and the image ships Xcode plus its simulator runtimes on that same volume before your first step runs ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). The fix is to write down a disk budget from the catalog row, delete in an order that removes the largest files with the smallest rebuild cost, and move to the larger label only when the budget still fails to close. This page gives the budget worksheet, the cleanup order with the commands, the three cleanups that destroy the cache you were trying to keep, and the arithmetic that prices the larger label against the minutes the cleanup steps cost. ## [Diagnosis](#diagnosis)Copy Anchor Link The failure arrives with several different messages, and all of them mean the same thing. * `error: No space left on device` from a compile or link task. * `Failed to write to /Users/runner/work/.../DerivedData`, usually during the archive step, which is the largest single write in the job. * `Unable to boot device` or `Failed to install the app` from `simctl`, when the simulator has no room to write its device data. * `codesign` failing on the export, since signing rewrites every bundle in place and needs room for the copy. * The cache save step failing at the end of an otherwise green job, because `actions/cache` writes a compressed archive to a temporary path on the same volume before it uploads. Saving a 28GB DerivedData tree needs room for the tarball on top of the tree itself. Read the disk correctly before anything else. On macOS the root filesystem is the read-only system volume, so `df -h /` reports a number that has no relationship to your job. The writable volume is the one to watch: Copy Text ``` df -h /System/Volumes/Data ``` ### [The disk budget](#the-disk-budget)Copy Anchor Link The catalog gives the size of the volume. Your job cares about free space at step one, which is smaller, because the image is built from the matching upstream GitHub runner image and carries the same preinstalled tooling ([preinstalled software documentation](/docs/ci/preinstalled-software)). Write the budget as a subtraction and every later decision on this page falls out of it. | Line item | Path | How to measure | Example GB | | ----------------------------------- | ---------------------------------- | ----------------------------------------- | ---------- | | Free space at step one | data volume | df -h /System/Volumes/Data | 90 | | Checkout, including LFS objects | $GITHUB\_WORKSPACE | du -sh . | 4 | | Resolved package checkouts | SourcePackages | du -sh SourcePackages | 6 | | DerivedData after build-for-testing | DerivedData | du -sh DerivedData | 28 | | Simulator devices, logs, caches | \~/Library/Developer/CoreSimulator | du -sh \~/Library/Developer/CoreSimulator | 6 | | Archive, exported IPA, dSYM bundles | build/ | du -sh build | 12 | | Cache archive written before upload | temp path on the same volume | size of the tree you save | 12 | | **Headroom left** | **22** | | | The GB column is a placeholder for the arithmetic rather than a measurement. Replace every row with your own `du -sh` output from one instrumented run, because the ratios differ enormously between a two-module app and a workspace with sixty targets. Three things push a budget that used to close over the edge, and none of them show up in a diff: 1. **A runtime downloaded at job time.** `xcodebuild -downloadPlatform iOS` or a `-destination` that names a runtime the image does not carry pulls several GB onto the volume mid-job. 2. **Keeping the archive and the export together.** The `.xcarchive` holds the binary and its dSYMs, and `-exportArchive` writes a second signed copy alongside it. 3. **A cache save added later.** The tarball is a second full-size write that the job did not make last month. macOS runners do not support nested virtualization and cannot run Docker ([cloud runners documentation](/docs/ci/cloud-runners)), so container images are never a line in this budget. Container work belongs on remote Docker builders, which sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the surface around the runner. ## [Fix](#fix)Copy Anchor Link Delete in this order. It is sorted by space returned per unit of rebuild cost, so the first two steps are the ones to try before anything else. **1\. Unused Xcode bundles.** The largest single items on the image, and your job rebuilds none of them. Pin the toolchain first so a later step cannot reach for a bundle you removed. Copy Text ``` KEEP=/Applications/Xcode_27.0.app sudo xcode-select -s "$KEEP" echo "DEVELOPER_DIR=$KEEP/Contents/Developer" >> "$GITHUB_ENV" for app in /Applications/Xcode*.app; do [ "$app" = "$KEEP" ] || sudo rm -rf "$app" done ``` **2\. Simulator runtimes and devices you do not test against.** Runtimes ship inside the Xcode bundles and as standalone bundles; devices and their data accumulate under CoreSimulator. Copy Text ``` xcrun simctl runtime list xcrun simctl runtime delete xcrun simctl delete unavailable rm -rf ~/Library/Developer/CoreSimulator/Caches rm -rf ~/Library/Developer/Xcode/iOS\ DeviceSupport rm -rf ~/Library/Developer/Xcode/watchOS\ DeviceSupport ``` **3\. DerivedData index store and logs.** The index store exists for the Xcode editor and has no consumer in a workflow, and the logs grow on every run. Both are safe to remove before the cache save step, and removing them shrinks the tarball too. Copy Text ``` rm -rf DerivedData/Index.noindex DerivedData/Logs ``` **4\. The archive, once the export is done.** Keep the IPA and the dSYMs, drop the `.xcarchive` they came from, and drop the stale archives Xcode keeps by default. Copy Text ``` rm -rf build/App.xcarchive ~/Library/Developer/Xcode/Archives ``` **5\. Build intermediates, but only after the cache save step.** `Build/Intermediates.noindex` and `ModuleCache.noindex` are what a warm restore reuses. Deleting them mid-job returns space and pays for it on the next run. **6\. Package checkouts, last.** Cheapest to lose in bytes and most expensive to lose in minutes, because dropping them means a fresh resolve on the next job that needs them. ### [Which cleanups break the cache you wanted to keep](#which-cleanups-break-the-cache-you-wanted-to-keep)Copy Anchor Link | Cleanup | Space class | What it breaks | | ------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | rm -rf DerivedData/Index.noindex DerivedData/Logs | Large | Nothing. No step reads either directory. | | rm -rf DerivedData/Build/Intermediates.noindex before the save step | Large | The incremental object files a warm restore reuses. The next run recompiles from source. | | rm -rf DerivedData/Build/ModuleCache.noindex before the save step | Medium | Precompiled module output. Same result: a full recompile on the next run. | | rm -rf SourcePackages | Medium | The resolve cache keyed on Package.resolved, which puts the clone and resolve phase back into every job. | | Removing an Xcode bundle after the key generation step | Large | The cache key. xcodebuild -version reports a different toolchain, so the entry saved from main no longer matches and the job restores nothing. | | xcrun simctl delete all | Small | Pre-created devices only. The next \-destination recreates one and pays the boot time. | The ordering rule that follows from the table: everything in class "breaks the cache" runs after `actions/cache/save`, and everything else runs before it. Directory by directory reasoning for what belongs in the archive is in [caching Xcode DerivedData in GitHub Actions](/guides/xcode-derived-data-cache-github-actions). ## [Configuration](#configuration)Copy Anchor Link A build and archive job with the budget instrumented, the safe cleanups placed before the save step, and the destructive ones after it. Copy Text ``` name: ios-release on: push: branches: [main] jobs: archive: runs-on: warp-macos-26-arm64-6x steps: - uses: actions/checkout@v4 - name: Pin the toolchain and reclaim the unused Xcode bundles run: | KEEP=$(ls -d /Applications/Xcode_27*.app | tail -1) sudo xcode-select -s "$KEEP" echo "DEVELOPER_DIR=$KEEP/Contents/Developer" >> "$GITHUB_ENV" for app in /Applications/Xcode*.app; do [ "$app" = "$KEEP" ] || sudo rm -rf "$app" done xcrun simctl delete unavailable rm -rf ~/Library/Developer/Xcode/iOS\ DeviceSupport - name: Record the starting budget run: df -h /System/Volumes/Data - name: Compute the cache key from the pinned toolchain id: toolchain run: echo "id=$(xcodebuild -version | tr '\n' '-' | tr -d ' ')" >> "$GITHUB_OUTPUT" - name: Restore DerivedData uses: actions/cache/restore@v4 with: path: DerivedData key: dd-${{ steps.toolchain.outputs.id }}-${{ github.sha }} restore-keys: dd-${{ steps.toolchain.outputs.id }}- - name: Archive run: | xcodebuild archive \ -workspace App.xcworkspace \ -scheme App \ -configuration Release \ -destination 'generic/platform=iOS' \ -derivedDataPath DerivedData \ -clonedSourcePackagesDirPath SourcePackages \ -archivePath build/App.xcarchive - name: Prune what the cache should never carry run: | rm -rf DerivedData/Index.noindex DerivedData/Logs du -sh DerivedData SourcePackages build df -h /System/Volumes/Data - name: Save DerivedData uses: actions/cache/save@v4 with: path: DerivedData key: dd-${{ steps.toolchain.outputs.id }}-${{ github.sha }} - name: Reclaim before the export run: | rm -rf DerivedData/Build/Intermediates.noindex rm -rf DerivedData/Build/ModuleCache.noindex df -h /System/Volumes/Data - name: Export run: | xcodebuild -exportArchive \ -archivePath build/App.xcarchive \ -exportOptionsPlist ExportOptions.plist \ -exportPath build/export - name: Drop the archive once the export exists run: rm -rf build/App.xcarchive - uses: actions/upload-artifact@v4 with: name: ipa path: build/export ``` Four facts decide whether this lands cleanly: 1. Runner storage is ephemeral. The volume is deleted when the runner terminates, so everything the next job needs travels through an artifact or a cache. 2. `warp-macos-latest-arm64-6x` resolves to `warp-macos-15-arm64-6x`. Pin the explicit version label on a release branch so the alias moving does not change the toolchain mid-release. 3. The macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. 4. macOS 13 labels were removed on June 8, 2026, so a workflow that still names one queues without a match. Every label, size, and rate is in the [cloud runners documentation](/docs/ci/cloud-runners), and WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners under the same `warp-` label scheme. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [The size option, priced](#the-size-option-priced)Copy Anchor Link | Label | vCPU | Memory | Storage | Per minute | | ----------------------- | ---- | ------ | --------- | ---------- | | warp-macos-26-arm64-6x | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | 12 | 28GB | 270GB SSD | $0.16 | The 12 vCPU label doubles the disk and doubles the rate ([pricing page](/pricing), checked on 2026-08-13). Per vCPU-minute the two cost the same, so the extra 150GB is priced at the extra $0.08 per minute rather than sold separately. Put the two options against each other on a 20-minute archive job that runs 300 times a month: * Baseline on the 6 vCPU label: 300 x 20 x $0.08 = $480.00 per month. * Same job on the 12 vCPU label, same 20 minutes: 300 x 20 x $0.16 = $960.00 per month, so the disk costs $480.00. * Same job on the 6 vCPU label with three cleanup steps adding 2.5 minutes: 300 x 22.5 x $0.08 = $540.00 per month, so the cleanup costs $60.00. Cleanup wins on that shape by $420.00 per month, which is the usual answer on the invoice. Reruns move the number but rarely reverse it. A job that dies on disk at the archive step has already paid for the whole build and pays again on the rerun: at a 5 percent failure rate that is 15 reruns x 22.5 minutes x $0.08 = $27.00 and roughly 340 minutes of waiting. For the cleanup path to reach the $960.00 of the wider label, reruns would have to add $420.00, which is 5,250 minutes, or 233 failed jobs out of 300. So the wider label is a wall clock and reliability decision rather than a cost saving. It buys 150GB more headroom and removes the cleanup steps from the critical path for $480.00 more per month on this job shape. Take it when the working set genuinely exceeds 120GB, and take the cleanup steps when most of the working set is deletable. Wall clock matters as much as the invoice. Cleanup steps run serially before and after the work you care about, so 2.5 minutes on 300 jobs is 12.5 hours per month of pull requests sitting in the queue behind a `rm -rf`. ### [The baseline this replaces](#the-baseline-this-replaces)Copy Anchor Link GitHub documents 14 GB of SSD storage on its hosted macOS runners, standard and larger alike ([GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13). A job that has been engineered to survive 14 GB usually needs no cleanup at all at 120GB, so the first thing to try after a label change is deleting the cleanup steps and rerunning the budget. The price side is direct arithmetic: `warp-macos-latest-arm64-6x` at 6 vCPU, 14GB and a 120GB SSD costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14GB, which is 22 percent lower list price, GitHub list price checked on 2026-08-13. Sizes and shapes per label are on the [macOS runner sizes page](/runners/macos/sizes), and the full catalog is on the [macOS runner hub](/runners/macos). ## [FAQ](#faq)Copy Anchor Link **Why does a macOS runner run out of disk when the catalog says 120GB?** The catalog figure is the size of the volume, and the image ships Xcode, the bundled simulator runtimes, and the rest of the preinstalled toolchain on that same volume before step one runs. Free space at step one is the number that matters, and only `df` tells you what it is. Print `df -h /System/Volumes/Data` as the first step, because `df -h /` reports the read-only system volume rather than the writable data volume where the checkout and DerivedData live. The per-label volume sizes are in [how much disk do macOS GitHub Actions runners have](/answers/how-much-disk-do-macos-github-actions-runners-have). **What should I delete first, and what is safe to delete?** Unused Xcode bundles and unused simulator runtimes first, since they are the largest items on the image and your job rebuilds none of them. Then `DerivedData/Index.noindex` and `DerivedData/Logs`, which no workflow step reads. Then the `.xcarchive` once the IPA and the dSYMs are exported. Pin `DEVELOPER_DIR` before removing any Xcode bundle so a later step cannot reach for one that is gone. **Which cleanups break the cache I wanted to keep?** Three. Deleting `Build/Intermediates.noindex` or `ModuleCache.noindex` before the cache save step throws away the incremental object files and module output that make the next run fast. Deleting `SourcePackages` costs a fresh package resolve on the next job. And changing which Xcode bundle `DEVELOPER_DIR` points at after the key generation step changes the toolchain identifier in the cache key, so the entry saved from main no longer matches. --- # Sharding Test Suites Across a Matrix | WarpBuild URL: https://www.warpbuild.com/guides/matrix-sharding-github-actions Description: Put a shard index in the GitHub Actions matrix, pass it to the test command, and merge the shard reports in one job. Workflow YAML and cost arithmetic. # Sharding Test Suites Across a Matrix Put a shard index in the GitHub Actions matrix, pass it to the test command, and merge the shard reports in one job. Workflow YAML and cost arithmetic. Last verified: 2026-08-13 Sharding a test suite across a GitHub Actions matrix means putting a shard index in `strategy.matrix`, passing that index and the shard total to the test command so each job runs a disjoint slice, and merging the per shard reports in a dependent job. The split has to be deterministic and balanced by recorded duration, because a matrix fixes the assignment before the run starts and the slowest shard sets the wall clock for the whole gate. This guide covers the three failures that make a sharded matrix report the wrong answer, the edits that fix each one, a workflow with a shard index matrix and a merge job, and the arithmetic that prices eight small shards against one large runner. For the language neutral definition see the [test sharding entry](/glossary/test-sharding), for matrix semantics see the [guide to matrix builds](/guides/github-actions-matrix-builds), and for the rest of the run see the [hub on speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link A sharded matrix fails quietly. The grid is green, the job count is high, and the number that matters has stopped moving. Three failures account for most of it, and each has a distinct symptom. ### [The split is unbalanced](#the-split-is-unbalanced)Copy Anchor Link Splitting by test file count gives eight jobs of eight different lengths. Total billed minutes stay the same, since the same tests run either way, but the run waits for the longest shard while the other seven sit finished. A shard holding 1.5 times the mean work adds that overshoot to every run of the gate. Read duration P75 and P90 per job in the Jobs report, which reports both percentiles for every repository, workflow, and job name combination with CSV export ([reports documentation](/docs/ci/features/reports)). Shards from one matrix have distinct job names, so the spread between the fastest and slowest row is the imbalance, in minutes, without any extra instrumentation. On GitHub-hosted runners, pull per job timings from the workflow jobs API and compute the spread yourself. ### [The assignment is not reproducible](#the-assignment-is-not-reproducible)Copy Anchor Link A failure reported by shard 3 is only useful when shard 3 can be rerun on a laptop and produce the same slice. Assignment drifts when the test file list comes from a glob whose order depends on the filesystem, when the runner randomizes test order per process, or when the split is keyed on a durations file that a scheduled job rewrites. The symptom is a shard that fails in the pull request and passes when rerun alone. ### [The results are never merged](#the-results-are-never-merged)Copy Anchor Link Each shard writes its own report, so something has to combine them into one verdict. Two defaults work against that. A dependent job inherits `if: success()`, so the merge job is skipped exactly when a shard fails and the report matters most. And `actions/upload-artifact@v4` writes immutable artifacts and rejects a second upload under an existing name ([upload-artifact](https://github.com/actions/upload-artifact), checked on 2026-08-13), so shards that all write `test-report` fail after the first one wins the race. | Symptom | What it means | Where to look | | ------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------- | | Shards finish minutes apart | Split by file count rather than recorded duration | Jobs report, duration P75 and P90 per shard | | A shard fails in the pull request and passes when rerun alone | Assignment varies between runs | Glob order, test order seed, durations file freshness | | No merged report on red runs | The merge job inherited if: success() | if key on the dependent job | | Second shard fails at artifact upload | Two shards uploading the same artifact name | name key under upload-artifact | | Required check pending forever after a shard count change | Branch protection pinned to a matrix job name | Required checks against the current job names | | Billed minutes climb while wall clock stays flat | Per shard setup is now most of each job | Setup steps against test steps in one shard log | | Wall clock is a whole multiple of one shard duration | Fan-out wider than the eligible runners | Queue Timings, P75 and P90 per label | ## [Fix](#fix)Copy Anchor Link Three properties make a shard split a faithful substitute for running the suite on one machine, and each maps to one edit. **Make the assignment deterministic.** Sort the file list before splitting it, pin the shard total in one place so the index and the total can never disagree, and pass an explicit seed when the runner randomizes order. Refresh a durations file on a schedule rather than on every run, so the assignment is stable across the reruns of a single pull request. **Balance by recorded time.** Test file count is a poor proxy for duration; recorded per test time is the real one. Most runners either accept a durations file or expose per test timings in their report output, and a split built from last quarter's timings drifts as tests are added. When no timing data exists, sorting the slowest known files first and dealing them round robin across shards gets most of the way there. **Merge, and require the merged job.** Collect the per shard artifacts in a dependent job with `if: always()`, produce one report, and make that job the required status check. Matrix job names carry the shard index, so requiring them means editing branch protection every time the count changes. Then give the fan-out somewhere to land. A fresh runner is provisioned per job. Generally available Linux and Windows runners do not have plan-level concurrency caps, so raising the shard count is an edit to the matrix rather than a plan upgrade. Which ceiling applies where is covered in the [answer on how many jobs a matrix can fan out to](/answers/how-many-jobs-can-a-matrix-fan-out-to). ## [Configuration](#configuration)Copy Anchor Link Eight shards on one label, a shard aware test command, per shard artifacts, and a merge job that owns the status check. Copy Text ``` name: test on: pull_request: push: branches: [main] jobs: test: name: test ${{ matrix.shard }}/${{ strategy.job-total }} runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - name: Run shard run: npx playwright test --shard=${{ matrix.shard }}/${{ strategy.job-total }} --reporter=blob - uses: actions/upload-artifact@v4 if: always() with: name: blob-report-${{ matrix.shard }} path: blob-report retention-days: 3 report: name: merged test report needs: test if: always() runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - uses: actions/download-artifact@v4 with: path: all-blob-reports pattern: blob-report-* merge-multiple: true - run: npx playwright merge-reports --reporter=html ./all-blob-reports - uses: actions/upload-artifact@v4 with: name: html-report path: playwright-report - name: Fail the gate when any shard failed if: needs.test.result != 'success' run: exit 1 ``` What each piece does: * `strategy.job-total` is the number of jobs the matrix expanded to, so the shard total is written once in the `shard` list and read everywhere else ([GitHub Actions contexts](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts), checked on 2026-08-13). With one dimension it equals the shard count; adding a second dimension multiplies it, so a two dimensional grid needs an explicit total in a matrix key instead. * `fail-fast: false` keeps the other seven shards running after one fails, so a single flaky test costs one rerun rather than a full grid. * `if: always()` on the upload step preserves the report from a shard that failed, and the same key on the `report` job stops the merge from being skipped when the matrix is red. * Artifact names carry the shard index, which is what keeps eight parallel uploads from colliding under one immutable name. `merge-multiple: true` on the download unpacks every matching artifact into one directory so the merge command can read them as a set. * `needs.test.result` is one string for the whole matrix job and is `failure` when any cell failed, so the last step turns eight shard outcomes into one required check with a fixed name. * Where the shard count is computed rather than written, generate the list in a setup job, expose it as an output, and read it with `matrix: shard: ${{ fromJSON(needs.setup.outputs.shards) }}`. Any runner with a shard flag drops into the same shape, and the [guide to sharding pytest suites](/guides/pytest-parallel-test-sharding) covers the flag and the merge step for that framework specifically. Runners without one are sharded from the outside: list the test files, sort them, take every eighth entry starting at the shard index, and pass that slice as arguments. The labels above resolve to these machines, from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | runs-on label | vCPU | RAM | Storage | USD per minute | | -------------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Two formulas cover a shard matrix. Wall clock is `s + (T / w) * m`, and billed minutes are `N * (s + T / w)`, where `T` is serial suite execution time, `N` is the shard count, `w` is the total worker processes across all shards, `s` is the per job setup a shard pays before its first test, and `m` is the share of the work held by the largest shard. Assumptions: `T` of 96 minutes, `s` of 3 minutes for checkout, `npm ci`, and a warm cache, one worker per vCPU, an even split, no retries, and queue wait excluded. Rates come from the catalog above. | Layout | Jobs | vCPU in flight | Test minutes per shard | Wall clock | Billed minutes | Cost per run | | --------------------- | ---- | -------------- | ---------------------- | ---------- | -------------- | ------------ | | 1 job on \-x64-4x | 1 | 4 | 24 | 27 min | 27 | $0.22 | | 2 shards on \-x64-4x | 2 | 8 | 12 | 15 min | 30 | $0.24 | | 4 shards on \-x64-4x | 4 | 16 | 6 | 9 min | 36 | $0.29 | | 8 shards on \-x64-4x | 8 | 32 | 3 | 6 min | 48 | $0.38 | | 16 shards on \-x64-4x | 16 | 64 | 1.5 | 4.5 min | 72 | $0.58 | | 1 job on \-x64-32x | 1 | 32 | 3 | 6 min | 6 | $0.38 | Read the last two rows together, because they are the choice this page exists to price. Eight 4 vCPU shards and one 32 vCPU job hold the same 32 vCPU, finish in the same 6 minutes, and cost the same $0.38, since every Linux x64 size in the catalog charges $0.002 per vCPU minute and the repeated setup on the shard plan is paid on quarter size machines: `8 * 3 * $0.008` is the same $0.19 as `1 * 3 * $0.064`. Adding the merge job on `warp-ubuntu-latest-x64-2x` for 2 minutes puts $0.008 on top of the shard plan. Three things break that tie, and none of them is price. * **Imbalance is paid by the shard plan alone.** A largest shard holding 1.5 times the mean pushes `m` to 1.5, so wall clock goes from 6 minutes to 7.5 while billed minutes stay at 48\. The single machine rebalances at run time and keeps its 6 minutes. * **Rerun granularity favors shards.** Rerunning one failed shard costs 6 billed minutes at $0.008, or $0.05\. Rerunning the single job costs its 6 minutes at $0.064, or $0.38, because the whole suite goes back on the machine. * **Ceilings favor shards at the top end.** A suite needing more than 128 GB, or one approaching the 6 hour single job limit ([GitHub Actions usage limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), has no single machine option left at all. Those billed minutes carry a list price. GitHub rates below are from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Plan | Billed minutes | WarpBuild label and rate | GitHub-hosted runner and rate | Cost per run | | --------------------- | -------------- | ---------------------------------- | ----------------------------- | ------------------- | | 8 shards, 4 vCPU each | 48 | warp-ubuntu-latest-x64-4x, $0.008 | 4-core larger runner, $0.012 | $0.38 against $0.58 | | 1 job, 32 vCPU | 6 | warp-ubuntu-latest-x64-32x, $0.064 | 32-core larger runner, $0.082 | $0.38 against $0.49 | `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. `warp-ubuntu-latest-x64-32x` (32 vCPU, 128 GB) costs $0.064 per minute against $0.082 for the 32-core Linux larger runner (32 vCPU, 128 GB): 22 percent lower list price. GitHub list prices checked on 2026-08-13. Scale the eight shard plan to a working month at 40 runs a day: 1,920 billed minutes a day, which is $15.36 on `warp-ubuntu-latest-x64-4x` against $23.04 on the GitHub-hosted 4-core larger runner, or $337.92 against $506.88 over 22 working days. The same change returns 21 minutes of wall clock per run against the single 4 vCPU job. Full rates by runner type are on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link **How do I shard a test suite across a GitHub Actions matrix?** Put a shard index in `strategy.matrix`, set `runs-on` to a single label, and pass the index and the shard total to the test command as `--shard=${{ matrix.shard }}/${{ strategy.job-total }}`. Upload one report artifact per shard under a name that carries the shard index, then merge the artifacts in a dependent job that runs with `if: always()` so a failed shard still produces a report. **Is it cheaper to run eight small shards or one large runner?** At the Linux x64 catalog rates the two price the same, because every size charges $0.002 per vCPU minute: eight 4 vCPU shards and one 32 vCPU job both hold 32 vCPU and both burn $0.064 per minute of wall clock. The difference is repeated setup on the shard plan against the memory ceiling and the all-or-nothing rerun on the single machine. **How do I keep one required status check when the shard count changes?** Require the merge job rather than the matrix job. Matrix job names carry the shard index, so a name like `test 4/8` disappears the moment the count changes and branch protection waits forever on a check that no run will report. The merge job has a fixed name, needs the matrix job, and can fail the gate on `needs.test.result`. --- # Caching Toolchains Installed with mise | WarpBuild URL: https://www.warpbuild.com/guides/mise-toolchain-cache-github-actions Description: mise reinstalls every pinned tool on a fresh GitHub Actions runner. Cache the mise data directory, key it on the config files, and verify versions first. # Caching Toolchains Installed with mise mise reinstalls every pinned tool on a fresh GitHub Actions runner. Cache the mise data directory, key it on the config files, and verify versions first. mise installs the tools named in `mise.toml` or `.tool-versions` into a data directory under the home directory, and a GitHub Actions runner is destroyed when its job ends, so every run downloads and unpacks the whole toolchain again. Cache that data directory with `WarpBuilds/mise-action@v2` and `cache: true`, key the entry on the configuration files that decide what gets installed, and print the resolved versions before the build step runs. This guide covers where mise puts its files, the key that ties an entry to the toolchain configuration, a workflow that restores and verifies before it builds, and the minutes and dollars the change moves at real runner rates. For the wider question of carrying state between runs, see the [guide to persistent caches for GitHub Actions runs](/guides/persistent-caches-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link Read the duration GitHub prints next to the mise step on a recent run, then split that number into causes before editing anything. ### [The install work repeats on every job](#the-install-work-repeats-on-every-job)Copy Anchor Link mise resolves each pinned version against its backend, downloads an archive, extracts it into a versioned directory, and writes a shim so the binary appears on `PATH`. On a fresh runner none of that exists. Collect the working set from inside a job: Copy Text ``` mise ls --current du -sh ~/.local/share/mise mise doctor ``` `mise ls --current` prints the active tools with the config file each version came from. `du` gives the size that a cache entry has to carry. `mise doctor` prints the resolved directories, which is the fastest way to confirm the paths on a runner image you did not build. Three directories matter, and they are documented in the [mise directories reference](https://mise.jdx.dev/directories.html): | Path | What it holds | Environment override | | ------------------------------------------------ | ----------------------------------------------------------- | -------------------- | | \~/.local/share/mise/installs | Extracted tool versions, one directory per tool and version | MISE\_DATA\_DIR | | \~/.local/share/mise/shims | Shims that put the pinned versions on PATH | MISE\_DATA\_DIR | | \~/.cache/mise (\~/Library/Caches/mise on macOS) | Downloaded archives and metadata before extraction | MISE\_CACHE\_DIR | The installs directory is the one worth carrying between runs. The download cache is a staging area for the same bytes, so caching both pays twice for one toolchain. ### [mise reads more than one configuration file](#mise-reads-more-than-one-configuration-file)Copy Anchor Link A repository can carry `mise.toml` at the root, `.mise.toml` next to it, and a `.tool-versions` file inherited from an earlier asdf setup, plus per-directory configs inside a monorepo. A cache key that hashes only the root file stays constant when a subdirectory bumps a version, so the entry restores, `mise install` notices the missing version, and the job pays a download that the key said it had avoided. Hash every file that can change the install set. ### [One key can serve two architectures](#one-key-can-serve-two-architectures)Copy Anchor Link A WarpBuild cache entry is scoped to its key, its version, and its branch, and the version is a hash over the compression tool and the cached paths. Linux x64 and Linux ARM64 runners share an operating system and share `~/.local/share/mise/installs`, so a key built from `runner.os` alone matches across both, and x64 binaries restore onto an ARM64 job. Put `runner.arch` in the key. ### [Part of the toolchain is already in the image](#part-of-the-toolchain-is-already-in-the-image)Copy Anchor Link Check the versions your workflow pins against the [preinstalled software on WarpBuild runners](/runners/preinstalled-software) before optimizing the cache. When the image already ships the pinned version, mise installs nothing and the cache step is pure overhead. When the pins are far from the image, every job pays a full install, and a [custom runner image](/guides/custom-runner-images-github-actions) is the cheaper place to put a large toolchain that changes twice a year. ## [Fix](#fix)Copy Anchor Link Two changes, in this order. **1\. Swap the action.** WarpBuild maintains a fork of `jdx/mise-action` that installs mise, runs `mise install` for the tools in your config, and routes the toolchain cache through [WarpBuild Cache](/docs/ci/features/caching/setup-actions). It takes the same inputs: Copy Text ``` - uses: jdx/mise-action@v2 + uses: WarpBuilds/mise-action@v2 with: version: 2024.10.0 cache: true ``` `cache` defaults to `true`, and on a WarpBuild runner the action uses WarpBuild Cache with no further configuration. Pin `version` so a mise release does not change resolution behavior in the middle of a week. **2\. Verify the resolved versions before the build.** A restored data directory and a bumped config can disagree, and the failure mode is a build against the wrong compiler rather than an error. One step closes it: Copy Text ``` - name: Verify toolchain run: | mise ls --current mise exec -- node --version mise exec -- go version ``` The step costs seconds and puts the active versions and their source files in the log, which is where you look first when a green branch starts failing on one runner size. If a config file lives outside the checkout, mise treats it as untrusted until [mise trust](https://mise.jdx.dev/cli/trust.html) records the decision. The caching path differs on one of them. WarpBuild caching is documented as unsupported for Windows-based runners, so on a WarpBuild Windows runner use `actions/cache@v4` against the data directory `mise doctor` reports, and keep `WarpBuilds/mise-action@v2` on Linux and macOS. ## [Configuration](#configuration)Copy Anchor Link The manual form is worth having in the file when a monorepo needs an explicit path list or a shared key across workflows: Copy Text ``` name: build on: pull_request: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x env: MISE_DATA_DIR: ${{ github.workspace }}/.mise steps: - uses: actions/checkout@v5 - name: Restore mise toolchains id: mise-cache uses: WarpBuilds/cache@v1 with: path: | ${{ github.workspace }}/.mise/installs ${{ github.workspace }}/.mise/shims key: mise-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/mise.toml', '**/.tool-versions') }} restore-keys: | mise-${{ runner.os }}-${{ runner.arch }}- - name: Install toolchains run: mise install --yes - name: Verify toolchain run: | mise ls --current mise exec -- node --version mise exec -- go version - name: Build run: mise exec -- make build ``` `MISE_DATA_DIR` pins the install path to the workspace, which keeps the cached path identical when part of the job runs in a container and part runs on the host. The cache client needs `wget` and `zstd` inside a container, and an entry saved with one compression tool does not restore where the other is in use. ### [Key design](#key-design)Copy Anchor Link Hash the configuration files and nothing else. [hashFiles](https://docs.github.com/en/actions/learn-github-actions/expressions#hashfiles) over `**/mise.toml` and `**/.tool-versions` changes when a pinned version changes and stays stable when application code changes, which is the behavior a toolchain cache wants. A commit SHA in the key produces a miss on every run and a new entry on every run, so storage bills for entries that are read once. The [cache key](/glossary/cache-key) term covers the general shape of a key and its fallbacks. Keep the one `restore-keys` prefix. A partial hit is safe here: `mise install` compares the config against what is on disk and installs only the versions that are missing, so restoring last week's entry after a Node bump downloads one tool instead of six. ### [Expiry, fees, and larger toolchains](#expiry-fees-and-larger-toolchains)Copy Anchor Link A cache entry expires 7 days after its last use and can be deleted at any time from the action or from the console, as the [caching documentation](/docs/ci/features/caching) describes. On hosted runners, cache storage bills at $0.20 per GB-month and each write or restore operation bills at $0.0001\. On BYOC runners, cache storage and operations are included. Rates checked on the [pricing page](/pricing) on 2026-08-13. When the toolchain grows past the point where a transfer is worth it, move the state instead of the files. Snapshot runners capture the runner VM with the toolchain already installed, and they sit alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger in the product surface. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link WarpBuild rates come from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The job below runs on `warp-ubuntu-latest-x64-4x` at $0.008 per minute. Substitute your own numbers from the `du` and `mise ls --current` output collected in the diagnosis section. * Six pinned tools: Node.js, Go, Python, Terraform, jq, and a linter, extracting to 1.4 GB in the installs directory and compressing to a 480 MB entry. * 40 workflow runs per weekday over 22 weekdays, so 880 runs per month. * Cold install, meaning download and extract all six tools: 2.40 minutes. * Restored install, meaning entry download, decompression, and a no-op `mise install`: 0.40 minutes. * A pinned version changes on one run in ten, so a save runs 88 times per month at 0.60 minutes, which amortizes to 0.06 minutes per run. | Path | Toolchain minutes per job | Monthly runner minutes | Monthly cost | | ------------------------- | ------------------------- | ---------------------- | ------------ | | Cold install on every job | 2.40 | 2,112 | $16.90 | | Restored from cache | 0.46 | 405 | $3.24 | Add the cache fees on the restored path: 0.48 GB of storage at $0.20 per GB-month is $0.10, and 968 operations at $0.0001 each is $0.10\. The restored path lands at $3.44 per month against $16.90, a difference of $13.46 on one job in one workflow. The number scales with the matrix: a five-leg matrix runs the install step five times per run, so the same change is worth five times as much before any runner resize enters the picture. Two levers sit behind that arithmetic and are worth keeping separate when you build the case internally. The cache removes minutes, and the runner rate sets what each remaining minute costs. On this workload the cache is the larger lever, and it is the one you control entirely from the workflow file. ## [FAQ](#faq)Copy Anchor Link ### [Where does mise install tools on a GitHub Actions runner?](#where-does-mise-install-tools-on-a-github-actions-runner)Copy Anchor Link Into the mise data directory, which defaults to `~/.local/share/mise`. Extracted versions land under `installs//` and the shims that put them on `PATH` land under `shims`. Downloaded archives are staged in the separate cache directory, `~/.cache/mise` on Linux and `~/Library/Caches/mise` on macOS. `MISE_DATA_DIR` and `MISE_CACHE_DIR` move both paths, and `mise doctor` prints the resolved values from inside a job. ### [What should the cache key hash for a mise toolchain?](#what-should-the-cache-key-hash-for-a-mise-toolchain)Copy Anchor Link The files that decide what gets installed, which are `mise.toml` and `.tool-versions` anywhere in the repository, so `hashFiles('**/mise.toml', '**/.tool-versions')`. Add `runner.os` and `runner.arch`, because a Linux x64 entry and a Linux ARM64 entry share the same operating system and the same cached paths and can otherwise match each other. Keep one shorter `restore-keys` prefix so a version bump restores yesterday's entry and `mise install` fills in the delta. ### [Does mise toolchain caching work on WarpBuild Windows runners?](#does-mise-toolchain-caching-work-on-warpbuild-windows-runners)Copy Anchor Link No. The caching documentation lists Windows-based runners as unsupported for WarpBuild caching, so on a WarpBuild Windows runner use `actions/cache@v4` against the data directory that `mise doctor` reports. Linux and macOS runners take `WarpBuilds/mise-action@v2` with `cache` set to `true`, which needs no key management. --- # Building Several Images from One Monorepo | WarpBuild URL: https://www.warpbuild.com/guides/monorepo-docker-builds-github-actions Description: A monorepo that rebuilds every image on every commit pays for images nobody touched. Detect the changed paths, bake only the affected targets, price both paths. # Building Several Images from One Monorepo A monorepo that rebuilds every image on every commit pays for images nobody touched. Detect the changed paths, bake only the affected targets, price both paths. Last verified: 2026-08-13 A monorepo rebuilds every image on every commit because no step in the workflow maps changed files to image targets, so a one line edit under `services/api` rebuilds all twelve Dockerfiles. The fix is a change-detection job that diffs against the merge base and emits the affected target list, plus a bake file whose targets are selected from that list and built against one builder profile. This guide covers the detection step, the bake file and workflow YAML, and a cost model that prices the build-everything path against the affected-set path. ## [Diagnosis](#diagnosis)Copy Anchor Link Three workflow shapes produce the same bill. **One job running `docker buildx bake` with no target argument.** Bake builds the `default` group, and that group usually lists every target in the file. The command is short, which is why it survives the growth from three images to twelve. **A matrix with a hardcoded service list.** Each service gets its own job and its own build, so the job count tracks the directory count. Adding a service means adding a matrix entry, and nothing in the file ever removes one for a given commit. **A workflow level `paths` filter.** `on.push.paths` gates the whole workflow rather than individual images, so it cannot express "build api and web but not worker" inside one run. It also creates a second problem: a workflow that never starts reports nothing, and GitHub documents the resulting stuck pull request under [troubleshooting required status checks](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/troubleshooting-required-status-checks). Teams then mark the check optional, which removes the gate they wanted. The cost shape is the same in all three. Build minutes track the image count rather than the size of the change, so the bill grows every time somebody adds a service and stays flat when a commit touches one line. Shared base stages make it worse. If eight of twelve images start from the same builder stage and each image builds in its own job with its own cache, that base stage resolves once per job. Splitting images across jobs to gain parallelism costs you the layer reuse that a single builder would have given for free. Measuring takes one query against your own history. For each of the last 50 commits on the default branch, record how many image targets built and how many top level service directories the commit touched. When the first number sits at the image count while the second sits at one or two, the workflow is building on a schedule rather than on a change. ## [Fix](#fix)Copy Anchor Link The pattern has three pieces: a detection job that turns a diff into a JSON list of bake targets, a bake file with one target per image plus a group, and one builder profile that all selected targets build against so their shared layers stay warm. Start with the mapping. Write it once, as data, and keep it next to the bake file. | Changed path | Targets selected | Reason | | ------------------------------------------ | ---------------- | ----------------------------------------- | | services/api/\*\* | api | One service directory, one image | | services/web/\*\* | web | One service directory, one image | | services/worker/\*\* | worker | One service directory, one image | | packages/shared/\*\* | api, web, worker | Every image vendors this package | | Dockerfile.base, docker-bake.hcl, bun.lock | every target | Layers below the service copy step change | Two rules keep that table honest. Anything shared selects every target that depends on it, and anything unmapped selects every target, so a new directory fails safe by building too much instead of shipping a stale image. The compute the pattern needs, with per-minute rates from the [cloud runners documentation](/docs/ci/cloud-runners) and the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13: | Job | Where it runs | Shape | Rate per minute | | ------------------- | ------------------------- | ----------------------------- | --------------- | | Change detection | warp-ubuntu-latest-x64-2x | 2 vCPU, 8GB, 150GB SSD | $0.004 | | Bake driver | warp-ubuntu-latest-x64-4x | 4 vCPU, 16GB, 150GB SSD | $0.008 | | Image build | Builder profile | 32 vCPU, 64GB RAM, 200GB disk | $0.12 | | Required check gate | warp-ubuntu-latest-x64-2x | 2 vCPU, 8GB, 150GB SSD | $0.004 | The driver job label is the only thing that changes if the images need an ARM64 host. Builder profiles carry the layer cache across runs, and the cache resets after 10 days without use. Multi architecture and arm64 profiles top out at 64 vCPU; the 96 and 192 vCPU sizes, $0.36 to $0.88 per minute, are amd64 only. One profile for all the targets in a repository is the default worth starting from. Concurrent jobs on one profile share a builder VM and a session, so twelve targets that share a base stage resolve that stage against one cache rather than twelve. Split into a second profile only when two image families share no layers and contend for disk. ## [Configuration](#configuration)Copy Anchor Link The bake file names one target per image and inherits shared settings from a hidden target. Copy Text ``` # docker-bake.hcl variable "TAG" { default = "dev" } variable "REGISTRY" { default = "ghcr.io/acme" } target "_common" { context = "." platforms = ["linux/amd64"] cache-from = ["type=registry,ref=${REGISTRY}/cache"] } target "api" { inherits = ["_common"] dockerfile = "services/api/Dockerfile" tags = ["${REGISTRY}/api:${TAG}"] } target "web" { inherits = ["_common"] dockerfile = "services/web/Dockerfile" tags = ["${REGISTRY}/web:${TAG}"] } target "worker" { inherits = ["_common"] dockerfile = "services/worker/Dockerfile" tags = ["${REGISTRY}/worker:${TAG}"] } group "default" { targets = ["api", "web", "worker"] } ``` The `default` group stays complete so a local `docker buildx bake` still builds everything. The workflow overrides it by naming targets on the command line, which is what makes the same file usable from a laptop and from GitHub Actions. The detection job diffs against the merge base and writes a JSON array to a job output. Copy Text ``` name: images on: push: branches: [main] pull_request: env: ALL_TARGETS: '["api","web","worker"]' jobs: changed: runs-on: warp-ubuntu-latest-x64-2x outputs: targets: ${{ steps.select.outputs.targets }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - id: select env: BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} run: | set -euo pipefail if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then echo "targets=$ALL_TARGETS" >> "$GITHUB_OUTPUT" exit 0 fi changed=$(git diff --name-only "$BASE" "$GITHUB_SHA") if grep -qE '^(Dockerfile\.base|docker-bake\.hcl|bun\.lock|packages/shared/)' <<< "$changed"; then echo "targets=$ALL_TARGETS" >> "$GITHUB_OUTPUT" exit 0 fi selected=() for svc in api web worker; do if grep -q "^services/$svc/" <<< "$changed"; then selected+=("$svc") fi done if [ ${#selected[@]} -eq 0 ]; then targets='[]' else targets=$(printf '%s\n' "${selected[@]}" | jq -Rsc 'split("\n") | map(select(length > 0))') fi echo "targets=$targets" >> "$GITHUB_OUTPUT" ``` `fetch-depth: 0` matters. The default shallow clone gives the job one commit, and `git diff` against a base SHA the runner never fetched fails with `bad object`. The zero SHA guard covers the first push to a new branch, where `github.event.before` carries no parent and the safe answer is to build everything. The build job expands that array into bake target arguments and points at one builder profile. Copy Text ``` build: needs: changed if: needs.changed.outputs.targets != '[]' runs-on: warp-ubuntu-latest-x64-4x env: TAG: ${{ github.sha }} REGISTRY: ghcr.io/${{ github.repository_owner }} steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Bake the affected targets uses: Warpbuilds/bake-action@v6 with: files: docker-bake.hcl targets: ${{ join(fromJSON(needs.changed.outputs.targets), ',') }} push: ${{ github.event_name == 'push' }} profile-name: "monorepo-images" api-key: ${{ secrets.WARPBUILD_API_KEY }} # Not required on WarpBuild runners timeout: 600000 images: needs: [changed, build] if: always() runs-on: warp-ubuntu-latest-x64-2x steps: - name: Report the aggregate result run: | result="${{ needs.build.result }}" if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then echo "build job result: $result" exit 1 fi echo "build job result: $result" ``` `Warpbuilds/bake-action` is a drop in replacement for `docker/bake-action`, so `files`, `targets`, `push`, and `set` keep their meaning, and the action wires up the remote builder itself. Drop `docker/setup-buildx-action` when it was only there to create a builder. The `TAG` and `REGISTRY` job environment variables feed the HCL `variable` blocks, so the same bake file tags by commit SHA in the workflow and by `dev` on a laptop. The `images` job is what a branch protection rule should require. It runs on every commit, including the ones where `build` was skipped, so the check reports rather than hanging. When you need the same discipline for tests and lint alongside images, the [monorepo pipelines guide](/solutions/monorepo-github-actions) covers the wider fan out, and [running a job only when files change](/answers/how-do-i-run-a-job-only-when-files-change) covers the single job case. Two details worth knowing before this reaches a busy repository. `cache-to` and `cache-from` are not required with a builder profile, since the profile caches layers and reuses them on the next build; keep the registry cache entry in `_common` only if builds also run outside GitHub Actions. And bake target names become part of your public interface the moment the mapping table references them, so rename them the same way you rename a package. The [bake reference](https://docs.docker.com/build/bake/reference/) documents target inheritance and group expansion, and the [caching documentation](/docs/ci/features/caching) covers the dependency cache that sits alongside the layer cache. CI observability is the practical one here, because it correlates system metrics from the runner agent with GitHub Actions job logs, which shows whether the driver job is waiting on the builder or on the registry push. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Take a repository with twelve images, 300 commits per month on the default branch, and a change profile where the average commit touches two services. Bake all twelve targets and the builder session runs 14 minutes; bake two and it runs 4 minutes. Rates are from the [pricing page](/pricing) and the [Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13\. Substitute your own durations, because the ratio between the two paths is what the model is for. | Path | Arithmetic | Cost per commit | Wall clock | | ---------------------- | ----------------------------------------- | --------------- | ---------- | | Build every image | (14 x $0.12) + (14 x $0.008) | $1.792 | 14 min | | Build the affected set | (4 x $0.12) + (4 x $0.008) + (1 x $0.004) | $0.516 | 5 min | The affected-set path adds one minute of detection at $0.004 and removes ten minutes of builder session. Across 300 commits that is $537.60 against $154.80, a difference of $382.80 per month, and 2,700 minutes of pipeline time returned, roughly 45 hours. The model moves with the change profile rather than with the image count, so run it against your own history. | Average services touched | Session minutes | Cost per commit | Monthly at 300 commits | | ------------------------ | --------------- | --------------- | ---------------------- | | 1 | 3 | $0.388 | $116.40 | | 2 | 4 | $0.516 | $154.80 | | 4 | 7 | $0.900 | $270.00 | | 8 | 10 | $1.284 | $385.20 | | 12 (every image) | 14 | $1.796 | $538.80 | Read the last row against the first table. When every commit touches every image, detection costs $0.004 and returns nothing, which is the honest break-even. The pattern earns its keep when the bottom rows are rare, and the way to check is the 50 commit sample from the diagnosis section rather than an assumption about how the team works. Two levers sit underneath the table. Profile size trades rate against session length: a 16 vCPU profile at $0.06 per minute halves the rate and lengthens the session, so it prices better on IO bound images and worse on compile bound ones. A repository that bakes every target on every commit keeps the cache warm by accident; a repository on the affected-set path keeps it warm on purpose by sending all targets to one profile. The GitHub Actions job and the builder session are separate resources and both are billed, which is why the driver runner stays at 4 vCPU in every row above. Sizing the driver up buys nothing when the build runs on the builder. For the wider Docker picture on GitHub Actions, including registry choice and cache strategy, see [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions), and for the case where several images build at once, see [concurrent Docker builds](/guides/concurrent-docker-builds-github-actions). The target syntax itself is covered in [Docker bake](/glossary/docker-bake). ## [FAQ](#faq)Copy Anchor Link ### [Which images should build when a shared base image changes?](#which-images-should-build-when-a-shared-base-image-changes)Copy Anchor Link All of them. Treat the base Dockerfile, the bake file, and the lockfile as global triggers that select every target, because a change in any of the three invalidates layers in every image that inherits from them. Everything else maps one service directory to one bake target. ### [Why does the workflow need a job that always runs?](#why-does-the-workflow-need-a-job-that-always-runs)Copy Anchor Link A skipped job reports nothing to a required status check, so a pull request whose build job was filtered out waits forever. Add a final job with `if: always()` that reads the build job result and exits non-zero on failure or cancellation, then make that job the required check. ### [Can one builder profile handle several bake targets in the same run?](#can-one-builder-profile-handle-several-bake-targets-in-the-same-run)Copy Anchor Link Yes. Concurrent jobs on one builder profile run on the same builder VM and bill as one session, measured from the first job start to the last job completion. The layer cache is shared across those jobs and is eventually consistent, so a layer written by one build may reach the next build rather than the concurrent one. --- # MSBuild Caching on GitHub Actions Windows Runners | WarpBuild URL: https://www.warpbuild.com/guides/msbuild-caching-github-actions Description: MSBuild rebuilds every project on GitHub Actions because runners are ephemeral. Cache NuGet, keep incremental output inside one job, and skip idle analyzers. # MSBuild Caching on GitHub Actions Windows Runners MSBuild rebuilds every project on GitHub Actions because runners are ephemeral. Cache NuGet, keep incremental output inside one job, and skip idle analyzers. Last verified: 2026-08-13 MSBuild has a working incremental build system, and it gives you nothing on GitHub Actions, because every job starts on a fresh machine with empty `obj` and `bin` directories and no previous timestamps to compare against. On a Windows runner the practical answer is to cache the NuGet global packages folder with `actions/cache`, preserve incremental output inside a single job rather than trying to carry it between jobs, and stop analyzers from rerunning on projects nobody touched. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. This guide is about the Windows fleet and about one constraint that shapes every decision below: WarpBuild cache is enabled by default on Linux runners and is documented as unsupported on Windows runners, so Windows caching goes through `actions/cache` or a build-server strategy. ## [Diagnosis](#diagnosis)Copy Anchor Link Three separate mechanisms make a Windows MSBuild job repeat work it already did. They look identical in the GitHub Actions timing panel, and they have different fixes, so separate them before changing anything. ### [No incremental state survives between jobs](#no-incremental-state-survives-between-jobs)Copy Anchor Link Runners are ephemeral. The virtual machine is created for the job and destroyed at the end, so the NuGet global packages folder under the runner profile, the `obj` intermediate outputs, and the `bin` outputs are all empty when the job starts. A solution with 300 package references downloads and extracts 300 packages on every run. A solution with 60 projects compiles 60 projects on every run. The signal is a restore step that takes minutes and a build step whose duration barely moves between a one-line change and a thousand-line change. ### [Checkout timestamps force a full solution rebuild](#checkout-timestamps-force-a-full-solution-rebuild)Copy Anchor Link This is the part that surprises people who try to fix the first problem by caching `obj` and `bin`. MSBuild decides whether a project is up to date by comparing input timestamps against output timestamps. Git stores no modification times, so `actions/checkout` writes every source file with the current clock. Every source file is therefore newer than any output you restored from a cache, and MSBuild rebuilds the project. Restoring build output across jobs also opens a correctness hole. A stale `obj` folder from a different compiler version or a different property set produces a build that succeeds locally and fails to reproduce, which costs more time than it saves. ### [Analyzers and source generators rerun on unchanged projects](#analyzers-and-source-generators-rerun-on-unchanged-projects)Copy Anchor Link Roslyn analyzers and source generators run as part of compilation. When the project rebuilds, they run again, on every syntax tree, including the projects whose sources are byte-identical to the last run. A large rule set on a 60-project solution can spend more time in analyzers than in code generation. Prove it before acting. Build with `-p:ReportAnalyzer=true -v:n` and read the analyzer execution summary the compiler prints, then capture a binary log with `-bl` and inspect which targets ran and why MSBuild judged each project out of date. WarpBuild's CI observability collects OpenTelemetry system metrics from the runner agent and correlates them with GitHub Actions job logs, which is how you tell a CPU-bound analyzer pass from an IO-bound package extraction without guessing. ### [What is worth caching on Windows](#what-is-worth-caching-on-windows)Copy Anchor Link | Artifact | Path | Cache key | Reuse across jobs | | ------------------------------ | --------------- | -------------------------- | ---------------------------------------- | | NuGet global packages | C:\\nuget | hash of packages.lock.json | Yes, this is the main win | | MSBuild intermediate output | per-project obj | none | No, checkout timestamps defeat it | | Compiled assemblies | per-project bin | none | No, and stale output risks a bad build | | Analyzer and generator results | inside obj | none | No, they are tied to the same timestamps | ## [Fix](#fix)Copy Anchor Link Work down the list in order. The first four cost nothing per minute and usually return more time than a larger runner does. **1\. Cache the NuGet global packages folder.** Point `NUGET_PACKAGES` at a short path such as `C:\nuget`, which also keeps package paths clear of the Windows path length limit, and key an `actions/cache` entry on the hash of your lock files. Turn on `RestorePackagesWithLockFile` so the lock files exist, and restore with `-p:RestoreLockedMode=true` so a drifted lock file fails the job instead of silently re-resolving against the feed. WarpBuild cache is unsupported on Windows runners, and `actions/cache` writes to GitHub's own Actions cache service, which a WarpBuild runner reaches with no extra configuration because it receives the same cache credentials any GitHub Actions job receives. Key design and lock file handling are covered in detail in [caching NuGet packages on GitHub Actions](/guides/nuget-cache-github-actions). **2\. Restore once, then build without restoring.** Run `-t:Restore` as its own step with `-p:RestoreUseStaticGraphEvaluation=true` so the project graph is evaluated once, then run `-t:Build` on its own. Merging the two makes MSBuild walk the graph twice per run. **3\. Preserve incremental output inside the job.** The job is the only scope where MSBuild incrementality is trustworthy, so build the solution exactly once and let every later step consume that output. Pass `--no-build` to `dotnet test` and `-p:NoBuild=true` to a publish or pack target. Never run a clean target between them. On a solution with several test projects and a publish step, this alone removes two or three full compilations per run. **4\. Turn on parallel project builds.** `msbuild.exe` uses a single node unless you pass `-m`, so a 60-project solution on an 8 vCPU runner compiles one project at a time while seven cores idle. Pass `-m` explicitly rather than relying on an SDK default that moves between versions, and set `MSBUILDDISABLENODEREUSE=1`, because reused worker processes buy nothing on a machine that is about to be destroyed and occasionally hold file locks that break later steps. **5\. Move analyzers off the pull request path.** Set `-p:RunAnalyzersDuringBuild=false` on the build that gates pull requests, and run one job on merges to `main` that builds with analyzers on and `-p:ReportAnalyzer=true`. Rule violations still block the branch, and the per-pull-request wait drops by whatever the analyzer pass was costing. **6\. Consider a build-server strategy for the parts that do not need Windows.** Cross-platform .NET code often runs its whole pipeline on Windows out of habit. Compilation and test for platform-neutral projects can run on `warp-ubuntu-latest-x64-8x` at $0.016 per minute, where WarpBuild cache is on by default and snapshot runners are available, and Windows keeps the targets that genuinely need Windows toolsets. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, so this option applies to the Linux side of the pipeline. The toolchain detail for splitting a pipeline this way lives in [running .NET builds on GitHub Actions](/solutions/dotnet-github-actions). **7\. Size the runner last.** Move up one size at a time and read the metrics after each move. When the build stops scaling with cores, split work across jobs instead. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. ## [Configuration](#configuration)Copy Anchor Link ### [The workflow](#the-workflow)Copy Anchor Link This is the whole fix as a workflow. It restores packages from the Actions cache, restores and builds as separate steps, builds projects in parallel, and keeps the build output alive for the test and publish steps. Copy Text ``` name: windows-msbuild on: pull_request: push: branches: [main] env: DOTNET_NOLOGO: "true" DOTNET_CLI_TELEMETRY_OPTOUT: "true" MSBUILDDISABLENODEREUSE: "1" NUGET_PACKAGES: C:\nuget jobs: build: runs-on: warp-windows-latest-x64-8x timeout-minutes: 30 steps: - uses: actions/checkout@v4 with: fetch-depth: 1 - name: Restore the NuGet packages folder from the Actions cache uses: actions/cache@v4 with: path: C:\nuget key: nuget-windows-${{ hashFiles('**/packages.lock.json') }} restore-keys: | nuget-windows- - uses: microsoft/setup-msbuild@v2 - name: Restore once, with static graph evaluation run: > msbuild Contoso.sln -t:Restore -m -p:RestoreUseStaticGraphEvaluation=true -p:RestoreLockedMode=true - name: Build every project in parallel, keeping obj and bin in place run: > msbuild Contoso.sln -t:Build -m -p:Configuration=Release -p:ContinuousIntegrationBuild=true -p:RunAnalyzersDuringBuild=false -bl:artifacts/build.binlog - name: Test against the output the build step produced run: > dotnet test Contoso.sln --configuration Release --no-build --logger trx - name: Publish from the same output run: > msbuild src/Contoso.Web/Contoso.Web.csproj -t:Publish -m -p:Configuration=Release -p:NoBuild=true -p:PublishDir=artifacts/web - uses: actions/upload-artifact@v4 if: always() with: name: build-binlog path: artifacts/build.binlog ``` Two details are easy to get wrong. `NUGET_PACKAGES` is set at the workflow level so the cache path, the restore, and the build all agree on one folder. And the binary log is uploaded even on failure, because the run you most want to inspect is the one that broke. ### [The analyzer job](#the-analyzer-job)Copy Anchor Link Analyzers still gate the default branch. This job runs on merges only and prints where the analyzer time goes. Copy Text ``` analyzers: if: github.ref == 'refs/heads/main' runs-on: warp-windows-latest-x64-8x timeout-minutes: 45 steps: - uses: actions/checkout@v4 with: fetch-depth: 1 - uses: microsoft/setup-msbuild@v2 - name: Build with analyzers and report their cost run: > msbuild Contoso.sln -t:Restore;Build -m -p:Configuration=Release -p:RunAnalyzersDuringBuild=true -p:ReportAnalyzer=true -v:n ``` ### [Images and labels](#images-and-labels)Copy Anchor Link `warp-windows-latest-x64-8x` runs Windows Server 2022 with Visual Studio 2022\. Use `warp-windows-2025-x64-8x` for the Windows Server 2025 base, and `warp-windows-2025-vs2026-x64-8x` when the solution needs Visual Studio 2026 toolsets. Pin an explicit version label rather than `latest` when the build depends on a specific toolset. Image detail lives on [Visual Studio images on WarpBuild Windows runners](/runners/windows/visual-studio-images), and cache behavior is documented on the [WarpBuild caching page](/docs/ci/features/caching). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Windows rates are linear in vCPU: every Windows vCPU-minute lists at $0.004, so the catalog runs from $0.016 per minute at 4 vCPU to $0.128 per minute at 32 vCPU. That linearity makes sizing arithmetic instead of opinion. | WarpBuild label | vCPU | RAM | Storage | Per minute | GitHub-hosted Windows larger runner | GitHub per minute | Lower list price | | --------------------------- | ---- | ------ | --------- | ---------- | ----------------------------------- | ----------------- | ---------------- | | warp-windows-latest-x64-4x | 4 | 16 GB | 256GB SSD | $0.016 | 4-core, 4 vCPU, 16 GB | $0.022 | 27 percent | | warp-windows-latest-x64-8x | 8 | 32 GB | 256GB SSD | $0.032 | 8-core, 8 vCPU, 32 GB | $0.042 | 24 percent | | warp-windows-latest-x64-16x | 16 | 64 GB | 256GB SSD | $0.064 | 16-core, 16 vCPU, 64 GB | $0.082 | 22 percent | | warp-windows-latest-x64-32x | 32 | 128 GB | 256GB SSD | $0.128 | 32-core, 32 vCPU, 128 GB | $0.162 | 21 percent | WarpBuild rates and shapes come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing). GitHub rates and shapes come from the [Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. The same four sizes exist on the Windows Server 2025 and Visual Studio 2026 label families at identical rates, and the full matrix with aliases and regions is on [WarpBuild Windows runners for GitHub Actions](/runners/windows). The smallest Windows size is 4 vCPU, because Windows 2vCPU runners were removed on June 8, 2026. ### [A worked model](#a-worked-model)Copy Anchor Link Replace these inputs with numbers from your own run history. * 800 Windows solution builds per month, roughly 36 per weekday. * A baseline build of 18 minutes on `warp-windows-latest-x64-4x`, of which 4 minutes is NuGet restore. * 200 of those 800 runs are merges to `main`. **Step one, same shape, lower rate.** 800 builds at 18 minutes is 14,400 Windows minutes. At $0.016 that is $230.40 per month, against $316.80 for the same minutes on the 4-core GitHub-hosted Windows larger runner at $0.022\. Nothing about the build changed. **Step two, cache the packages folder.** A warm NuGet cache plus static graph evaluation takes restore from 4 minutes to 1 minute, so the build is 15 minutes. That is 12,000 minutes, or $192.00\. Cache entries live in GitHub's Actions cache service, so this step adds no WarpBuild line item. **Step three, move analyzers to merges.** Say the analyzer pass is 3 minutes. Pull request builds drop to 12 minutes: 800 times 12 is 9,600 minutes, or $153.60\. The dedicated analyzer job runs on 200 merges at 8 minutes each, which is 1,600 minutes, or $25.60\. Monthly total is $179.20, and every pull request check returns 3 minutes sooner. **Step four, decide whether more cores pay.** An 8 vCPU minute costs $0.032, exactly twice a 4 vCPU minute, so break-even is half the wall clock: 12 times 0.016 divided by 0.032 is 6 minutes. With `-m` on a 60-project solution, suppose the build lands at 8 minutes. The pull request line goes from $153.60 to $204.80 and buys 4 minutes on every run. 800 runs times 4 minutes is 3,200 minutes, about 53 hours per month of engineers waiting on a check, for $51.20. If your Windows fleet is large enough that compute economics dominate, BYOC runs on AWS, GCP, and Azure with the compute billed to your own cloud account. ## [FAQ](#faq)Copy Anchor Link ### [Can I cache the obj and bin folders between GitHub Actions jobs?](#can-i-cache-the-obj-and-bin-folders-between-github-actions-jobs)Copy Anchor Link You can restore them, but MSBuild will still rebuild. Up-to-date checks compare source timestamps against output timestamps, and `actions/checkout` writes every source file with the checkout time, which is newer than any output you restored. The reliable cross-job cache on Windows is the NuGet global packages folder. Incremental output is worth preserving inside one job, across the restore, build, test, and publish steps. ### [Does WarpBuild caching work on Windows runners?](#does-warpbuild-caching-work-on-windows-runners)Copy Anchor Link No. WarpBuild cache is available on Linux runners and is enabled by default there, and the documentation lists it as unsupported for Windows runners. On a WarpBuild Windows runner, use `actions/cache`, which writes to GitHub's own Actions cache service and needs no extra configuration. ### [Which MSBuild switches actually reduce build time in GitHub Actions?](#which-msbuild-switches-actually-reduce-build-time-in-github-actions)Copy Anchor Link Pass `-m` so MSBuild builds independent projects in parallel instead of one node at a time, set `MSBUILDDISABLENODEREUSE=1` because worker reuse buys nothing on a machine that is destroyed after the job, run `-t:Restore` once with `-p:RestoreUseStaticGraphEvaluation=true`, and then run `-t:Build` without restoring again. Use `--no-build` for the test step so the test host reuses the output the build step produced. ### [How do I find out how much time analyzers cost my build?](#how-do-i-find-out-how-much-time-analyzers-cost-my-build)Copy Anchor Link Build with `-p:ReportAnalyzer=true` and `-v:n`. The log prints total analyzer execution time and a per-analyzer breakdown, so you can see which rules dominate. Capture a binary log with `-bl` at the same time and read it to confirm which targets ran and why MSBuild considered each project out of date. --- # Multi Platform Docker Images on Native Runners | WarpBuild URL: https://www.warpbuild.com/guides/multi-platform-docker-builds Description: Multi arch Docker builds crawl on one x64 runner because the arm64 stage runs under QEMU. Build each architecture natively and join them with a manifest. # Multi Platform Docker Images on Native Runners Multi arch Docker builds crawl on one x64 runner because the arm64 stage runs under QEMU. Build each architecture natively and join them with a manifest. Last verified: 2026-08-13 A `docker buildx build --platform linux/amd64,linux/arm64` on a single x64 GitHub Actions runner is slow because the arm64 half of that build runs under QEMU user mode emulation, which translates every guest instruction before the host CPU executes it. The fix is to build each architecture on a runner of that architecture and join the two images with a manifest list, which on WarpBuild means pairing `warp-ubuntu-latest-x64-8x` at $0.016 per minute with `warp-ubuntu-latest-arm64-8x` at $0.012 per minute. This guide covers the diagnosis, the split workflow YAML, the remote Docker builder alternative for teams that want one job, and a worked cost model against GitHub list prices. ## [Diagnosis](#diagnosis)Copy Anchor Link When you pass two platforms to a single buildx invocation and the builder has one node, BuildKit resolves the Dockerfile once per platform and produces two independent build graphs on that one machine. The host is x64\. The arm64 graph therefore needs an aarch64 execution environment, and the usual way a GitHub Actions workflow gets one is `docker/setup-qemu-action`, which installs binfmt handlers so the kernel hands aarch64 binaries to a QEMU user mode interpreter. That interpreter is where the time goes. QEMU translates aarch64 instructions into x64 instructions at run time and executes the translation. Work that is dominated by CPU pays for this on every instruction it executes: gcc and clang, rustc, the Go linker, javac, node-gyp and other native module builds, image and asset processing, minifiers, and anything that compiles a wheel from source in a `pip install`. Work dominated by the network pays much less, because a package download runs at the speed of the registry either way. Three second order effects make the emulated path worse than the instruction translation alone suggests. **Both stages share one machine.** BuildKit will happily run the amd64 and arm64 graphs concurrently on the same node, so they contend for the same vCPUs, the same page cache, and the same disk. The native amd64 stage slows down while the emulated arm64 stage is running, and the arm64 stage becomes the critical path for the whole job. **Cache entries are keyed per platform.** A warm layer cache from the amd64 stage does nothing for the arm64 stage. If your registry cache or `type=gha` cache was sized around a single architecture build, the two platform build doubles the entries it wants to store and restore. **Emulated toolchains fail in ways native ones do not.** The common ones worth recognizing in a log: `exec format error` when a binfmt handler is missing or was never registered in that job; segfaults and hangs in JIT heavy runtimes and in threading heavy builds; and `Illegal instruction` from libraries that probe for CPU features the interpreter does not implement. Every one of these reads like a Dockerfile bug and is actually an emulation bug, which is why teams often lose an afternoon before they suspect QEMU. There is also a hard ceiling. A GitHub Actions job can run for up to 6 hours, and a two platform build of a large compiled service on one emulated runner can approach that limit as the codebase grows. The failure mode is a cancelled job with no artifact, usually on the day a dependency starts building from source. Confirming the diagnosis takes one experiment. Run the same build twice on the same runner, once with `--platform linux/amd64` alone and once with both platforms. The difference is what the arm64 stage costs you under emulation. If the single platform build finishes in a few minutes and the two platform build runs for tens of minutes, the emulated stage is the whole problem and no amount of runner resizing fixes it, because a bigger x64 machine still emulates. One partial fix stays inside a single runner: cross compilation. Pin the builder stage to the native architecture with `FROM --platform=$BUILDPLATFORM golang:1.24 AS build`, read `TARGETARCH` inside that stage, and emit an aarch64 binary from a native x64 compiler, then copy it into a thin runtime stage. That keeps the compiler out of the interpreter. It requires a toolchain that cross compiles cleanly, which rules out most builds with cgo, native npm modules, or a `RUN` step that executes a freshly built arm64 binary during the build. When cross compilation does not apply, native runners are the answer. ## [Fix](#fix)Copy Anchor Link Split the build. Run one job per architecture on a runner of that architecture, have each job push its image by digest without a tag, and add a short third job that writes a manifest list joining both digests under the tag your consumers pull. Nothing in the arm64 job is emulated, and the two build jobs run in parallel, so the wall clock for the pair is the slower of the two rather than the sum. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Linux runners attach to a workflow through the `runs-on` label, so the split costs one matrix entry per architecture. These are the labels and rates for the three jobs in the pattern, taken from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing): | Job | Runner label | vCPU | RAM | Storage | Rate per minute | | -------------- | --------------------------- | ---- | ---- | --------- | --------------- | | amd64 build | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | arm64 build | warp-ubuntu-latest-arm64-8x | 8 | 32GB | 150GB SSD | $0.012 | | manifest merge | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | Both architectures come in the same five sizes, so the matrix scales without changing shape: | vCPU | RAM | x64 label | x64 rate | ARM64 label | ARM64 rate | | ---- | ----- | -------------------------- | -------- | ---------------------------- | ---------- | | 2 | 8GB | warp-ubuntu-latest-x64-2x | $0.004 | warp-ubuntu-latest-arm64-2x | $0.003 | | 4 | 16GB | warp-ubuntu-latest-x64-4x | $0.008 | warp-ubuntu-latest-arm64-4x | $0.006 | | 8 | 32GB | warp-ubuntu-latest-x64-8x | $0.016 | warp-ubuntu-latest-arm64-8x | $0.012 | | 16 | 64GB | warp-ubuntu-latest-x64-16x | $0.032 | warp-ubuntu-latest-arm64-16x | $0.024 | | 32 | 128GB | warp-ubuntu-latest-x64-32x | $0.064 | warp-ubuntu-latest-arm64-32x | $0.048 | The ARM64 rate sits below the x64 rate at every size, so the arm64 half of a split build is the cheaper half per minute as well as the faster one once emulation is gone. The `latest` labels track Ubuntu 24.04; pin with `warp-ubuntu-2404-arm64-8x` or move to `warp-ubuntu-2604-arm64-8x` when you want Ubuntu 26.04\. The full ARM64 catalog, including the Ubuntu 26.04 labels, is on the [Linux ARM64 runner page](/runners/linux-arm64). Two operational details to plan for before you cut over. Ubuntu 24.04 ARM64 runners set the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/`, so any script with a hardcoded path needs an update when the job changes architecture. And workloads that need nested virtualization are x64 only, which rarely affects an image build but matters if the same workflow also runs an Android emulator. The [guide to running GitHub Actions jobs on ARM64 runners](/guides/arm64-github-actions-runners) covers the rest of the porting checklist. ## [Configuration](#configuration)Copy Anchor Link The workflow below builds both architectures in parallel, pushes each by digest, and merges them into a single tag. It uses `warp-ubuntu-latest-x64-8x` for amd64 and `warp-ubuntu-latest-arm64-8x` for arm64. Copy Text ``` name: docker on: push: branches: [main] env: IMAGE: ghcr.io/${{ github.repository }} jobs: build: runs-on: ${{ matrix.runner }} timeout-minutes: 45 strategy: fail-fast: false matrix: include: - platform: linux/amd64 runner: warp-ubuntu-latest-x64-8x - platform: linux/arm64 runner: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - name: Name the digest artifact env: PLATFORM: ${{ matrix.platform }} run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV" - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/setup-buildx-action@v3 - name: Build and push by digest id: build uses: docker/build-push-action@v6 with: context: . platforms: ${{ matrix.platform }} outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true - name: Export digest run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - uses: actions/upload-artifact@v4 with: name: digests-${{ env.PLATFORM_PAIR }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 merge: runs-on: warp-ubuntu-latest-x64-2x needs: build steps: - uses: actions/download-artifact@v4 with: path: /tmp/digests pattern: digests-* merge-multiple: true - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/setup-buildx-action@v3 - name: Create the manifest list working-directory: /tmp/digests run: | docker buildx imagetools create \ -t ${{ env.IMAGE }}:latest \ -t ${{ env.IMAGE }}:${{ github.sha }} \ $(printf '${{ env.IMAGE }}@sha256:%s ' *) - name: Inspect the result run: docker buildx imagetools inspect ${{ env.IMAGE }}:latest ``` Four things in that file carry the pattern. `push-by-digest=true` with `name-canonical=true` uploads the image without claiming a tag, so the two architecture builds never race to overwrite each other. Each build job leaves an empty file named after its digest and uploads it, which is the cheapest way to pass a digest between jobs. The merge job downloads every `digests-*` artifact into one directory and expands the filenames into the `imagetools create` arguments. `fail-fast: false` keeps a broken arm64 build from cancelling a healthy amd64 build, which makes the failure easier to read. Note what is missing: there is no `docker/setup-qemu-action` step anywhere, because no job builds a foreign architecture. ### [Alternative: remote Docker builders](#alternative-remote-docker-builders)Copy Anchor Link If you would rather keep one build job, run the build off the runner. [Remote Docker builders](/guides/remote-docker-builders) are dedicated build VMs with a persistent layer cache, selected per builder profile, and multi architecture builds are supported directly. Create a profile on the [Docker Builders page](https://app.warpbuild.com/ci/docker-builders), enable both architectures on it, and point the WarpBuild build and push action at it: Copy Text ``` name: docker on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ghcr.io/${{ github.repository }}:latest profile-name: "multi-arch-builder" api-key: ${{ secrets.WARPBUILD_API_KEY }} # Not required on WarpBuild runners timeout: 600000 ``` `Warpbuilds/build-push-action` is a drop in replacement for `docker/build-push-action`, so the `context`, `push`, `tags`, and `platforms` inputs keep their meaning. Remove `docker/setup-buildx-action` when it was only there to create a builder, since the action wires up the remote builder itself. Remote Docker builders work with both WarpBuild runners and non-WarpBuild runners; the `api-key` input is what authenticates the job when the runner is not a WarpBuild runner. If you need custom build steps rather than a single action, `Warpbuilds/docker-configure@v1` provisions the builder and exposes its details for your own commands, and the same assignment flow is available from the API and the CLI. Profile sizing and rates come from the [Docker builders documentation](/docs/ci/docker-builders): | Profile size | Disk | Rate per minute | Architectures | | ------------------ | ----- | --------------- | ------------------- | | 16 vCPU, 32GB RAM | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU, 64GB RAM | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU, 128GB RAM | 200GB | $0.24 | amd64, arm64, multi | Multi architecture and arm64 profiles top out at 64 vCPU. The 96 and 192 vCPU sizes, from $0.36 to $0.88 per minute, are amd64 only. Billing is per session, measured from when the builder action starts until the job completes, and a multi architecture build creates one session per architecture because each architecture runs on its own builder instance. The builder cache resets after 10 days without use, and an `exec format error` from this path almost always means the profile is missing one of the two architectures. CI observability is the practical one here, since it correlates system metrics from the runner agent with GitHub Actions job logs, so you can see whether the arm64 job is CPU bound at its current size before you move it from 8 vCPU to 16 vCPU. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Measure your own build first: run the single platform amd64 build and the two platform build on the same runner, then run each architecture natively. The model below uses one set of example durations so the arithmetic is visible; substitute yours. Assume the emulated path runs 42 billed minutes on one `warp-ubuntu-latest-x64-8x` runner. Split natively, the amd64 job takes 9 minutes, the arm64 job takes 10 minutes, and the merge job takes 1 minute on a 2 vCPU runner. WarpBuild rates are from the [pricing page](/pricing). | Path | Arithmetic | Cost per build | Wall clock | | ------------------------ | ------------------------------------------- | -------------- | ---------- | | Emulated, one x64 runner | 42 min x $0.016 | $0.672 | 42 min | | Native split, three jobs | (9 x $0.016) + (10 x $0.012) + (1 x $0.004) | $0.268 | 11 min | The split path costs $0.404 less per build and returns 31 minutes of wall clock, because the two build jobs run in parallel and the merge job adds one minute. At 400 image builds per month that is $268.80 against $107.20, a difference of $161.60 per month, and 12,400 minutes of pipeline time removed, roughly 207 hours. The same comparison on GitHub-hosted runners, using GitHub's published per-minute list prices from the [actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub pricing page](https://github.com/pricing), both checked on 2026-08-13: an 8 vCPU Linux x64 runner is $0.022 per minute, an 8 vCPU Linux ARM64 runner is $0.014 per minute, and the standard `ubuntu-latest` runner is $0.006 per minute. | Path | Arithmetic | Cost per build | Monthly at 400 builds | | ------------------------------------- | ------------------------------------------- | -------------- | --------------------- | | GitHub-hosted, emulated on 8 vCPU x64 | 42 min x $0.022 | $0.924 | $369.60 | | GitHub-hosted, native split | (9 x $0.022) + (10 x $0.014) + (1 x $0.006) | $0.344 | $137.60 | | WarpBuild, emulated on 8 vCPU x64 | 42 min x $0.016 | $0.672 | $268.80 | | WarpBuild, native split | (9 x $0.016) + (10 x $0.012) + (1 x $0.004) | $0.268 | $107.20 | Two readings of that table. The architecture change is the larger lever: dropping emulation moves the bill from $369.60 to $137.60 on GitHub-hosted runners without changing provider. The rate change stacks on top of it at the same vCPU count on both sides: $0.016 per minute against $0.022 on 8 vCPU x64 is 27 percent lower list price, and $0.012 against $0.014 on 8 vCPU ARM64 is 14 percent lower, both checked on 2026-08-13. The remote Docker builder path bills differently, so model it separately. The GitHub Actions job and the builder session are two independent resources and both are charged. With a 16 vCPU multi architecture profile at $0.06 per minute, a cold build with a 9 minute session per architecture and a 9 minute driver job on `warp-ubuntu-latest-x64-4x` costs (2 x 9 x $0.06) + (9 x $0.008) = $1.152\. Once the persistent layer cache is warm and the build settles at 3 minutes, the same build costs (2 x 3 x $0.06) + (3 x $0.008) = $0.384. That path is worth its price when the layer cache hit rate is high and the image build is the whole job, since the cache lives on the builder rather than being restored over the network on every run. When the job also runs tests, lint, and packaging around the image build, the native split usually prices better, because you are paying runner minutes you already needed. One more variable worth pricing: where the compute runs. BYOC runs on AWS, GCP, and Azure, which keeps the same `warp-` labels and the same split workflow while the machines live in your own cloud account. Teams already committed to cloud spend often model that path alongside the two above. To rerun any of this with your own numbers, take the billed minutes per architecture from your GitHub usage report, multiply by the rate at your size in the tables above, and compare against the emulated total you measured in the diagnosis step. For the wider Docker picture on GitHub Actions, including cache strategy and registry choice, see the [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions) page. ## [FAQ](#faq)Copy Anchor Link ### [Why is buildx --platform linux/amd64,linux/arm64 slow on a single x64 runner?](#why-is-buildx---platform-linuxamd64linuxarm64-slow-on-a-single-x64-runner)Copy Anchor Link The arm64 half of the build runs under QEMU user mode emulation, so every guest instruction is translated before the host CPU executes it. Compilers, linkers, and native module builds pay that translation on every instruction, which is why the arm64 stage dominates the wall clock. ### [Do I still need docker/setup-qemu-action after splitting the build?](#do-i-still-need-dockersetup-qemu-action-after-splitting-the-build)Copy Anchor Link No. Each job builds only its own architecture on a runner of that architecture, so no binfmt handler is involved. Keep `setup-qemu-action` only on workflows where one machine still has to produce both architectures. ### [How do two separate architecture images end up under one tag?](#how-do-two-separate-architecture-images-end-up-under-one-tag)Copy Anchor Link Each build job pushes by digest with `push-by-digest=true`, and a final job runs `docker buildx imagetools create` to write a manifest list pointing at both digests. A `docker pull` of that tag then resolves to the variant matching the client architecture. ### [Can I keep the multi architecture build in one job?](#can-i-keep-the-multi-architecture-build-in-one-job)Copy Anchor Link Yes, with remote Docker builders. Set `platforms` to `linux/amd64,linux/arm64` on `Warpbuilds/build-push-action` and point it at a builder profile that has both architectures enabled. Each architecture runs on a separate builder instance, so the build bills as one session per architecture. --- # Publishing One Image to Several Registries | WarpBuild URL: https://www.warpbuild.com/guides/multi-registry-publishing-github-actions Description: Build the image once, push it to a primary registry, then copy it by digest into every other target. The workflow YAML, the wiring, and the transfer math. # Publishing One Image to Several Registries Build the image once, push it to a primary registry, then copy it by digest into every other target. The workflow YAML, the wiring, and the transfer math. Push an image to several registries by building it once, pushing that build to one primary registry, and copying the resulting manifest by digest into each additional target. Every registry then serves the same bytes under the same `sha256:` digest, and the workflow pays for one build instead of one build per registry. This guide covers the workflow shapes that quietly rebuild per target, the copy-by-digest fix, a workflow with one build job and a copy job per registry, and a transfer model that counts both runner minutes and bytes at a stated image size. It sits under [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link A pipeline that publishes to three registries usually builds three times, and the YAML rarely makes that obvious. Three shapes account for most of it. | Shape | What you see in the workflow | What it produces | | ------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | | A matrix over registries | strategy.matrix.registry with a build and push step inside each entry | One full build per registry, and one distinct digest per registry | | A second publish workflow | An on: release workflow with its own checkout and build step that targets the mirror | A build from a different point in time, with its own credential set | | A promote script | docker pull, docker tag, docker push in a release job | A retag of whatever the source tag resolved to at pull time | ### [Confirm it from the registries](#confirm-it-from-the-registries)Copy Anchor Link Ask each registry what the release tag currently points at: Copy Text ``` docker buildx imagetools inspect ghcr.io/acme/api:v1.4.0 \ --format '{{.Manifest.Digest}}' docker buildx imagetools inspect 111122223333.dkr.ecr.us-east-1.amazonaws.com/api:v1.4.0 \ --format '{{.Manifest.Digest}}' ``` Two different `sha256:` values for one release means two builds happened. The [image digest](/glossary/image-digest) is computed over the manifest, which names the config and every layer by hash, so any difference at all in the output changes it. Independent builds of one commit diverge for reasons that have nothing to do with your source. A base image tag that moved between the two runs, a package index that resolved a newer patch release, and a build timestamp written into a layer each produce different bytes from identical input. The result is that the image your scanner cleared and the image running in the second region are different artifacts that share a tag. That divergence also breaks anything bound to a digest. Signatures, SBOMs, and provenance attestations attach to one manifest, so a per-registry rebuild leaves the attestation in the first registry describing an image that exists nowhere else. The [container registry](/glossary/container-registry) entry covers how registries store those references. ### [Confirm it from the bill](#confirm-it-from-the-bill)Copy Anchor Link The [Reports page](/docs/ci/features/reports) settles the same question from the billing side. The Docker Builder billing table lists one row per session with profile, architecture, duration, and cost, so one merge that opened three sessions tells you the pipeline built three times. The CI billing table shows the same thing from the runner side, since each row is one job execution with its job name, runner label, and billed time, and every registry matrix entry carries a duration close to a full build rather than close to an upload. ## [Fix](#fix)Copy Anchor Link Build once, push to a primary registry, and copy the manifest by digest into each remaining target. `docker buildx imagetools create` is the copy. Given a source reference and one or more `--tag` values, it reads the source manifest, uploads any blobs the target registry does not already hold, and writes the same manifest bytes under the target name. Because the manifest is copied rather than regenerated, the digest survives the trip, and a multi-platform image index copies as one unit with all of its per-platform manifests intact. Two properties of that copy decide the cost model later. The target registry is asked whether it already holds each blob before anything is uploaded, so a release that changes one application layer moves one layer rather than the whole image. And a copy between two repositories inside the same registry can mount the existing blobs instead of transferring them, while a copy across registry hosts moves the blob bytes through the job doing the copy. The two job roles size differently. Rates below come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13. | Job | Runner label | vCPU | RAM | Storage | Rate per minute | | -------------------------------------- | ------------------------- | ---- | ---- | --------- | --------------- | | Build and push to the primary registry | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | Copy to each additional registry | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | The copy job spends its minutes on network round trips and blob uploads, so cores buy nothing there. The build job takes the larger label because that is where the compile happens. A workflow that also publishes Windows images can point the same pattern at a Windows label. ### [The one-step alternative](#the-one-step-alternative)Copy Anchor Link A buildx push accepts several `tags` in different registries and pushes the same result to each. That is fewer moving parts, and it is worth taking when all the targets are equally reliable and every credential belongs in the build job anyway. The trade is coupling. A registry that rejects the push or times out fails the whole build after the image has already been produced, and rerunning the job rebuilds. A copy job per registry can be rerun on its own, and `fail-fast: false` keeps one slow mirror from cancelling the others. ## [Configuration](#configuration)Copy Anchor Link The workflow below builds once on a tag push, publishes to GitHub Container Registry, and fans out to two more registries with one copy job each. Copy Text ``` name: publish on: push: tags: ["v*"] permissions: contents: read packages: write id-token: write env: PRIMARY: ghcr.io/acme/api VERSION: ${{ github.ref_name }} jobs: build: runs-on: warp-ubuntu-latest-x64-8x outputs: digest: ${{ steps.build.outputs.digest }} steps: - uses: actions/checkout@v4 - name: Log in to the primary registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: build uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ${{ env.PRIMARY }}:${{ env.VERSION }} profile-name: platform-amd64 timeout: 600000 copy: needs: [build] runs-on: warp-ubuntu-latest-x64-2x strategy: fail-fast: false matrix: include: - name: ecr target: 111122223333.dkr.ecr.us-east-1.amazonaws.com/api - name: mirror target: registry.example.com/acme/api steps: - name: Log in to the source registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Exchange the OIDC token for AWS credentials if: matrix.name == 'ecr' uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-ecr-push aws-region: us-east-1 - name: Log in to Amazon ECR if: matrix.name == 'ecr' uses: aws-actions/amazon-ecr-login@v2 - name: Log in to the mirror if: matrix.name == 'mirror' uses: docker/login-action@v3 with: registry: registry.example.com username: ${{ secrets.MIRROR_USERNAME }} password: ${{ secrets.MIRROR_TOKEN }} - name: Copy by digest run: | docker buildx imagetools create \ --tag ${{ matrix.target }}:${{ env.VERSION }} \ ${{ env.PRIMARY }}@${{ needs.build.outputs.digest }} - name: Verify the digest survived the copy run: | src="${{ needs.build.outputs.digest }}" dst=$(docker buildx imagetools inspect \ ${{ matrix.target }}:${{ env.VERSION }} \ --format '{{.Manifest.Digest}}') if [ "$src" != "$dst" ]; then echo "digest mismatch: $src against $dst" >&2 exit 1 fi ``` ### [The wiring, line by line](#the-wiring-line-by-line)Copy Anchor Link | Where | Line | Why it is required | | --------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Push step | id: build | steps.build.outputs.digest needs a step id to reference | | Build job | outputs.digest | Job outputs are the only channel between jobs on GitHub Actions ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidoutputs)) | | Push step | push: true | The digest output comes back from the registry push, so a build that stays local reports nothing | | Copy job | needs: \[build\] | The needs context exposes outputs only for the jobs named there | | Copy step | ${{ env.PRIMARY }}@${{ needs.build.outputs.digest }} | Copying from a digest reference rather than a tag, so a later push to the tag cannot change what is copied | | Copy job | Two login steps | The copy pulls from the source host and pushes to the target host, and Docker stores one credential entry per host | | Copy job | fail-fast: false | One failing target leaves the other copies to finish and be rerun independently | ### [Credentials per target](#credentials-per-target)Copy Anchor Link Each registry brings its own login shape. GitHub Container Registry takes the per-job `GITHUB_TOKEN` with `packages: write` in the `permissions` block. A cloud registry takes an OpenID Connect exchange, which needs `id-token: write` and a trust policy on the cloud side naming one repository ([security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)). A self-managed registry takes a username and token from secrets. The [private registry authentication answer](/answers/how-do-i-authenticate-to-a-private-registry-in-github-actions) covers all three in detail. WarpBuild does not access or store build secrets, and each runner is its own virtual machine that is destroyed after the build, so the Docker config those login steps wrote goes away with it ([security documentation](/docs/ci/security)). ### [Failures worth recognizing](#failures-worth-recognizing)Copy Anchor Link **`denied` or `403` on the copy, with the pull working.** The credential authenticated and the scope it received has no push right on the target repository. On a cloud registry this is usually a repository policy or an IAM role without `ecr:PutImage`. **`manifest unknown` naming the source.** The digest output was empty or the build never pushed, so the copy asked for a reference ending in `@`. Guard the copy job with a step that checks the value starts with `sha256:` before it runs. **An unsupported media type error on one target only.** Buildx attaches provenance and SBOM attestations to the index by default, and a registry that rejects those manifest types fails the copy while accepting a plain image. Building with provenance disabled, or copying the platform manifests alone, gets the release out while the registry is upgraded. **The target repository does not exist.** Some registries create a repository on first push and others require it to exist first, which turns into a `name unknown` error on the very first release to a new target. CI observability is the useful one while tuning a fan-out, since it correlates runner system metrics with GitHub Actions job logs and shows whether a copy job is waiting on network or on the registry. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Everything below is arithmetic on stated assumptions rather than a measurement. Substitute the durations in your own job logs and the compressed size from `docker buildx imagetools inspect --raw` before deciding anything on it. Assumptions: * Three registries: a primary and two additional targets. * The image is 1.2 GB compressed, of which 0.4 GB comes from base image layers pulled during the build and 0.08 GB is the application layer that changes each release. * A cold build occupies a runner for 6.0 minutes, including the push to the primary registry. * A copy job occupies a runner for 1.5 minutes. * 40 tagged releases per month. * Rates from the [pricing page](/pricing), checked on 2026-08-13: `warp-ubuntu-latest-x64-8x` at $0.016 per minute, `warp-ubuntu-latest-x64-2x` at $0.004 per minute. Runner cost per release: | Path | Arithmetic | Runner minutes | Cost per release | Cost at 40 releases | | -------------------------- | ----------------------------------- | -------------- | ---------------- | ------------------- | | Build in each registry job | 3 x 6.0 x $0.016 | 18.0 | $0.288 | $11.52 | | Build once, copy twice | (6.0 x $0.016) + (2 x 1.5 x $0.004) | 9.0 | $0.108 | $4.32 | The copy path removes 9.0 runner minutes and $0.180 per release, which comes to 360 minutes and $7.20 per month for one service. Those are small numbers for one repository and they scale linearly with services and with registries, since each extra registry adds a full build on the first path and 1.5 minutes on the second. Bytes move differently, and the honest version of the table has three rows: | Path | Bytes into the runners | Bytes out of the runners | Digests across the three registries | | --------------------------------------------------------------- | --------------------------------------------- | -------------------------------- | ----------------------------------- | | Build in each registry job | 1.2 GB, three base pulls | 3.6 GB, three full pushes | Three different values | | Build once, copy twice, targets empty | 2.8 GB, one base pull plus two manifest reads | 3.6 GB, one push plus two copies | One value | | Build once, copy twice, targets already holding the base layers | 0.56 GB | 0.24 GB | One value | Read those rows in order. On a first release to empty targets, copying moves more bytes than rebuilding, because the blobs travel to the primary registry and then back out through each copy job. On every release after that, the blob existence check ahead of each upload means only the 0.08 GB application layer moves, and the copy path drops well below the rebuild path on both counts. The rebuild path cannot reach that third row reliably. Three independent builds produce three different layer digests unless the build is bit for bit reproducible, so the target registries have nothing to deduplicate against and keep paying the full transfer every release. Wall clock runs the other way and is worth stating plainly. Three builds in parallel finish in 6.0 minutes, while build-then-copy finishes in 7.5 minutes because the copy jobs wait on the build. The trade is 1.5 minutes of pipeline latency for 9.0 runner minutes, one large runner held during the release instead of three, and one digest instead of three. Two variations change the arithmetic. If the build runs on a remote Docker builder, the build minutes move to a builder session billed at $0.06 per minute for the 16 vCPU, 32GB profile ([Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13), which widens the gap further, since the rebuild path opens one session per registry. And if two of the three targets are repositories inside the same registry host, the copy between them can mount existing blobs rather than transfer them, so those bytes leave the table entirely. Where the runners pull from matters for the deployment side of the same pipeline. On the enterprise tier, egress costs from your cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores during deployments, on BYOC and on WarpBuild-hosted runners; the [zero egress page](/enterprise/zero-egress) covers the terms. Once one digest reaches every registry, the deploy side stops guessing which build it is running, which is the subject of [build once, deploy many on GitHub Actions](/guides/build-once-deploy-many-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [Does copying an image between registries change its digest?](#does-copying-an-image-between-registries-change-its-digest)Copy Anchor Link No, as long as the copy moves the manifest bytes unchanged. A digest is the SHA-256 hash of the manifest document, and `docker buildx imagetools create` uploads the blobs that manifest references and then writes the same manifest under the target name. The tag differs per registry and the digest is identical, which is what makes the copy verifiable with a single comparison step. ### [Can one build and push step publish to several registries at once?](#can-one-build-and-push-step-publish-to-several-registries-at-once)Copy Anchor Link Yes. The `tags` input on a buildx push accepts references in different registries, and the builder pushes the same result to each one. The cost is coupling: every credential has to be present in the build job, and a registry that is unreachable or rejects the push fails the build after the image was already produced. A separate copy job per registry can be retried on its own. ### [Do I need to log in to both registries in the copy job?](#do-i-need-to-log-in-to-both-registries-in-the-copy-job)Copy Anchor Link Yes. The copy reads the manifest and any missing blobs from the source registry and writes them to the target, so the job needs a pull credential for the source and a push credential for the target. Two login steps in the same job is the normal shape, because the Docker config stores one entry per registry host. --- # Multi Stage Docker Builds and Layer Reuse | WarpBuild URL: https://www.warpbuild.com/guides/multi-stage-docker-builds-github-actions Description: Multi stage Docker builds reuse layers one stage at a time, and GitHub Actions throws that away every job. Keep the stages on a builder that outlives the job. # Multi Stage Docker Builds and Layer Reuse Multi stage Docker builds reuse layers one stage at a time, and GitHub Actions throws that away every job. Keep the stages on a builder that outlives the job. Last verified: 2026-08-13 A multi stage Docker build reuses cache one stage at a time: BuildKit rebuilds the stages whose inputs changed plus the stages that copy from them, and it skips every stage the selected target never reaches. On GitHub Actions that reuse is thrown away on each run, because the job creates a fresh buildx instance whose layer store starts empty, so the durable fix is to run the stages on a builder that outlives the job and keeps its layers on local disk. Per-minute rates for job runners and builder profiles are on the [pricing page](/pricing), and the model at the end of this guide runs them through a Go service with a 1.4 GB build stage and a 28 MB release image. This guide covers stage-level cache behavior, a Dockerfile and workflow pair that keeps a test image and a release image sharing layers, the builder-side facts that make the reuse durable, and a time and cost model you can re-run with your own step timings. For the wider picture, start at [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link ### [How BuildKit decides what to rebuild](#how-buildkit-decides-what-to-rebuild)Copy Anchor Link Every instruction gets a cache key built from its parent chain, the instruction text, and the digests of its inputs. When a key changes, that instruction rebuilds along with every instruction after it inside the same stage. Invalidation crosses a stage boundary in exactly two ways: a `FROM x AS y` that names the changed stage, and a `COPY --from=x` that reads files out of it. A stage that sits outside both paths is untouched, whatever else changed in the file. That makes stage layout a cache decision. Work placed above the source copy is shared by every stage descended from it, and work placed below is repeated by each descendant that repeats the copy. ### [Which stages survive a source change](#which-stages-survive-a-source-change)Copy Anchor Link Take a four stage layout: `deps` resolves modules, `build` compiles, `test` runs the suite, and `release` assembles a minimal runtime image from the compiled binary. Both `build` and `test` inherit from `deps` with `FROM deps AS ...`, and `release` reads the binary with `COPY --from=build`. | Stage | Direct inputs | Rebuilds when | Effect downstream | | ------- | ------------------------------ | ---------------------------------------- | -------------------------------------------- | | deps | go.mod, go.sum, the base image | The lockfile or the base image tag moves | build and test both rebuild | | build | deps, the source tree | Any file in the build context changes | release re-evaluates its COPY --from | | test | deps, the source tree | Any file in the build context changes | Nothing reads from it, so nothing follows | | release | The binary copied from build | The copied binary's checksum changes | Final image layers change, push size changes | The row that surprises teams is `test`. It is expensive, it reruns on every commit, and it never invalidates the shipped image, so it can run as its own job against the same warm `deps` layers instead of sitting inside the release path. ### [Why a changed build stage does not always invalidate the release stage](#why-a-changed-build-stage-does-not-always-invalidate-the-release-stage)Copy Anchor Link BuildKit keys a `COPY --from` on the checksum of the files it copies rather than on whether the source stage reran. A rebuilt `build` stage that emits a byte-identical binary therefore leaves the `release` stage on cache. Reproducible compiler output is what turns that from theory into cache hits. For Go, `-trimpath` strips absolute paths and `-ldflags="-buildid="` removes the build id; for other toolchains, `SOURCE_DATE_EPOCH` and a deterministic archive order do the same job. Without them the binary differs on every run even when the source did not move, and the release layers rebuild and repush for nothing. ### [What GitHub Actions costs you here](#what-github-actions-costs-you-here)Copy Anchor Link Four losses stack on a standard hosted job. The buildx instance is created inside the job and destroyed with the machine, so all four stages start cold on every run. A commit touching one `.go` file re-resolves modules. Cache export mode hides the intermediate stages. The default `mode=min` exports the layers of the final image only, so `deps` and `build` are absent from the cache even on a run that reports a cache hit. Switching to `mode=max` stores them and inflates the export, which then competes with the size limits of the backend you pointed it at. The trade between those backends is worked through in [choosing a buildx cache backend on GitHub Actions](/guides/buildx-cache-backends-github-actions). Single stage Dockerfiles push the toolchain. When the compiler, the module cache, and the source tree stay in the shipped image, every run moves gigabytes to the registry instead of the tens of megabytes a release stage produces. Stage parallelism has nowhere to run. BuildKit executes independent stages concurrently, so `build` and `test` can overlap, and a 2 vCPU job runner has no cores to give them. Read your build log before changing anything. Lines like `CACHED [deps 3/3]` tell you the stage was reused. If `deps` prints as executed on a commit that touched one source file, the cache did not survive the job, and no Dockerfile edit will fix that. The [layer cache invalidation guide](/guides/docker-cache-invalidation-github-actions) covers the Dockerfile-side causes when the cache does survive and still misses. ## [Fix](#fix)Copy Anchor Link Two moves, in order. Give the expensive work a stage that several targets inherit, then run the build where that stage's layers persist between jobs. Here is the Dockerfile. `deps` sits above the source copy, `build` and `test` both inherit it, and `release` carries only the binary. Copy Text ``` # syntax=docker/dockerfile:1.7 FROM golang:1.24-bookworm AS deps WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod go mod download FROM deps AS build COPY . . RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -buildid=" \ -o /out/api ./cmd/api FROM deps AS test COPY . . RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ go test ./... FROM gcr.io/distroless/static-debian12:nonroot AS release COPY --from=build /out/api /usr/local/bin/api USER nonroot ENTRYPOINT ["/usr/local/bin/api"] ``` Three details carry the reuse. The lockfile is copied on its own so `go mod download` only reruns when dependencies move. The two `--mount=type=cache` directories hold the module cache and the compiler cache outside the layer graph, so a rebuilt `build` stage still compiles incrementally. And the release stage starts from a distroless base, so the shipped image contains the binary and nothing from the toolchain. Now the workflow. Both jobs name the same builder profile, so both reach the same builder VM and the same warm `deps` layers. Copy Text ``` name: docker on: pull_request: push: branches: [main] jobs: test: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v5 - uses: Warpbuilds/build-push-action@v6 with: context: . target: test push: false profile-name: api-amd64 timeout: 600000 release: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v5 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: Warpbuilds/build-push-action@v6 with: context: . target: release push: ${{ github.event_name == 'push' }} tags: ghcr.io/acme/api:${{ github.sha }} profile-name: api-amd64 timeout: 600000 ``` A WarpBuild builder profile is one dedicated Docker builder VM with a persistent layer cache on its local disk, addressed from the workflow by `profile-name`. Jobs come and go; the profile and every stage it has already built stay. The cache has a TTL of 10 days, so a profile that goes unused for more than 10 days is reset automatically and the next build repopulates all four stages. Note the two omissions against a hosted setup. There is no `docker/setup-buildx-action` step, because the WarpBuild action configures the remote builder itself. There are no `cache-from` and `cache-to` lines, because the intermediate stages already live on the builder disk, so the export and import round trip buys nothing. The job runner shrinks with the work. Both jobs check out the repository, hand the build to the builder, and wait, which `warp-ubuntu-latest-x64-2x` covers. Pick a Linux label here, since macOS runners have no nested virtualization and cannot run Docker. One honest caveat on the parallel split. The layer cache between concurrent builds is eventually consistent, so on a cold profile the `test` job and the `release` job may each build `deps` rather than sharing one execution of it. From the second run on, both hit. ## [Configuration](#configuration)Copy Anchor Link ### [Action inputs that decide stage selection](#action-inputs-that-decide-stage-selection)Copy Anchor Link | Input | Value | Notes | | ------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------------- | | target | Stage name from the Dockerfile | Builds that stage and its ancestors only. Omit it and BuildKit builds the last stage in the file. | | profile-name | Name of the builder profile | Selects the builder VM and therefore which cached stages you reach. Required. | | api-key | ${{ secrets.WARPBUILD\_API\_KEY }} | Not required on WarpBuild runners. Required from a GitHub-hosted runner or any other machine. | | timeout | Milliseconds to wait for the builder | Defaults to 10 minutes for Warpbuilds/build-push-action. | | platforms | linux/amd64,linux/arm64 | Both architectures must be enabled on the profile, otherwise the build fails with exec format error. | ### [Building several targets in one invocation](#building-several-targets-in-one-invocation)Copy Anchor Link When targets multiply, a bake file keeps the stage graph in one place and lets `Warpbuilds/bake-action@v6` drive it: Copy Text ``` group "default" { targets = ["test", "release"] } target "test" { context = "." target = "test" output = ["type=cacheonly"] } target "release" { context = "." target = "release" platforms = ["linux/amd64"] tags = ["ghcr.io/acme/api:latest"] } ``` Both targets resolve against the same `deps` stage inside one builder session, which removes the eventual consistency caveat above at the cost of coupling the two into one job. If your build needs custom steps between setup and `docker build`, use `Warpbuilds/docker-configure@v1` instead and invoke it immediately before the build step, since the builder is billed from the moment it is assigned. ### [What resets the cached stages](#what-resets-the-cached-stages)Copy Anchor Link * Ten days without a build on that profile. The TTL applies to the profile rather than to any single stage. * An explicit reset. A profile whose disk is full of dead layers after a base image bump can be reset through the API: Copy Text ``` curl -s -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles?per_page=30&page=1" curl -s -X POST \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles/$BUILDER_PROFILE_ID/cache/reset" ``` * A different profile. Each profile is one VM with one cache, and two profiles never share stages. ### [Sizing and concurrency](#sizing-and-concurrency)Copy Anchor Link Roughly 8 vCPU and 16 GB per concurrent build job is the recommended floor, so a 16 vCPU profile comfortably carries the two jobs above. Run as many jobs as your workflows need, because generally available Linux and Windows runners do not have plan-level concurrency caps. For multi-arch profiles, each architecture runs on a separate builder instance, which means one session per architecture, and arm64 and multi-arch profiles top out at 64 vCPU. The builder and the job runner are two separate resources and both are billed. Full input lists, the CLI flow, and the session billing rules are in the [remote Docker builders documentation](/docs/ci/docker-builders). Dependency caches that live outside the image, such as a downloaded SDK or a test fixture set, belong in `WarpBuilds/cache@v1` from the [WarpBuild caching documentation](/docs/ci/features/caching) rather than in the layer graph. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The model below is arithmetic on stated assumptions rather than a measurement. Replace each row with the durations printed in your own build log before you use the result to decide anything. ### [Assumptions](#assumptions)Copy Anchor Link * A Go API service with 240 modules, a 1.4 GB `build` stage from `golang:1.24-bookworm`, and a 28 MB distroless release image. * Step durations held constant across every option, so the layer cache is the only variable that moves. * 500 pull request builds per month, one in ten cold: 50 cold and 450 warm. Cold means a lockfile or base image change that invalidates `deps`. * 0.5 minutes of per-job overhead for checkout, registry login, and action setup. * Concurrent jobs on one profile bill as a single session from the first job's start to the last job's completion, with starts staggered by 0.5 minutes. ### [Single stage rebuild against staged build with warm layers](#single-stage-rebuild-against-staged-build-with-warm-layers)Copy Anchor Link | Step | Single stage, every run | Multi stage, cold profile | Multi stage, warm profile | | -------------------------------------- | ----------------------- | ------------------------- | ------------------------- | | Pull golang:1.24-bookworm base | 0:20 | 0:20 | 0:00 | | go mod download, 240 modules | 1:05 | 1:05 | 0:00 | | Copy source into the stage | 0:04 | 0:04 | 0:04 | | go build with the compiler cache mount | 2:40 | 2:40 | 0:48 | | go test ./... | 3:10 | 3:10 | 1:35 | | Export and push | 1:20 at 1.4 GB | 0:22 at 28 MB | 0:22 at 28 MB | | Total | 8:39 | 7:41 | 2:49 | Two rows do most of the work. `go mod download` disappears on a warm profile because `deps` sits above the source copy and the commit did not touch the lockfile. Export drops from 1:20 to 0:22 because the release stage ships 28 MB where the single stage image ships the whole toolchain. Split across the two jobs, a warm pull request runs a 1:39 test job and a 1:14 release job in parallel against the same profile, so the check goes green in about 2.2 minutes including job overhead, against 9.2 minutes for the single stage build. ### [Rates used](#rates-used)Copy Anchor Link Job runners, from the [WarpBuild pricing page](/pricing): | Runner label | Shape | Price per minute | | -------------------------- | --------------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064 | Builder profiles, billed per session, with the full catalog on [remote Docker builder sizes and pricing](/runners/docker-builders): | Builder size | Disk | Price per minute | Architectures | | ---------------- | ----- | ---------------- | ------------------- | | 16 vCPU, 32 GB | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU, 64 GB | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU, 128 GB | 200GB | $0.24 | amd64, arm64, multi | | 96 vCPU, 192 GB | 600GB | $0.36 | amd64 | | 96 vCPU, 192 GB | 2TB | $0.52 | amd64 | | 192 vCPU, 384 GB | 600GB | $0.72 | amd64 | | 192 vCPU, 384 GB | 2TB | $0.88 | amd64 | The GitHub-hosted baseline for a single stage build at the same compute shape as a 16 vCPU builder is the 16-core Linux larger runner at $0.042 per minute, from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. At that shape `warp-ubuntu-latest-x64-16x` costs $0.032 per minute, which is $0.010 per minute lower on list price for 16 vCPU and 64 GB. ### [Monthly model, 500 builds](#monthly-model-500-builds)Copy Anchor Link | Option | Job minutes | Builder minutes | Monthly cost | | ------------------------------------------------------------------------- | ----------------- | --------------- | ------------ | | A. Single stage, in-job buildx, GitHub 16-core larger runner | 4,575 at $0.042 | 0 | $192.15 | | B. Single stage, in-job buildx, warp-ubuntu-latest-x64-16x | 4,575 at $0.032 | 0 | $146.40 | | C. Multi stage, two warp-ubuntu-latest-x64-2x jobs plus a 16 vCPU profile | 2,255.8 at $0.004 | 1,225 at $0.06 | $82.52 | Reading it straight: option C costs $63.88 less per month than option B at the same compute shape, and it returns the check in about 2.2 minutes instead of 9.2\. The builder minutes carry most of the C total at $73.50, because a session covers both jobs from the first start to the last completion while the two 2 vCPU job runners add $9.02 between them. Two levers move the result. Session sharing is the first: more pull request jobs overlapping on one profile spread the same session cost, which is why splitting `test` and `release` across two jobs on one profile costs less than giving each its own profile. Stage layout is the second: every minute of work you lift above the source copy moves from the 450 warm runs into the 50 cold ones. ## [FAQ](#faq)Copy Anchor Link ### [Does a multi stage Dockerfile build every stage?](#does-a-multi-stage-dockerfile-build-every-stage)Copy Anchor Link No. BuildKit builds only the stages the selected target depends on, plus any stage pulled in by a `COPY --from`. Building with `target: test` never runs the release stage, and building with `target: release` never runs the test stage. ### [Why does a change in my builder stage invalidate the release stage?](#why-does-a-change-in-my-builder-stage-invalidate-the-release-stage)Copy Anchor Link Because the release stage copies from it. BuildKit keys a `COPY --from` on the checksum of the files it copies, so a rebuilt builder stage that emits a byte-identical binary still lets the release stage hit cache. Embedded timestamps, build ids, and non-deterministic archive ordering change that checksum on every run and force the rebuild. ### [Do I still need cache-to mode=max for the intermediate stages?](#do-i-still-need-cache-to-modemax-for-the-intermediate-stages)Copy Anchor Link No. A builder profile keeps every stage's layers on the builder VM's own disk, so intermediate stages such as `deps` and `build` persist without an export. Remove `cache-from` and `cache-to`; they add an import and export round trip that the persistent cache already covers. ### [How long do the cached stages stay on a builder profile?](#how-long-do-the-cached-stages-stay-on-a-builder-profile)Copy Anchor Link The builder cache has a TTL of 10 days. A profile that goes unused for more than 10 days is reset automatically, and the next build repopulates every stage. You can also reset a profile's cache on demand through the WarpBuild API. --- # Nightly Build Jobs on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/nightly-builds-github-actions Description: A nightly build runs the work that is too slow or too broad for a pull request check. Pick what belongs there, route failures to a person, and price the matrix. # Nightly Build Jobs on GitHub Actions A nightly build runs the work that is too slow or too broad for a pull request check. Pick what belongs there, route failures to a person, and price the matrix. Last verified: 2026-08-13 A nightly build is a scheduled GitHub Actions workflow that runs the checks a pull request cannot afford to wait for: full integration suites, compatibility matrices, soak runs, and audits. It earns its minutes when the work it holds would either blow the review latency budget or catch failures that no single pull request can produce. This guide sets the rule for what moves out of the pull request lane, fixes the two failure modes that make nightly runs decorative, gives a workflow with a schedule trigger, a manual fallback, and a failure notification, and prices a six leg nightly matrix at real rates. It sits under the [guide to speeding up GitHub Actions builds](/guides/speed-up-github-actions), which covers the pull request lane itself. ## [Diagnosis](#diagnosis)Copy Anchor Link A nightly workflow fails quietly by design, because nobody is waiting on it. Four symptoms account for most broken ones. **Nobody reads the failures.** GitHub sends notifications for a scheduled workflow to the user who created it, and to whoever last edited the cron syntax after that ([notifications for workflow runs](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/notifications-for-workflow-runs), checked on 2026-08-13). One person inherits every nightly alert, and a scheduled run has no pull request to annotate, so a red run sits in the Actions tab until someone opens it on purpose. **The nightly repeats the pull request.** The same unit suite that already ran on merge runs again at 04:00 against the same commit. The minutes are real and the information is zero, which is the first thing to look for when a nightly bill grows. **Everything slow was dumped in it.** A nightly that started as one integration suite becomes the place slow work goes to be forgotten. It grows past the 6 hour job limit and starts getting cancelled mid-run ([GitHub Actions usage limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), and the default job timeout of 360 minutes means a hung step burns the full window before anyone hears about it. **The schedule cannot be tested.** The `schedule` event runs the workflow from the latest commit on the default branch, so a fix pushed to a branch produces no run and the loop is edit, merge, wait a day. The [answer on running jobs on a schedule](/answers/how-do-i-run-jobs-on-a-schedule) covers the cron mechanics, the UTC evaluation, and the delay behavior at the top of the hour. Read duration and queue time at P75 and P90 per job in the Jobs report to see which legs actually grew ([reports documentation](/docs/ci/features/reports)). A nightly leg whose P90 doubled over a month is the one to split before the workflow hits the job limit. ## [Fix](#fix)Copy Anchor Link **Apply one decision rule.** A job stays in the pull request lane when a failure should block the merge and the job finishes inside the review latency budget. Move it to the nightly workflow when either half of that is false. Everything that gates a merge stays where the merge happens. | Work | Where it belongs | Why | | -------------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | Lint, type check, unit tests, build of the changed target | Pull request | Blocks the merge and finishes in minutes | | Integration suite against real databases and brokers | Nightly, plus pull requests that touch the boundary | Too slow for every push, and most diffs cannot break it | | Compatibility matrix over older runtimes and database versions | Nightly | Wide grid, rare breakage, no per-commit signal | | Soak, fuzz, and long property runs | Nightly, time boxed | Finds failures that need hours of execution to surface | | Dependency audit and license scan | Nightly on the default branch | Fails on upstream changes rather than on your diff | | Repeat runs that hunt flaky tests | Nightly | Needs many executions of unchanged code | | Base image and digest refresh | Nightly, opening a pull request | Covered by the [base image update workflow](/guides/base-image-update-workflow-github-actions) | | Packaging and publishing artifacts | Tag trigger | Belongs to [release build workflows](/guides/release-builds-github-actions) | **Send failures to a place people look.** Add a notification job that depends on the matrix, runs on `failure()`, and opens an issue with a label your team triages. An issue survives the night, carries the run URL, and can be assigned. Gate the same job on `github.event_name == 'schedule'` when you want manual reruns to stay silent. **Give the schedule a manual twin.** Put `workflow_dispatch` next to the `schedule` key so the same jobs run from any ref while you are debugging, and add a matrix input when you want to reproduce a single leg. **Time box every leg.** Set `timeout-minutes` on the job rather than relying on the 360 minute default ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidtimeout-minutes), checked on 2026-08-13). A leg that stops finishing should fail inside 90 minutes so the next night still runs on schedule. **Let the nightly leave something behind.** A scheduled run executes on the default branch, and a run may restore cache entries from its own ref and from the default branch ([dependency caching reference](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13). A nightly that installs dependencies and saves the cache hands the next morning's first pull request run a current entry. The [answer on caches across branches](/answers/do-caches-carry-across-branches-in-github-actions) has the full scope grid. ## [Configuration](#configuration)Copy Anchor Link This workflow runs six legs on a schedule, keeps a manual trigger, saves the shared cache entry, and opens an issue when anything fails. Copy Text ``` name: nightly on: schedule: - cron: "17 4 * * *" workflow_dispatch: inputs: leg: description: Single leg to rerun, or all default: all concurrency: group: nightly cancel-in-progress: false permissions: contents: read jobs: suite: name: nightly-${{ matrix.leg }} runs-on: warp-ubuntu-latest-x64-8x timeout-minutes: 90 strategy: fail-fast: false matrix: leg: [integration-1, integration-2, integration-3, integration-4, compat, soak] steps: - uses: actions/checkout@v5 - uses: WarpBuilds/cache@v1 with: path: | node_modules .cache/build key: nightly-${{ runner.os }}-${{ hashFiles('bun.lock') }} restore-keys: | nightly-${{ runner.os }}- - run: bun install --frozen-lockfile - run: bun run test:${{ matrix.leg }} if: github.event_name != 'workflow_dispatch' || inputs.leg == 'all' || inputs.leg == matrix.leg notify: needs: [suite] if: failure() && github.event_name == 'schedule' runs-on: warp-ubuntu-latest-x64-2x permissions: issues: write steps: - name: Open a nightly failure issue env: GH_TOKEN: ${{ github.token }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | gh issue create --repo "$GITHUB_REPOSITORY" \ --title "Nightly build failed on $(date -u +%Y-%m-%d)" \ --label nightly-failure \ --body "Run: $RUN_URL" ``` Six details carry the design. * The cron minute is `17` rather than `0`, because GitHub names the start of every hour as a high load window for the `schedule` event and the run can start late ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13). * `cancel-in-progress: false` keeps a long soak leg alive when a manual dispatch lands during the scheduled run. Cancelling a nightly loses the whole night. * `fail-fast: false` keeps every leg reporting. A nightly exists to produce a list of what is broken, and the default cancels the siblings after the first failure. * The `if` on the test step is what makes `workflow_dispatch` useful: a manual run with `leg: compat` starts all six jobs and executes only the one you named, which is cheap because the other five stop after checkout and restore. The event check comes first because the `inputs` context is empty on a scheduled run, so a condition written against `inputs.leg` alone would skip every leg at 04:17. * The notify job depends on the matrix and runs on `failure()`, so it fires once for the run rather than once per leg, and the `github.event_name` guard keeps manual debugging out of the issue tracker. * The cache step writes on the default branch, which is the scope every branch can read the next morning. WarpBuild Cache entries expire 7 days after their last use and a restore counts as a use ([caching documentation](/docs/ci/features/caching)), so the nightly also keeps the shared entry alive through a quiet week. The labels in this file come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. A compatibility leg that needs a different platform is a label change inside the same matrix. | runs-on label | OS | vCPU | RAM | USD per minute | | --------------------------- | ------------ | ---- | ----- | -------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | $0.012 | ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Price the nightly as a fixed monthly line, because it runs the same shape every night whether or not anyone pushed code. Assumptions: six legs, 25 minutes each, on `warp-ubuntu-latest-x64-8x`, 30 nights a month, one restore and one save per leg, and a 2.5 GB entry held on the default branch. Substitute the durations your Jobs report shows. | Line | Volume per month | Rate | Monthly cost | | ------------------------------------ | ---------------- | ------------------ | ------------ | | Nightly matrix, 6 legs at 25 minutes | 4,500 minutes | $0.016 per minute | $72.00 | | Cache storage | 2.5 GB | $0.20 per GB-month | $0.50 | | Cache operations | 360 operations | $0.0001 each | $0.04 | | Total | $72.54 | | | The same 4,500 minutes on a GitHub-hosted runner of the same shape bill $99.00\. `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price, with the GitHub list price checked on 2026-08-13 ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers)). That is $27.00 a month of difference on one nightly workflow, before the cache line below. Now count what the nightly gives back the next morning. The run writes its cache entry on the default branch, so every branch created after it restores that entry on the first try instead of installing cold. | Next morning | Without a warm entry | With last night's entry | | -------------------------------------- | -------------------- | ----------------------- | | Cold install and build warm-up per run | 4.0 min | 0.3 min | | 40 branch runs | 160 min | 12 min | | Minutes at $0.016 | $2.56 | $0.19 | | Over 22 working days | $56.32 | $4.18 | The nightly costs $72.54 a month and removes about $52 a month of repeated setup on branch runs, plus roughly 148 minutes a day of engineer waiting. That trade holds while the entry stays current, which is why the save step belongs in the nightly rather than in a job that only runs on merges. Full rates by runner type are on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link ### [What belongs in a nightly build rather than in a pull request check?](#what-belongs-in-a-nightly-build-rather-than-in-a-pull-request-check)Copy Anchor Link Keep a job in the pull request lane when a failure should block the merge and the job finishes inside the review latency budget. Move it to the nightly workflow when either half of that is false, which covers full integration suites, compatibility matrices against older runtimes, soak and fuzz runs, dependency and license audits, and repeat runs that hunt flaky tests. The table above lists the common cases and where each one lands. ### [Who gets notified when a nightly GitHub Actions run fails?](#who-gets-notified-when-a-nightly-github-actions-run-fails)Copy Anchor Link GitHub sends notifications for a scheduled workflow to the user who created the workflow, and to whoever last edited the cron syntax after that ([notifications for workflow runs](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/notifications-for-workflow-runs), checked on 2026-08-13). A scheduled run has no pull request to annotate, so add an explicit notification job that opens an issue or posts to a channel, gate it on `failure()`, and gate it on the `schedule` event when manual reruns should stay silent. ### [Does a nightly run make the next morning's builds faster?](#does-a-nightly-run-make-the-next-mornings-builds-faster)Copy Anchor Link It can, because a scheduled run executes on the default branch and every branch may restore entries written there. A nightly job that installs dependencies and saves the cache leaves a current entry for the first pull request run of the day, and the restore also resets the 7 day expiry on WarpBuild Cache entries. The scope rules are in the [answer on caches across branches](/answers/do-caches-carry-across-branches-in-github-actions). --- # Caching NuGet Packages on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/nuget-cache-github-actions Description: Cache the NuGet global packages folder, which dies with the runner on every GitHub Actions run. Use WarpBuilds/setup-dotnet on Linux, actions/cache on Windows. # Caching NuGet Packages on GitHub Actions Cache the NuGet global packages folder, which dies with the runner on every GitHub Actions run. Use WarpBuilds/setup-dotnet on Linux, actions/cache on Windows. Last verified: 2026-08-13 NuGet restore repeats its work on every GitHub Actions run because the global packages folder lives on the runner, and the runner is destroyed when the job ends. Cache that folder: on Linux the documented path is `WarpBuilds/setup-dotnet@v4` with `cache: true` and `cache-dependency-path` pointed at `packages.lock.json`, and on Windows, where WarpBuild caching is unsupported, keep `actions/cache@v4` against GitHub's own Actions cache service. .NET solutions routinely build on two of them in the same workflow. This guide covers how to measure where restore time goes, the workflow changes for each platform, the cache keys that hold up under a matrix, and what the change does to the monthly bill at real runner rates. For the surrounding pipeline, see [running .NET builds on GitHub Actions](/solutions/dotnet-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link Open a recent run and read the duration GitHub prints next to the restore step. Three separate causes hide behind that one number, and they respond to different fixes, so measure all three before editing the workflow. ### [The global packages folder starts cold on every run](#the-global-packages-folder-starts-cold-on-every-run)Copy Anchor Link NuGet resolves a package by looking for it in the global packages folder first, then downloading the `.nupkg` from a feed and extracting it into a versioned directory under that folder. The folder is `~/.nuget/packages` on Linux and `%UserProfile%\.nuget\packages` on Windows. Nothing in a GitHub Actions job preserves it between runs. Confirm the path and the working set from inside a job: Copy Text ``` dotnet nuget locals global-packages --list du -sh ~/.nuget/packages find ~/.nuget/packages -mindepth 2 -maxdepth 2 -type d | wc -l ``` The last command counts extracted package versions. That count, and the size of the folder, are the two inputs to every estimate later on this page. ### [Without a lock file, restore resolves the whole graph on every run](#without-a-lock-file-restore-resolves-the-whole-graph-on-every-run)Copy Anchor Link With no `packages.lock.json` committed, `dotnet restore` walks every `PackageReference`, asks each configured feed which versions satisfy each range, and builds the transitive closure before it downloads anything. Version ranges and floating versions make that a live negotiation with the feed rather than a lookup, so a slow or distant feed shows up as restore time even when most packages are already local. The lock file also gates the cache. Caching in the `setup-dotnet` action is off by default and depends on a NuGet lock file, so a repository with no `packages.lock.json` has no stable hash to key an entry on. Generating the lock file is therefore the first fix rather than an optional hardening step. ### [The transitive closure is larger than the dependency list you wrote](#the-transitive-closure-is-larger-than-the-dependency-list-you-wrote)Copy Anchor Link Teams size the problem from the `PackageReference` entries they can see in the project files. The number that matters is the closure. Print it: Copy Text ``` dotnet list package --include-transitive ``` A solution with 40 direct references commonly expands past 400 resolved packages once analyzers, ASP.NET Core metapackages, test frameworks, and cloud SDKs pull in their own trees. Every one of those is a separate download and a separate extraction into thousands of small files. That last detail is why the same solution restores more slowly on Windows than on Linux. Extraction is dominated by file creation, NTFS charges more metadata work per file than ext4, and Microsoft Defender real-time scanning inspects each file on the way to disk. The [Windows runner page](/runners/windows) covers the image and size choices for that platform. Separating a network-bound restore from a CPU-bound build is where WarpBuild CI observability helps. It sits alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger in the product surface, and it correlates runner system metrics with job logs, so a restore step with heavy network and idle CPU is visibly a different problem from a build step pinned on one core. ## [Fix](#fix)Copy Anchor Link Three changes, in this order. The first is a prerequisite for the other two. **1\. Commit lock files.** Add this to `Directory.Build.props` at the repository root so every project in the solution opts in: Copy Text ``` true ``` Run `dotnet restore` locally, then commit the `packages.lock.json` file that appears next to each project file. From then on, GitHub Actions runs `dotnet restore --locked-mode`, which fails the job when the lock file disagrees with the project files instead of silently resolving a new graph. **2\. On Linux runners, swap the setup action.** WarpBuild maintains forks of the popular `setup-*` actions that accept the same inputs and outputs and route caching through [WarpBuild Cache](/docs/ci/features/caching/setup-actions). On a WarpBuild runner they use it automatically, with no extra configuration: Copy Text ``` - uses: actions/setup-dotnet@v4 + uses: WarpBuilds/setup-dotnet@v4 with: dotnet-version: '8.0' cache: true cache-dependency-path: '**/packages.lock.json' ``` Two inputs carry the whole change. `cache` defaults to `false` for .NET, so it has to be set explicitly. `cache-dependency-path` points at the lock files when they are anywhere other than the repository root, which in a multi-project solution they always are. **3\. On Windows runners, use the GitHub cache service.** The caching documentation states plainly that WarpBuild caching is not supported for Windows-based runners, while it is enabled by default on Linux runners. The honest path on Windows is `actions/setup-dotnet@v4` plus `actions/cache@v4` keyed on the same lock file hash. Entries land in GitHub's Actions cache service, which works from a WarpBuild Windows runner with no extra setup. The [answer page on Windows cache support](/answers/do-github-actions-caches-work-on-windows-runners) covers the boundary in more detail. Nothing is lost by splitting the backends, because the two platforms could never have shared an entry anyway. A WarpBuild cache entry is scoped to its key, its version, and its branch, and the version is a hash over the compression tool and the cached paths. Change the operating system and the version changes, so a Linux entry never restores on a Windows job. ## [Configuration](#configuration)Copy Anchor Link Here is the whole workflow, with both platforms in one file: Copy Text ``` name: build on: pull_request: push: branches: [main] jobs: linux: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-dotnet@v4 with: dotnet-version: '8.0' cache: true cache-dependency-path: '**/packages.lock.json' - name: Restore run: dotnet restore --locked-mode - name: Build run: dotnet build --no-restore --configuration Release - name: Test run: dotnet test --no-build --configuration Release windows: runs-on: warp-windows-latest-x64-8x steps: - uses: actions/checkout@v5 - uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0' - name: Cache NuGet packages uses: actions/cache@v4 with: path: ~\.nuget\packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} restore-keys: | ${{ runner.os }}-nuget- - name: Restore run: dotnet restore --locked-mode - name: Build run: dotnet build --no-restore --configuration Release ``` ### [Key design](#key-design)Copy Anchor Link Hash the lock files and nothing else. `hashFiles('**/packages.lock.json')` changes when a dependency changes and stays stable when application code changes, which is the behavior you want from a package cache. Adding the commit SHA to the key produces a miss on every run and a new entry on every run, which pays storage for entries that are read once. Keep one `restore-keys` prefix so a lock file bump falls back to yesterday's entry instead of to nothing. A partial hit is safe under `--locked-mode`: restore fills the missing packages from the feed and fails only when the lock file itself is stale relative to the project files. ### [Pin the packages folder when the job runs in a container](#pin-the-packages-folder-when-the-job-runs-in-a-container)Copy Anchor Link Set `NUGET_PACKAGES` at the job level when the restore and the build run in different contexts, such as a container step followed by a host step: Copy Text ``` env: NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages ``` This makes the cached path identical everywhere in the job. Containers matter for a second reason: the cache client falls back from zstd to gzip when zstd is missing inside a container, and an entry saved with one compression tool does not restore where the other is in use. ### [Expiry, fees, and concurrency](#expiry-fees-and-concurrency)Copy Anchor Link A cache entry expires 7 days after its last use, and it can be deleted at any time from the action or from the console. On hosted runners, cache storage bills at $0.20 per GB-month and every write or restore bills at $0.0001 per operation. On BYOC runners, cache storage and operations are included. Fan the workflow out across target frameworks or test shards. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a plan limit does not serialize a 12-leg matrix. Restore caching removes the download and extraction work. Compilation is the next line item on the same job, and the [guide to MSBuild caching on GitHub Actions](/guides/msbuild-caching-github-actions) covers the `obj` and `bin` side of the problem. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Prices first. GitHub list prices come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. WarpBuild rates come from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners). | Shape | WarpBuild label | WarpBuild per minute | GitHub-hosted per minute | Lower list price | | ----------------------- | --------------------------- | -------------------- | -------------------------------------------- | ---------------- | | 2 vCPU, 8 GB Linux | warp-ubuntu-latest-x64-2x | $0.004 | $0.006 (ubuntu-latest, private repositories) | 33 percent | | 4 vCPU, 16 GB Linux | warp-ubuntu-latest-x64-4x | $0.008 | $0.012 (4-core Linux larger runner) | 33 percent | | 8 vCPU, 32 GB Linux | warp-ubuntu-latest-x64-8x | $0.016 | $0.022 (8-core Linux larger runner) | 27 percent | | 16 vCPU, 64 GB Linux | warp-ubuntu-latest-x64-16x | $0.032 | $0.042 (16-core Linux larger runner) | 24 percent | | 32 vCPU, 128 GB Linux | warp-ubuntu-latest-x64-32x | $0.064 | $0.082 (32-core Linux larger runner) | 22 percent | | 4 vCPU, 16 GB Windows | warp-windows-latest-x64-4x | $0.016 | $0.022 (4-core Windows larger runner) | 27 percent | | 8 vCPU, 32 GB Windows | warp-windows-latest-x64-8x | $0.032 | $0.042 (8-core Windows larger runner) | 24 percent | | 16 vCPU, 64 GB Windows | warp-windows-latest-x64-16x | $0.064 | $0.082 (16-core Windows larger runner) | 22 percent | | 32 vCPU, 128 GB Windows | warp-windows-latest-x64-32x | $0.128 | $0.162 (32-core Windows larger runner) | 21 percent | Windows shapes start at 4 vCPU because Windows 2 vCPU runners were removed on June 8, 2026. ### [Assumptions](#assumptions)Copy Anchor Link Substitute your own numbers from the counts you collected in the diagnosis section. * A solution whose closure resolves 420 packages, extracting to 1.1 GB in the global packages folder and compressing to a 380 MB cache entry. * 30 workflow runs per weekday over 22 weekdays, so 660 runs per month per platform. * Linux jobs on `warp-ubuntu-latest-x64-8x`, Windows jobs on `warp-windows-latest-x64-8x`. * Cold restore, meaning download and extract all 420 packages: 2.50 minutes on Linux, 4.00 minutes on Windows, where extraction writes more slowly. * Cached restore, meaning entry download, decompression, and a locked-mode restore against a populated folder: 1.00 minute on Linux, 1.50 minutes on Windows. * The lock file changes on one run in ten, so a save runs 66 times per month and costs 0.50 minutes on Linux and 1.50 minutes on Windows, which amortizes to 0.05 and 0.15 minutes per run. ### [Restore minutes and monthly cost](#restore-minutes-and-monthly-cost)Copy Anchor Link | Path | Restore minutes per run | Monthly runner minutes | Monthly cost | | ----------------- | ----------------------- | ---------------------- | ------------ | | Linux, no cache | 2.50 | 1,650 | $26.40 | | Linux, cached | 1.05 | 693 | $11.09 | | Windows, no cache | 4.00 | 2,640 | $84.48 | | Windows, cached | 1.65 | 1,089 | $34.85 | Add the cache fees on the Linux path: 0.38 GB of storage at $0.20 per GB-month is $0.08, and 726 operations at $0.0001 each is $0.07\. The cached Linux path lands at $11.24 per month against $26.40 uncached, a difference of $15.16\. Windows entries live in GitHub's cache service, so the cached Windows path carries no WarpBuild cache fee and lands at $34.85 against $84.48 uncached. ### [The same workload at GitHub-hosted list prices](#the-same-workload-at-github-hosted-list-prices)Copy Anchor Link Hold the cached minute counts fixed and reprice them at the GitHub-hosted rates for the identical shapes: | Path | Monthly runner minutes | WarpBuild | GitHub-hosted | Lower list price | | --------------------------------- | ---------------------- | --------- | ------------- | ---------------- | | Linux, cached, 8 vCPU and 32 GB | 693 | $11.09 | $15.25 | 27 percent | | Windows, cached, 8 vCPU and 32 GB | 1,089 | $34.85 | $45.74 | 24 percent | Two levers stack here, and it helps to keep them separate when you build the case internally. Caching removes minutes, and the list price sets what each remaining minute costs. Restore caching is the larger lever on this workload, and it is the one you control entirely from the workflow file. ## [FAQ](#faq)Copy Anchor Link ### [How do I cache NuGet packages on GitHub Actions?](#how-do-i-cache-nuget-packages-on-github-actions)Copy Anchor Link Cache the global packages folder, which is `~/.nuget/packages` on Linux and `%UserProfile%\.nuget\packages` on Windows. On WarpBuild Linux runners, use `WarpBuilds/setup-dotnet@v4` with `cache` set to `true` and `cache-dependency-path` pointed at `packages.lock.json`. On Windows runners, use `actions/cache@v4` keyed on the same lock file, because WarpBuild caching is documented as unsupported on Windows-based runners. ### [Why does NuGet caching in setup-dotnet require packages.lock.json?](#why-does-nuget-caching-in-setup-dotnet-require-packageslockjson)Copy Anchor Link The cache key is a hash of the dependency file, and NuGet has no single lock file by default. Set `RestorePackagesWithLockFile` to `true` in `Directory.Build.props`, run `dotnet restore` once, and commit the generated `packages.lock.json` files. Caching in `setup-dotnet` is off by default and stays off until both the `cache` input and a lock file are present. ### [Does WarpBuild caching work on Windows runners?](#does-warpbuild-caching-work-on-windows-runners)Copy Anchor Link No. The caching documentation states that WarpBuild caching is not supported for Windows-based runners. It is enabled by default on Linux runners. On a WarpBuild Windows runner, use `actions/cache@v4`, which stores entries in GitHub's own Actions cache service and needs no extra configuration. ### [How long does a NuGet cache entry live, and what does it cost?](#how-long-does-a-nuget-cache-entry-live-and-what-does-it-cost)Copy Anchor Link A WarpBuild cache entry expires 7 days after its last use and can be deleted at any time from the action or the console. On hosted runners, cache storage bills at $0.20 per GB-month and each write or restore operation bills at $0.0001\. On BYOC runners, cache storage and operations are included. --- # OpenTelemetry Metrics from GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/guides/opentelemetry-ci-metrics-github-actions Description: GitHub Actions reports run duration and nothing about the machine. The WarpBuild agent emits runner metrics over OpenTelemetry, grouped per job and instance. # OpenTelemetry Metrics from GitHub Actions Runners GitHub Actions reports run duration and nothing about the machine. The WarpBuild agent emits runner metrics over OpenTelemetry, grouped per job and instance. GitHub Actions publishes run duration, queue time, job counts, and conclusion ([GitHub Actions metrics](https://docs.github.com/en/actions/concepts/metrics)), and none of those numbers describe the machine the job ran on, so utilization data has to come from an agent on the runner emitting OpenTelemetry. On runners WarpBuild operates that agent is already installed: it reports CPU, memory, filesystem, disk I/O, and network utilization over OpenTelemetry on port `33931`, along with the runner's system logs and the GitHub Actions logs for the same job. This guide covers exactly what the agent emits and the hierarchy the platform groups it under, the two collection controls that decide whether data exists at all, a worked read of one job's metric series against its step timeline, and the per minute arithmetic for the label change that read implies. ## [Diagnosis](#diagnosis)Copy Anchor Link A job that takes fourteen minutes reports one number. That number can be a compiler pinned on four cores, a test runner swapping against a memory ceiling, a checkout saturating the volume, or several minutes of queue wait, and the four have different fixes. Telemetry from the machine is what separates them. ### [What the agent emits](#what-the-agent-emits)Copy Anchor Link The agent collects three kinds of data, described in the [observability documentation](/docs/ci/features/observability): utilization metrics, system logs that capture WarpBuild and other service behavior on the runner, and the GitHub Actions logs that let a metric spike be correlated with the step that caused it. Five utilization series are recorded per runner instance. | Metric | What the agent records | | ---------------------- | -------------------------------------------------------------------------------------- | | CPU utilization | Maximum rolling average CPU usage percentage over the last 30 seconds | | Memory utilization | Maximum memory usage percentage | | Filesystem utilization | Maximum storage usage percentage | | Disk I/O | Maximum rolling average of read plus write disk throughput over the last 30 seconds | | Network utilization | Maximum rolling average of read plus write network throughput over the last 30 seconds | The rolling average is the detail that changes how you read the CPU line. A three second spike inside a compile burst does not lift the sustained figure, so a job whose peak sits at 40 percent has real headroom even when the log looks busy. ### [The hierarchy it is grouped under](#the-hierarchy-it-is-grouped-under)Copy Anchor Link Metrics are aggregated in four levels, which mirror how GitHub Actions is organized: 1. **Repository**: every workflow in one repository. 2. **Workflow**: an individual workflow file, such as `ci.yml` or `release.yml`. 3. **Job**: a job defined in that file, such as `build` or `integration-tests`. 4. **Instance type**: the runner instance type the job actually landed on. Start at the top and drill down. A repository average hides the one fan-out job running two hundred times a day on a large label while touching four cores, and the instance type level is where matrix jobs give themselves away, because a matrix frequently lands on a size nobody chose deliberately. Every runner WarpBuild operates carries the same agent. The series for a Windows job and the series for a Linux job arrive in the same views under the same four levels. ## [Fix](#fix)Copy Anchor Link The read that turns the series into a decision is always the same: put the metric line next to the step timeline for one run, find the plateau, and name the step under it. Take an `integration-tests` job in `ci.yml` running on `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB), wall clock 14 minutes 20 seconds. The Usage view shows the utilization charts and both log streams for that single instance, so the elapsed times below come from the GitHub Actions log and the peaks come from the metric series over the same window. The numbers are an illustrative run. | Elapsed | Step in the GitHub Actions log | Peak CPU | Peak memory | Peak disk I/O | | -------------- | ---------------------------------------- | ---------- | ----------- | ------------- | | 0:00 to 0:35 | Set up job, actions/checkout@v4 | 22 percent | 7 percent | 180 MB/s | | 0:35 to 2:10 | actions/setup-node@v4, npm ci | 61 percent | 19 percent | 240 MB/s | | 2:10 to 3:05 | docker compose up for postgres and redis | 35 percent | 28 percent | 90 MB/s | | 3:05 to 12:40 | npm run test:integration | 99 percent | 91 percent | 12 MB/s | | 12:40 to 14:20 | Upload coverage artifact | 24 percent | 33 percent | 70 MB/s | Four things fall out of that table. **One step owns the job.** The test step holds 9 minutes 35 seconds of a 14 minute 20 second run. Any work spent on checkout or install returns at most a few seconds, so the install caching ticket can wait. **CPU and memory hit the ceiling together.** 99 percent CPU alongside 91 percent memory for the whole plateau means the machine has nothing left on either axis. Sizes in the catalog scale vCPU and RAM together, so the next step up the same family, `warp-ubuntu-latest-x64-8x` at 8 vCPU and 32 GB, is the move. **Disk is only busy at the ends.** The 240 MB/s peak during `npm ci` and the 180 MB/s peak during checkout confirm those phases are I/O bound and short. A job whose disk line stayed high through the middle would be a different diagnosis, and a bigger CPU label would not touch it. **A memory-only ceiling is a different fix.** If the memory line sat at 91 percent while CPU idled in the twenties, the lever is the worker or shard count inside the test runner rather than the label, because more cores would sit unused next to the same ceiling. Once a job is flagged, the aggregate views tell you whether the single run was representative. The [reports documentation](/docs/ci/features/reports) covers the Jobs report, which carries run count, success rate, and P75 and P90 for duration, queue time, CPU, and memory per unique repository, workflow, and job name. The full walkthrough of those percentiles lives in the guide to [runner level metrics](/guides/github-actions-runner-metrics), and [GitHub Actions observability with WarpBuild](/solutions/github-actions-observability) covers the surfaces around it. ## [Configuration](#configuration)Copy Anchor Link Two collection controls decide whether any of this exists for a given job. **Pausing collection.** Observability data collection can be paused. While paused, no telemetry is collected from the runners, and that includes the system logs and the GitHub Actions logs as well as the utilization metrics. There is no per-stream opt-out, so a job that must run with collection off runs with all three streams off. On pooled instances with collection paused, the agent still initializes before a job is allocated, which is why an instance can appear in the interface before any data is recorded for it. **The one minute floor.** Observability only collects metrics and logs for jobs longer than roughly one minute. A 25 second lint job produces no series at all, which is the usual reason a job shows a dash where CPU and memory should be. **Port usage.** Collection and communication with the platform run over port `33931`, using OpenTelemetry as the transport. That port is open by default on runners WarpBuild operates. On BYOC runners, confirm that egress on `33931` is allowed from the subnet the runners launch in, otherwise the jobs succeed while the Usage view stays empty. The label change the worked read implies is one line per job: Copy Text ``` name: ci on: pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm run build integration-tests: needs: build runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: docker compose up -d postgres redis - run: npm run test:integration ``` Publishing the same image for a second architecture uses the ARM64 labels from the same catalog, and the agent behaves identically on them: Copy Text ``` build-arm: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - run: make release ARCH=arm64 ``` If you want the metric series for a job that does not run on WarpBuild infrastructure, the standard supports that directly. [OpenTelemetry](/glossary/opentelemetry) covers the collector, the OTLP endpoint, and the resource attributes that join a metric series to a run id, and [how to see CPU and memory usage for a job](/answers/how-do-i-see-cpu-and-memory-usage-for-a-github-actions-job) is the short version of the same question. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Linux x64 rates from the WarpBuild [pricing page](/pricing), billed per minute: | Label | vCPU | RAM | USD per minute | | -------------------------- | ---- | ------ | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 | **Assumptions.** Rates are the published per minute rates above. The job runs 400 times a month at the 14.33 minute duration from the worked read. Queue time is excluded because it is not billed. The post-change duration is an assumption to check against the Jobs report after the label moves rather than a measured result. **Today.** 400 runs x 14.33 minutes = 5,732 minutes on `warp-ubuntu-latest-x64-4x` at $0.008 = $45.86 a month. **After the move.** Assume the test step scales with cores and duration lands at 8.5 minutes. 400 x 8.5 = 3,400 minutes on `warp-ubuntu-latest-x64-8x` at $0.016 = $54.40 a month. That is $8.54 more per month and returns 5.83 minutes per run, which is 2,332 minutes of wall clock a month handed back to whoever is waiting on the pull request. **The number to check first.** The dollar break even sits at 7.2 minutes: at 400 runs, the 8x label costs the same as today's bill once duration falls to 7.2 minutes, and anything below that lowers the bill as well as the clock. If the new P90 comes back at 11 minutes rather than 8.5, the upgrade bought time at $32.14 more per month, and that is a decision rather than a mistake. Two structural notes for anyone building this into a budget. Every cost statement here carries its rate, its run count, and the date the rates were checked, which is 2026-08-13. ## [FAQ](#faq)Copy Anchor Link **What OpenTelemetry data does a WarpBuild runner emit?** Three streams from an agent on the machine: CPU, memory, filesystem, disk I/O, and network utilization metrics, system logs from the runner, and the GitHub Actions logs used to line the metrics up with the step that produced them. Collection runs over OpenTelemetry on port 33931 and the results are grouped by repository, then workflow, then job, then instance type. **Why is there no telemetry for some of my jobs?** Observability collects metrics and logs only for jobs longer than about one minute, so a short lint job produces no series. Collection can also be paused, and pausing stops metrics, system logs, and GitHub Actions logs together rather than one stream at a time. On pooled instances with collection paused, the agent still initializes before a job is allocated, so an entry can appear in the interface before any data is recorded. **Do I have to run my own OpenTelemetry collector to get runner metrics?** Not on runners WarpBuild operates. The agent is already on the machine and reports to the platform on port 33931, and the metrics appear in the Usage view next to the same job's logs. On BYOC runners the agent behaves the same way, so the item to check is that egress on port 33931 is allowed from the subnet the runners launch in, otherwise jobs run normally while the Usage view stays empty. --- # Running Many iOS Jobs in Parallel on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/parallel-ios-jobs-github-actions Description: Fanning many iOS jobs out on GitHub Actions clears three limits: the workflow concurrency key, account job ceilings, and the macOS fleet quota. # Running Many iOS Jobs in Parallel on GitHub Actions Fanning many iOS jobs out on GitHub Actions clears three limits: the workflow concurrency key, account job ceilings, and the macOS fleet quota. Last verified: 2026-08-13 Running many iOS jobs in parallel on GitHub Actions comes down to three separate limits: the `concurrency` key written into the workflow, the account-level job ceiling attached to your GitHub plan, and the macOS capacity behind whatever label `runs-on` points at. Clear them in that order, then size the fan-out to the macOS concurrency quota your organization actually holds and shard the test suite to match that width. This guide separates the three limits, shows the workflow shape that builds once and fans `xcodebuild` shards across `warp-macos` labels, and works a wall-clock model that respects a quota instead of assuming infinite macOS machines. ## [Diagnosis](#diagnosis)Copy Anchor Link An iOS matrix that refuses to widen is stalled by one of three mechanisms. They produce different symptoms, and the fix for one does nothing for the other two. ### [1\. The workflow concurrency key](#1-the-workflow-concurrency-key)Copy Anchor Link The `concurrency` key is a throttle you wrote yourself. A group holds one running item and one pending item, so a third arrival evicts the pending one. With `cancel-in-progress: true`, the arriving run also kills the run currently executing. The tell is a "Waiting" banner naming a group, or a queued run flipping to "Canceled" the moment a teammate pushes. The key never starts work sooner, so it can only be responsible for parked and canceled runs. If shards inside a single run are queuing, look elsewhere. A common accident on iOS repositories: a bare `concurrency: ios` pasted into the test workflow, the nightly regression workflow, and the TestFlight workflow forces all three into a single lane, and a release build parks the pull request suite. ### [2\. GitHub account-level job ceilings](#2-github-account-level-job-ceilings)Copy Anchor Link GitHub-hosted runners draw from shared pools with a fixed ceiling on concurrent jobs per account, and macOS carries a much lower sub-ceiling than the account total. Figures from [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13: | GitHub plan | Concurrent jobs | Concurrent macOS jobs | | ----------- | --------------- | --------------------- | | Free | 20 | 5 | | Pro | 40 | 5 | | Team | 60 | 5 | | Enterprise | 500 | 50 | Five macOS slots for an entire account is what most iOS teams are actually hitting. A 24-shard suite on `macos-latest` executes in 5 waves whatever the matrix says, and every other repository in the organization waits behind it. Two details matter when you plan a fan-out. The ceiling is account wide rather than per repository, so one team's release day sets everyone else's queue. And jobs targeting self-hosted or managed labels, including `warp-macos` labels, sit outside these ceilings entirely; the repository-level cap of 256 jobs per matrix still applies to them. ### [3\. macOS fleet capacity](#3-macos-fleet-capacity)Copy Anchor Link Once jobs leave the GitHub-hosted pool, the constraint becomes how many macOS machines the label can produce at once. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the concurrency picture differs by platform. The [cloud runners documentation](/docs/ci/cloud-runners) scopes unlimited concurrency to generally available features on Linux and Windows runners. macOS has its own rule, and it is worth stating plainly before you plan a 60-way matrix. Since July 27, 2026, concurrency on the WarpBuild macOS fleet is governed by per-organization quotas, and jobs beyond the quota wait for capacity to free up. If your use case requires high concurrency on macOS, contact [support@warpbuild.com](mailto:support@warpbuild.com) to have the quota raised. So the honest planning input is a number: the macOS concurrency quota held by your organization. Everything below sizes the matrix against that number rather than pretending it does not exist. Measure before changing anything. The Queue Timings report breaks queue wait down per runner label and stack at P75 and P90, with a daily chart and CSV export, and the Jobs report puts queue time next to duration, CPU, and memory for each repository, workflow, and job name. Both are part of WarpBuild CI observability and are documented in the [reports documentation](/docs/ci/features/reports). A P90 on `warp-macos-26-arm64-6x` that climbs only during the merge rush points at the quota. A P90 that stays flat while shard duration grows points at the build itself. ## [Fix](#fix)Copy Anchor Link Work the fan-out in four moves, cheapest first. **Scope the concurrency key per workflow and per ref.** Build the group name from `${{ github.workflow }}-${{ github.ref }}` so the test suite, the nightly job, and the release workflow stop sharing a lane. Cancel on pull request branches and let `main` finish. **Build once, then fan out the test phase.** `xcodebuild build-for-testing` produces an `.xctestrun` bundle and compiled products. Every shard afterwards runs `test-without-building` against that bundle. One compile is spent on a wide machine, and the shards are cheap simulator work on smaller machines. Without this split, a 24-shard matrix compiles the app 24 times and burns the quota on repeated compilation. **Size the matrix to the quota you hold.** Set `max-parallel` to the macOS concurrency quota for your organization so the matrix width and the available capacity agree. Jobs above the quota wait for capacity, which shows up as queue time rather than as an error, so a matrix wider than the quota looks fast in the YAML and slow in the report. **Get everything that does not need Xcode off macOS.** SwiftLint, plist and JSON validation, changelog rules, metadata checks, and API contract tests run on `warp-ubuntu-latest-arm64-4x` at $0.006 per minute. Each of those jobs moved off macOS returns a macOS slot to the simulator shards, which widens the effective fan-out without touching the quota. Then re-read the Queue Timings report for the macOS labels. Queue wait per wave is the number the model in the next section consumes. ## [Configuration](#configuration)Copy Anchor Link The workflow below compiles once on the 12 vCPU macOS label and fans 24 test shards across the 6 vCPU label, with `fail-fast` disabled and the shard index wired through to `xcodebuild`. Copy Text ``` name: ios-tests on: pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: DESTINATION: "platform=iOS Simulator,name=iPhone 17 Pro,OS=27.0" jobs: build-for-testing: runs-on: warp-macos-26-arm64-12x steps: - uses: actions/checkout@v4 - run: | xcodebuild build-for-testing \ -scheme AppTests \ -destination "$DESTINATION" \ -derivedDataPath DerivedData - uses: actions/upload-artifact@v4 with: name: xctestrun-bundle path: DerivedData/Build/Products retention-days: 1 test: needs: build-for-testing runs-on: warp-macos-26-arm64-6x strategy: fail-fast: false max-parallel: 12 matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24] steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: xctestrun-bundle path: DerivedData/Build/Products - name: Run shard ${{ matrix.shard }} of 24 run: | ONLY_TESTING=$(sed 's/^/-only-testing:/' \ "shards/shard-${{ matrix.shard }}.txt" | tr '\n' ' ') xcodebuild test-without-building \ -xctestrun DerivedData/Build/Products/AppTests.xctestrun \ -destination "$DESTINATION" \ -resultBundlePath "results-${{ matrix.shard }}.xcresult" \ $ONLY_TESTING - uses: actions/upload-artifact@v4 if: always() with: name: results-${{ matrix.shard }} path: results-${{ matrix.shard }}.xcresult ``` What each piece is doing: * `fail-fast: false` keeps the other 23 shards running when one fails, so a single flaky UI test reports next to real failures instead of hiding them. * `max-parallel: 12` holds the fan-out at the organization macOS quota. Raise it only after support raises the quota; a wider matrix against a fixed quota converts into queue time. * The shard index reaches `xcodebuild` through a checked-in `shards/shard-N.txt` file listing `Target/Class` entries, one per line, converted into `-only-testing:` arguments. Keeping the split in files makes it reviewable and keeps slow suites balanced by hand where timing data is not available. * `if: always()` on the result upload preserves `.xcresult` bundles from failing shards, which is where the failure diagnosis actually lives. * 24 shards sits well under GitHub's cap of 256 jobs per matrix, checked on 2026-08-13 against [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits). The macOS catalog carries multiple sizes and configurations per chip, so the compile job and the shard jobs pick different machines by label. Rates and specifications come from the [cloud runners documentation](/docs/ci/cloud-runners), re-checked on 2026-08-13: | Runner label | macOS | vCPU | Memory | Storage | Rate per minute | | ----------------------- | ----- | ---- | ------ | --------- | --------------- | | warp-macos-26-arm64-6x | 26 | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | 26 | 12 | 28GB | 270GB SSD | $0.16 | | warp-macos-15-arm64-6x | 15 | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-15-arm64-12x | 15 | 12 | 28GB | 270GB SSD | $0.16 | | warp-macos-14-arm64-6x | 14 | 6 | 14GB | 120GB SSD | $0.08 | The macOS 15 labels also answer to the aliases `warp-macos-latest-arm64-6x` and `warp-macos-latest-arm64-12x`. The `OS=27.0` destination in the workflow above requires the macOS 26 image, which ships the Xcode 27.0 SDKs with iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes while GitHub's upstream macOS 27 runner image is in beta; a dedicated macOS 27 image follows once that image is released. Pin the destination OS to a runtime the image actually carries, since a missing runtime fails every shard at once. Full label details are on the [macOS runners page](/runners/macos), and the simulator setup itself is covered in the guide to [iOS simulator tests on GitHub Actions](/guides/ios-simulator-tests-github-actions). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Fan-out moves wall clock and leaves billed minutes where they were. Here is the arithmetic for the suite above. Assumptions, stated up front: 24 shards, each 9 minutes of simulator time on `warp-macos-26-arm64-6x`; one 6-minute `build-for-testing` job on `warp-macos-26-arm64-12x`; uniform shards with no retries; per-minute billing. Shard compute is 24 x 9 = 216 macOS job-minutes per run in every scenario below. Queue wait comes from the Queue Timings report for the macOS labels. This model uses P75 of 0.7 minutes and P90 of 1.6 minutes per job as placeholders; export your own rows and substitute them. Each wave of shards pays that wait once. | Fan-out width | Waves | Wall clock at P75 | Wall clock at P90 | Shard minutes billed | | ------------------------ | ----- | ----------------- | ----------------- | -------------------- | | 5 concurrent macOS jobs | 5 | 48.5 min | 53.0 min | 216 | | 12 concurrent macOS jobs | 2 | 19.4 min | 21.2 min | 216 | | 24 concurrent macOS jobs | 1 | 9.7 min | 10.6 min | 216 | The arithmetic per row is waves x (9 + queue wait): 5 x 9.7 = 48.5 and 5 x 10.6 = 53.0 at width 5, and 2 x 9.7 = 19.4 and 2 x 10.6 = 21.2 at width 12\. Widening from 5 to 12 returns 29.1 minutes of wall clock per run at P75\. Widening from 12 to 24, which needs a quota raise through support, returns 9.7 more. Billed shard minutes stay at 216 throughout, so wall-clock gains here cost nothing extra in minutes. Now price those minutes. `warp-macos-latest-arm64-6x` (6 vCPU, 14GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14GB): 22 percent lower list price, and the WarpBuild shape carries one more vCPU and the same memory. GitHub list price checked on 2026-08-13 against the [GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Carry that through a month at 200 runs of the suite: | Line item | Minutes per month | Rate | Monthly cost | | ----------------------------------------------------- | ----------------- | ------ | ------------ | | Shards on warp-macos-26-arm64-6x | 43,200 | $0.08 | $3,456.00 | | Same shard minutes at GitHub's macOS ARM64 list rate | 43,200 | $0.102 | $4,406.40 | | Compile job on warp-macos-26-arm64-12x | 1,200 | $0.16 | $192.00 | | Lint and metadata jobs on warp-ubuntu-latest-arm64-4x | 1,600 | $0.006 | $9.60 | Shard minutes: 24 x 9 x 200 = 43,200\. Compile minutes: 6 x 200 = 1,200\. The computed difference on the shard line is $950.40 per month at this volume, before counting the wall clock returned by the wider fan-out. SSO costs a flat $250 per month, whatever the user count, and it is the one line on this model that does not scale with the matrix. Full per-minute rates by runner type are on the [pricing page](/pricing). If queue wait rather than shard duration turns out to be the dominant term, the guide to [GitHub Actions queue times](/guides/github-actions-queue-times) covers the rest of the causes, and the answer page on [how many GitHub Actions jobs can run at once](/answers/how-many-github-actions-jobs-can-run-at-once) has the short version of the ceilings above. ## [FAQ](#faq)Copy Anchor Link **How many iOS jobs can run in parallel on GitHub Actions?** On GitHub-hosted macOS runners, the ceiling is 5 concurrent macOS jobs on Free, Pro, and Team plans and 50 on Enterprise, from [GitHub's usage limits reference](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13\. On WarpBuild macOS labels, concurrency is governed by a per-organization quota set for your account since July 27, 2026, and jobs above it wait for capacity. **Does the concurrency key control how many shards run at once?** No. The concurrency key holds one running item and one pending item per group, so it cancels and parks whole runs. Shard width inside one run is set by the matrix, by `max-parallel`, and by how much macOS capacity is available to your account. **What is the largest matrix I can use for an iOS test suite?** A matrix generates at most 256 jobs per workflow run, checked on 2026-08-13 against GitHub's usage limits reference. Past that, split the suite across workflows or collapse a matrix dimension such as the simulator list. **How do I confirm the fan-out actually got faster?** Read the [Queue Timings report](/docs/ci/features/reports) for your macOS runner labels before and after the change. It reports P75 and P90 queue wait per label and stack with CSV export, so you can pair queue wait with shard duration and rebuild the wall-clock model with your own numbers. --- # Persistent Caches for GitHub Actions Runs | WarpBuild URL: https://www.warpbuild.com/guides/persistent-caches-github-actions Description: A GitHub Actions runner is destroyed after every job. Persist state with a cache action for files, or with a snapshot runner for whole machine state. # Persistent Caches for GitHub Actions Runs A GitHub Actions runner is destroyed after every job. Persist state with a cache action for files, or with a snapshot runner for whole machine state. Last verified: 2026-08-13 A GitHub Actions runner is destroyed when its job finishes, so every run starts from a blank machine and pays to rebuild whatever the previous run left behind. Two mechanisms carry state forward: a cache action, which uploads named paths at the end of a run and downloads them at the start of the next one, and a [WarpBuild snapshot runner](/runners/snapshot), which captures the entire runner VM mid-workflow and boots later jobs from that image. This guide covers which mechanism fits which kind of state, the exact `runs-on` syntax for both, and the arithmetic that decides between them. ## [Diagnosis](#diagnosis)Copy Anchor Link Start by naming the state your workflow keeps rebuilding. Three separate costs hide under the label "cold start", and they respond to different fixes. ### [The restore and save round trip runs on every job](#the-restore-and-save-round-trip-runs-on-every-job)Copy Anchor Link A cache action is a network transfer wrapped in a workflow step. On a hit it downloads a compressed archive and decompresses it into the paths you named; on a save it compresses those paths and uploads them again. Both halves are charged as runner minutes, because the runner is billed for the wall clock time it spends waiting on that transfer. The round trip is nearly constant work per run, and it grows with the size of the archive rather than with the size of the change. A pull request that touches one file still pays the full restore for a 20 GB dependency and build tree. Look at the step timings on a recent run: if the restore step and the post-run save step together account for more than a quarter of the job, the transfer is the cost, and shrinking the entry or skipping the save on branches will move the number more than any runner resize. ### [Some entries are too large to restore quickly](#some-entries-are-too-large-to-restore-quickly)Copy Anchor Link Cache archives are compressed with zstd and streamed onto the runner. That is efficient for a few hundred megabytes of package tarballs and slow for tens of gigabytes of Gradle caches, container layers, and compiled artifacts. Large entries also fail in specific ways. Docker layers larger than 5 GB on a small runner produce a "Failed to commit cache" error, which the [caching documentation](/docs/ci/features/caching) resolves by moving the job to a larger runner size. Size interacts with retention. A cache entry expires 7 days after its last use, and on GitHub's own cache storage a repository-wide ceiling evicts entries in least recently used order once the total passes the cap. The [guide to the GitHub Actions cache size limit](/guides/github-actions-cache-size-limit) covers that ceiling and how to remove it. The point here is narrower: an entry big enough to be slow to restore is also big enough to crowd out its neighbors. ### [Incremental build state dies with the runner](#incremental-build-state-dies-with-the-runner)Copy Anchor Link The costs above are transfer costs. This one is structural. Incremental compilers depend on state that a cache action was never designed to move. * A [Gradle](/solutions/gradle-github-actions) or Bazel daemon that has a warm JVM, a populated in-memory file hash cache, and an open project model. The daemon dies with the machine. * Timestamps and inode identity across the working directory. A fresh `git clone` writes new timestamps, so an incremental compiler that compares mtimes treats every source file as changed even when the bytes are identical. * The Docker daemon's local image store, its overlay layers, and any containers a test harness left warm. * Package manager state that lives across several directories at once, where naming every path in a `path:` list is guesswork. None of that survives a restore, which is why a workflow can hold a 95 percent cache hit rate and still spend eight minutes before the first useful compilation. The state is machine state, and a cache action moves files. Measure the split before you choose a fix. Add up the seconds spent in the restore step, the seconds spent in the post-run save step, and the seconds between the end of the restore and the first step that does new work. The third number is reconcile time, and it is the number a cache action cannot reduce. ## [Fix](#fix)Copy Anchor Link Pick the mechanism that matches the state, and be willing to run both. **Use a cache action when the state is a set of files you can name by path.** Dependency directories such as `~/.npm`, `~/.m2`, `~/.gradle/caches`, `~/.cargo`, and `vendor/bundle` are exactly this shape. They are content addressed by a lockfile hash, they restore in seconds at typical sizes, and they are shared cleanly across branches through `restore-keys`. Swapping `actions/cache@v4` for `WarpBuilds/cache@v1` is a one-line change, and the WarpBuild [setup actions](/docs/ci/features/caching/setup-actions) for Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise wire the same storage in with no key management at all. **Use a snapshot runner when the state is the machine.** [A snapshot runner](/answers/what-are-snapshot-runners) captures the runner VM at a chosen point in the workflow, and later jobs boot from that image with the filesystem, the installed toolchains, the pulled container images, and the prior build outputs already in place. Nothing is transferred at job start, so the cost stops scaling with the size of the state. The boundary runs in both directions, and both directions have a cost. | Question | Cache action | Snapshot runner | | ----------------- | ------------------------------------ | ------------------------------------------------------- | | What it moves | Named paths, as a compressed archive | The whole runner VM image | | Cost at job start | Grows with archive size | A boot of 45 to 60 seconds, flat | | Per job fee | $0.0001 per cache operation | $0.04 per snapshot restore | | Standing fee | $0.20 per GB-month of storage | $0.025 per snapshot-hour | | Good fit | Lockfile-keyed dependency trees | Warm daemons, container stores, incremental build trees | | Poor fit | Tens of GB of mixed machine state | A 500 MB npm cache | | Platform support | Linux x64 and ARM64 runners | WarpBuild Cloud Ubuntu runners only | Read the poor-fit row carefully. A snapshot runner boots in 45 to 60 seconds, which is slower than a base image boot, and it charges $0.04 for the restore. On a small dependency cache the snapshot loses on both counts. Reach for it when the setup work it removes is worth more than that fixed boot and fee, and keep the cache action everywhere else. State the unsupported surfaces plainly before you plan a rollout. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. BYOC runners, Windows runners, and macOS runners are not supported, and a snapshot label on any of those is silently ignored, so the job runs normally with no snapshot behavior and no error. The `/tmp` directory does not persist, because it is cleaned on reboot. Snapshots are deleted after 15 days. ### [The conditional runs-on pattern](#the-conditional-runs-on-pattern)Copy Anchor Link The pattern the [snapshot runner documentation](/docs/ci/features/snapshot-runners) recommends puts snapshot creation on the default branch and snapshot consumption on feature branches. Pushes to `main` boot from the base image with `snapshot.enabled=true` and write a fresh snapshot at the end of the run; every other branch boots from that snapshot with `snapshot.key=`. Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-16x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-16x;snapshot.key=monorepo-main' }} steps: - name: Checkout code uses: actions/checkout@v5 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build run: pnpm build - name: Test run: pnpm test - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "monorepo-main" fail-on-error: true wait-timeout-minutes: 60 ``` Two properties make this shape the default recommendation. Snapshots are only ever written from a branch you control, so a pull request cannot poison the image other pull requests boot from. And the snapshot is rebuilt from the base image on every push to `main`, which bounds how far the image can drift from a clean install. The runner label carries the snapshot directive after a semicolon, so the whole thing stays a single `runs-on` string. The snapshot API supports listing and deleting aliases from automation as well as from the console. ## [Configuration](#configuration)Copy Anchor Link Two labels control the feature, and the difference between them is the boot source. * `snapshot.enabled=true` turns the feature on and always boots from the base image. Pair it with the `WarpBuilds/snapshot-save@v1` action to capture the machine at a chosen point. * `snapshot.key=` turns the feature on and boots from an existing snapshot for that alias when one exists. With no snapshot yet, the runner boots from the base image, so the first run of a new alias is correct without special handling. A job booted from a snapshot carries the `WARPBUILD_SNAPSHOT_KEY` environment variable set to the alias. Branch on it when a step should behave differently on a warm machine: Copy Text ``` - name: Prime toolchain if: env.WARPBUILD_SNAPSHOT_KEY == '' run: ./scripts/install-toolchain.sh ``` The `snapshot-save` action takes three inputs. `alias` is required and is the name later jobs boot from. `fail-on-error` defaults to `true` and turns a failed snapshot creation into a failed job. `wait-timeout-minutes` defaults to 30 and bounds how long the action waits for creation to finish; large images on busy repositories are the reason to raise it to 60. ### [Clean the machine before you capture it](#clean-the-machine-before-you-capture-it)Copy Anchor Link A snapshot preserves everything on disk, including anything a step wrote into a home directory. Put a cleanup step ahead of `snapshot-save` on every workflow that captures an image: Copy Text ``` rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx ``` `git clean -ffdx` removes untracked files and directories, including files ignored by `.gitignore`; the doubled `-f` forces removal even where `clean.requireForce` is set. Two exposure paths make this mandatory rather than tidy. On public repositories, anyone who can open a pull request can reference the alias and boot a runner from your image. On private repositories, WarpBuild provisions runners at the organization level and GitHub may hand a runner built for a snapshot job to a different job in the organization, so treat the snapshot as readable by anyone in the org. ### [Incremental snapshots on a feature branch](#incremental-snapshots-on-a-feature-branch)Copy Anchor Link When each run should build on the previous one, drop the conditional and use one alias for both boot and save. Every run boots from the latest snapshot and writes a new one at the end: Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-8x;snapshot.key=integration-suite steps: - uses: actions/checkout@v5 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build run: pnpm build - name: Cleanup credentials run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot uses: WarpBuilds/snapshot-save@v1 with: alias: "integration-suite" fail-on-error: true wait-timeout-minutes: 60 ``` This shape keeps incremental build directories warm across runs, which is the case a cache action handles worst. It also compounds drift, because nothing ever resets the image to a clean base. Rebuild the alias from a scheduled job on the default branch with `snapshot.enabled=true` if the working tree accumulates junk over a week. ### [Running both mechanisms together](#running-both-mechanisms-together)Copy Anchor Link Snapshots and cache actions coexist. A common split puts machine state in the snapshot and lockfile-keyed dependencies in the cache, so a lockfile bump invalidates one small entry instead of the whole image: Copy Text ``` - name: Restore pnpm store uses: WarpBuilds/cache@v1 with: path: ~/.local/share/pnpm/store key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm- ``` Platform placement decides what is available. The cache is enabled by default on Linux runners and is not supported on Windows-based runners, and snapshots are limited to Cloud Ubuntu runners, so a Windows or macOS job in the same workflow keeps its existing setup steps unchanged. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Prices first, then a worked model. GitHub's list prices for Linux GitHub-hosted runners, from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13: | GitHub-hosted runner | Shape | Price per minute | | ------------------------------------- | --------------- | ---------------- | | ubuntu-latest on a private repository | 2 vCPU, 8 GB | $0.006 | | 4-core Linux larger runner | 4 vCPU, 16 GB | $0.012 | | 8-core Linux larger runner | 8 vCPU, 32 GB | $0.022 | | 16-core Linux larger runner | 16 vCPU, 64 GB | $0.042 | | 32-core Linux larger runner | 32 vCPU, 128 GB | $0.082 | WarpBuild Linux rates for the same shapes, from the [pricing page](/pricing): | Runner label | vCPU | RAM | Price per minute | | ---------------------------- | ---- | ------ | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | | warp-ubuntu-latest-arm64-16x | 16 | 64 GB | $0.024 | | warp-ubuntu-latest-arm64-32x | 32 | 128 GB | $0.048 | State-persistence fees on hosted runners: | Metric | Rate | | ---------------------- | ------------------------ | | Snapshot restore | $0.04 per job | | Snapshot storage | $0.025 per snapshot-hour | | Cache storage | $0.20 per GB-month | | Cache write or restore | $0.0001 per operation | ### [Break-even before any modeling](#break-even-before-any-modeling)Copy Anchor Link One snapshot restore costs $0.04\. Divide that by the per-minute rate to get the setup time a snapshot has to remove before it pays for itself: | Runner label | Price per minute | Runner minutes equal to one snapshot restore | | ---------------------------- | ---------------- | -------------------------------------------- | | warp-ubuntu-latest-x64-2x | $0.004 | 10.0 | | warp-ubuntu-latest-x64-4x | $0.008 | 5.0 | | warp-ubuntu-latest-x64-8x | $0.016 | 2.5 | | warp-ubuntu-latest-x64-16x | $0.032 | 1.25 | | warp-ubuntu-latest-x64-32x | $0.064 | 0.63 | | warp-ubuntu-latest-arm64-8x | $0.012 | 3.33 | | warp-ubuntu-latest-arm64-16x | $0.024 | 1.67 | The rule falls out of the table. On a small runner with a small dependency cache, a snapshot has to save ten minutes per run to break even on the restore fee alone, which a 500 MB npm cache never will. On a large runner with a large working set, the threshold is under 90 seconds, and the boot itself takes 45 to 60 seconds of that. ### [Worked time model](#worked-time-model)Copy Anchor Link Assumptions, stated so you can substitute your own numbers from your step timings: * A monorepo on `warp-ubuntu-latest-x64-16x` at $0.032 per minute. * 30 workflow runs per weekday, 22 weekdays per month, so 660 runs. * A 20 GB working set: dependency trees, Gradle caches, pulled container images, and prior build outputs. * On the cache path: restoring 20 GB takes 5.0 minutes, reconcile after restore takes 3.0 minutes, and a save takes 5.0 minutes on the one run in five where a key changes, which is 1.0 minute amortized. Base image boot is 0.25 minutes. * On the snapshot path: boot from snapshot is 1.0 minute, the docs upper bound of 60 seconds. Reconcile is 1.0 minute, because prior build outputs and warm images are already on disk. Snapshot creation costs 5.0 minutes on each of 22 pushes to `main`, which is 0.17 minutes amortized over 660 runs. | Stage | Cache action path | Snapshot path | | ----------------------- | ----------------- | ------------- | | Runner boot | 0.25 min | 1.00 min | | Restore state | 5.00 min | 0.00 min | | Reconcile after restore | 3.00 min | 1.00 min | | Save state, amortized | 1.00 min | 0.17 min | | Total per run | 9.25 min | 2.17 min | The difference is 7.08 minutes per run. Across 660 runs that is 4,673 minutes per month, or about 78 hours of runner time, and every one of those minutes sits at the front of a job where a developer is waiting on a check. ### [Worked money model](#worked-money-model)Copy Anchor Link Same assumptions, priced out: | Line | Monthly | | --------------------------------------------------------- | ------- | | Runner minutes removed, 4,673 at $0.032 | $149.53 | | Cache storage no longer held, 20 GB at $0.20 per GB-month | $4.00 | | Cache operations no longer run, 3,960 at $0.0001 | $0.40 | | Snapshot restores added, 660 at $0.04 | $26.40 | | Snapshot storage added, 720 snapshot-hours at $0.025 | $18.00 | | Net | $109.53 | Two sensitivities are worth checking against your own numbers. Runner size moves the top line and nothing else, so the same workload on `warp-ubuntu-latest-x64-4x` at $0.008 per minute removes $37.38 of runner cost against the same $44.40 of snapshot fees, and the trade turns negative. Run count moves both, but the snapshot storage line is fixed at $18.00 per alias per month whether the repository runs 60 jobs or 6,000, so busy repositories amortize it away. The same workload on GitHub-hosted runners carries the full 9.25-minute setup on every run with no snapshot mechanism available, priced at $0.042 per minute for the 16 vCPU, 64 GB shape (GitHub billing reference, checked on 2026-08-13) against $0.032 per minute for `warp-ubuntu-latest-x64-16x` with the same 16 vCPU and 64 GB, which is 24 percent lower list price. ## [FAQ](#faq)Copy Anchor Link ### [How do I persist state between GitHub Actions runs?](#how-do-i-persist-state-between-github-actions-runs)Copy Anchor Link Two mechanisms exist. A cache action uploads named paths at the end of a run and downloads them at the start of the next one, which suits dependency directories. A snapshot runner captures the whole runner VM mid-workflow and boots later jobs from that image, which suits state that lives outside a cacheable path, such as warmed daemons, container images, and incremental build outputs. ### [When should I use a snapshot runner instead of a cache action?](#when-should-i-use-a-snapshot-runner-instead-of-a-cache-action)Copy Anchor Link Use a snapshot runner when the restore and reconcile work at the start of a run exceeds the runner minutes that one snapshot restore costs. A snapshot restore is $0.04 per job, which equals 10 minutes on `warp-ubuntu-latest-x64-2x` and 1.25 minutes on `warp-ubuntu-latest-x64-16x`. Below that threshold, a cache action is the cheaper mechanism. ### [Which runners support snapshot runners?](#which-runners-support-snapshot-runners)Copy Anchor Link Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, on x64 and ARM64\. BYOC runners, Windows runners, and macOS runners are not supported. Snapshot labels applied to an unsupported runner type are silently ignored and the job runs normally without snapshot behavior. ### [How long does a WarpBuild snapshot last, and what does it not capture?](#how-long-does-a-warpbuild-snapshot-last-and-what-does-it-not-capture)Copy Anchor Link Snapshots are temporary and are deleted after 15 days. The `/tmp` directory does not persist, because it is cleaned on reboot, so anything a workflow needs across runs must live outside `/tmp`. Snapshot storage is billed at $0.025 per snapshot-hour. --- # Preview Environments from Pull Requests | WarpBuild URL: https://www.warpbuild.com/guides/preview-environments-github-actions Description: Preview environments on GitHub Actions run on three transitions: create on open, update on push, destroy on close. Workflow YAML, concurrency, and cost. # Preview Environments from Pull Requests Preview environments on GitHub Actions run on three transitions: create on open, update on push, destroy on close. Workflow YAML, concurrency, and cost. Last verified: 2026-08-13 A preview environment on GitHub Actions is a deployment of one pull request branch that lives exactly as long as the pull request does, driven by three transitions: create when the pull request opens, update on every push to the head branch, destroy when the pull request closes. The third transition is the one that decides the bill, because a teardown job that gets skipped leaves the environment running on your cloud account until a person notices it. This guide covers the failure modes that leave environments alive, the workflow keys that fix them, a full workflow with the three triggers and a concurrency group keyed on the pull request number, and a cost model at 260 pull requests a month that separates runner minutes from environment hours. ## [Diagnosis](#diagnosis)Copy Anchor Link Read the workflow's `on:` block and its job-level `if:` conditions before anything else. Five shapes account for most preview environment trouble, and each puts the cost somewhere different. | Symptom | Mechanism | Where the cost lands | | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | Environments outlive their pull requests | on: pull\_request fires on opened, synchronize, and reopened by default, so closed never arrives unless it is listed | Environment hours on your cloud bill, with no upper bound | | Teardown is skipped after a failed deploy | The teardown job carries needs: deploy, and a failed dependency skips the dependent job | A half-created environment holding cluster resources, a database, and a load balancer | | A stale commit wins the preview URL | Two pushes deploy in parallel and the slower run finishes last | The preview serves an older commit than the pull request head | | Every preview queues behind one lane | The concurrency.group string resolves to the same value for all pull requests | Deploys serialize across unrelated pull requests | | Fork pull requests fail at credential exchange | A pull\_request run from a fork gets a read-only GITHUB\_TOKEN and no repository secrets | Red checks on external contributions | The activity type list for `pull_request` is in GitHub's [events reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull%5Frequest), checked on 2026-08-13\. The first row is the expensive one, and it hides because the two halves of the cost show up in two different places. Runner minutes appear on the Actions side and stop when the job ends. Environment hours appear on your cloud invoice and keep accruing while the workflow does nothing at all, which is why an orphaned preview stays invisible on the surface where people look for pipeline spend. The per pull request view of the first half is in [GitHub Actions cost per pull request](/guides/ci-cost-per-pull-request). The fourth row is a naming problem. GitHub matches the resolved group string across the repository rather than per workflow file, so `group: preview` is a single global lane for every open pull request. The resolution rules are in the [concurrency group glossary entry](/glossary/concurrency-group). One thing that is rarely the cause: platform capacity. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps of WarpBuild runners, and capacity adjusts dynamically, so 40 open pull requests deploying at the same time is a decision you write into the YAML rather than a limit you inherit. ## [Fix](#fix)Copy Anchor Link ### [Declare all four activity types](#declare-all-four-activity-types)Copy Anchor Link `types: [opened, reopened, synchronize, closed]` maps the three lifecycle transitions onto events. `opened` and `reopened` create, `synchronize` fires on every push to the head branch and updates, `closed` destroys. Merging counts as closing, so one destroy path covers both merged and abandoned pull requests. ### [Key the concurrency group on the pull request number](#key-the-concurrency-group-on-the-pull-request-number)Copy Anchor Link `group: preview-${{ github.event.pull_request.number }}` gives each pull request its own lane, and `cancel-in-progress: true` means a new push kills the deploy that the push just made obsolete. Because the same group covers the close event, closing a pull request cancels an in-flight deploy for that pull request instead of racing it to the finish. Nothing follows a close except a reopen, so the teardown run reaches its own end. ### [Make the teardown unskippable](#make-the-teardown-unskippable)Copy Anchor Link Put the destroy job in the same workflow file, gate it at job level on `if: github.event.action == 'closed'`, and give it no `needs:` edge at all. A job with no dependencies cannot be skipped by an upstream failure, which matters most in the case where the deploy died partway through creating resources. Add a scheduled reaper as the backstop. It lists the environments your naming scheme creates, asks the API for the state of the matching pull request, and destroys anything whose pull request is closed or whose pull request has been idle past your threshold. A daily reaper run is under a minute of runner time and catches the workflow runs that were cancelled, the ones that hit an expired credential, and the environments somebody created by hand. ### [Size the deploy runner and keep the rows separable](#size-the-deploy-runner-and-keep-the-rows-separable)Copy Anchor Link Preview deploys build an image, push it, and apply a manifest, so they sit in the middle of the size range. Rates and shapes come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13. | runs-on label | OS | vCPU | RAM | Storage | Rate per minute | | -------------------------- | ------------ | ---- | ----- | --------- | --------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | A preview that ships a mobile client alongside the service runs the mobile leg on a macOS label while the deploy stays on Linux. Name the jobs `preview-deploy` and `preview-destroy`: the CI tab of the [Reports page](/docs/ci/features/reports) writes one row per job execution carrying repository, job name, runner label, stack, execution time, billed time, and the cost split, and it filters on job name, so the preview line separates from the rest of the pipeline with no extra instrumentation. ## [Configuration](#configuration)Copy Anchor Link One workflow file carries all three transitions. The deploy job and the destroy job share the concurrency group and share nothing else. Copy Text ``` name: preview on: pull_request: types: [opened, reopened, synchronize, closed] concurrency: group: preview-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: contents: read id-token: write pull-requests: write jobs: preview-deploy: if: >- github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository runs-on: warp-ubuntu-latest-x64-4x environment: name: preview-${{ github.event.pull_request.number }} url: https://pr-${{ github.event.pull_request.number }}.preview.example.com steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/gha-preview aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - uses: Warpbuilds/build-push-action@v6 id: push with: context: . push: true profile-name: api-builder tags: ${{ steps.ecr.outputs.registry }}/api:pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} - name: Apply the preview environment run: ./preview/apply.sh "pr-${{ github.event.pull_request.number }}" "${{ steps.push.outputs.digest }}" - name: Publish the preview URL env: GH_TOKEN: ${{ github.token }} run: | gh pr comment "${{ github.event.pull_request.number }}" \ --edit-last --create-if-none \ --body "Preview: https://pr-${{ github.event.pull_request.number }}.preview.example.com" preview-destroy: if: github.event.action == 'closed' runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 with: sparse-checkout: preview/ - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/gha-preview aws-region: us-east-1 - name: Destroy the preview environment run: ./preview/destroy.sh "pr-${{ github.event.pull_request.number }}" ``` Five details carry the behavior. The `types:` list includes `closed`, so the destroy path exists. `preview-destroy` has no `needs:` edge, so a deploy that failed three pushes ago cannot skip it. The concurrency group resolves to `preview-1487` for every event on pull request 1487 and to a different string for every other pull request, so pushes supersede their own predecessors and leave other previews alone. The head repository check keeps fork pull requests out of the credential exchange, which is the read-only-token row from the diagnosis table. And the environment name matches the resource prefix the destroy script takes, so the reaper can rebuild the mapping from a list of resources alone. The reaper is a second file, and it is the reason the teardown holds even when a run never happens: Copy Text ``` name: preview-reaper on: schedule: - cron: "0 6 * * *" workflow_dispatch: jobs: reap: runs-on: warp-ubuntu-latest-x64-2x permissions: contents: read pull-requests: read id-token: write steps: - uses: actions/checkout@v4 with: sparse-checkout: preview/ - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/gha-preview aws-region: us-east-1 - name: Destroy previews whose pull request is closed env: GH_TOKEN: ${{ github.token }} MAX_IDLE_HOURS: "168" run: ./preview/reap.sh ``` `reap.sh` lists every resource group with the `pr-` prefix, resolves the number back to a pull request through `gh pr view`, and calls the same `destroy.sh` for anything closed or idle past `MAX_IDLE_HOURS`. Using the same destroy script in both places keeps one code path for resource removal. If the target is a Kubernetes cluster, `apply.sh` and `destroy.sh` wrap the two commands from the [Kubernetes deployments guide](/solutions/kubernetes-deployments-github-actions), and the credential and digest handling around them follows the [deployment jobs guide](/guides/deployment-jobs-github-actions). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Assumptions](#assumptions)Copy Anchor Link | Input | Value | Source | | ------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------- | | Pull requests opened per month | 260 | Your pull request history | | Deploys per pull request | 6 (one open, five pushes) | Your workflow run history | | Deploy job duration | 4.0 minutes | Your run logs | | Destroy job duration | 1.2 minutes | Your run logs | | Median time a pull request stays open | 19 hours | Your pull request history | | Runner rates | $0.004 to $0.032 per minute | [cloud runners docs](/docs/ci/cloud-runners) and [pricing](/pricing), checked 2026-08-13 | ### [Runner minutes](#runner-minutes)Copy Anchor Link | Transition | Runs per month | Label | Minutes each | Minutes | Rate | Cost | | ---------------- | -------------- | ------------------------- | ------------ | ------- | ------ | ------ | | Create on open | 260 | warp-ubuntu-latest-x64-4x | 4.0 | 1,040 | $0.008 | $8.32 | | Update on push | 1,300 | warp-ubuntu-latest-x64-4x | 4.0 | 5,200 | $0.008 | $41.60 | | Destroy on close | 260 | warp-ubuntu-latest-x64-2x | 1.2 | 312 | $0.004 | $1.25 | | Reaper, daily | 30 | warp-ubuntu-latest-x64-2x | 0.8 | 24 | $0.004 | $0.10 | | Total | 1,850 | 6,576 | $51.27 | | | | The concurrency group changes the second row. A push that lands while the previous deploy is still running cancels it, so a superseded run bills the minutes it actually used. At 18 percent of update pushes superseded after an average 1.5 minutes, that row falls from 5,200 minutes to 4,615 minutes and $36.92, and the monthly total to $46.58. Those two deploy rows carry the list-price arithmetic. warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price, with the GitHub list price checked on 2026-08-13 in the [minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The 5,655 deploy minutes above bill $45.24 at the WarpBuild rate against $67.86 at the GitHub list rate, a difference of $22.62 a month at an identical machine shape. ### [Environment hours](#environment-hours)Copy Anchor Link Runner minutes are the smaller half. The environment bills on your own cloud account for every hour it exists, and the destroy job is the thing that stops that meter. | Teardown behavior | Environments per month | Average lifetime | Environment hours | | -------------------------------------------------------- | ------------------------ | ---------------- | ----------------- | | Destroy on close, every time | 260 | 19 h | 4,940 | | Destroy on close, 8 percent skipped, weekly manual sweep | 239 at 19 h, 21 at 103 h | 26 h | 6,704 | | No destroy job, cleanup at month end | 260 | 360 h | 93,600 | Skipping 21 teardowns out of 260 adds 1,764 environment hours, a 36 percent increase over a month where every teardown lands. Dropping the destroy job entirely takes the same 260 pull requests to 93,600 hours. Multiply the last column by your own hourly rate for one environment. At $0.05 an hour the three rows are $247, $335, and $4,680 a month. At $0.40 an hour they are $1,976, $2,682, and $37,440\. The runner minutes that create, update, and destroy those environments stay at $46.58 in all three rows, which is the shape of the problem: the workflow is cheap and the thing it leaves behind is not. Pricing on WarpBuild is purely usage based. Ordering this against the rest of the bill is covered in [cutting GitHub Actions costs](/guides/reduce-github-actions-costs). ## [FAQ](#faq)Copy Anchor Link ### [Why does my preview environment survive after the pull request is merged?](#why-does-my-preview-environment-survive-after-the-pull-request-is-merged)Copy Anchor Link Because `on: pull_request` defaults to the `opened`, `synchronize`, and `reopened` activity types, and `closed` sits outside that default set ([events reference](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull%5Frequest), checked on 2026-08-13). List all four in `types:` and the destroy path starts firing. Merging a pull request closes it, so one `closed` handler covers merged and abandoned alike. ### [Should the teardown job depend on the deploy job?](#should-the-teardown-job-depend-on-the-deploy-job)Copy Anchor Link No. A `needs: deploy` edge makes a failed or cancelled deploy skip the teardown, which is the case where an environment is most likely to be half created and still holding resources. Gate the teardown at job level on `if: github.event.action == 'closed'` with no `needs:` edge, and keep a scheduled reaper as the backstop. ### [What concurrency group should a preview workflow use?](#what-concurrency-group-should-a-preview-workflow-use)Copy Anchor Link One keyed on the pull request number, such as `preview-${{ github.event.pull_request.number }}` with `cancel-in-progress: true`. GitHub resolves the group string across the whole repository, so a group keyed on a constant or on the workflow name serializes every preview in the repository behind one lane. The resolution rules are in the [concurrency group glossary entry](/glossary/concurrency-group). --- # Sharding pytest Suites Across Runners | WarpBuild URL: https://www.warpbuild.com/guides/pytest-parallel-test-sharding Description: pytest runs parallel in GitHub Actions two ways: xdist worker processes inside one job and matrix shards across jobs. Size both levers and merge results. # Sharding pytest Suites Across Runners pytest runs parallel in GitHub Actions two ways: xdist worker processes inside one job and matrix shards across jobs. Size both levers and merge results. Last verified: 2026-08-13 A pytest suite runs parallel in GitHub Actions through two independent levers: `pytest-xdist` worker processes inside one job, and matrix shards that spread the suite across several jobs. Workers divide the suite across the vCPUs of one runner, shards divide it across runners, and a suite over roughly 20 minutes usually wants both set deliberately rather than one of them turned up until it stops helping. This guide covers how to tell which lever is mis-set, the edits that fix each case, a workflow that runs four shards with four workers each and merges the results into one report, and a cost model that prices one 16 vCPU runner against four 4 vCPU runners. For the language-neutral form of the technique see the [guide to matrix sharding](/guides/matrix-sharding-github-actions) and the [test sharding definition](/glossary/test-sharding), and for the rest of the run see the [hub on speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link The two levers fail in different ways, so the first job is deciding which one you are looking at. ### [Worker processes inside one job](#worker-processes-inside-one-job)Copy Anchor Link `pytest -n 8` starts eight worker processes on the machine running the job. They are separate processes, so the CPython global interpreter lock does not cap them, and the real ceilings are vCPU count, memory, disk, and any shared service the suite talks to. Each worker imports the test modules on startup, so collection cost is paid once per worker rather than once per job. `-n auto` reads the CPU count of the machine, which means the same workflow line produces 2 workers on `warp-ubuntu-latest-x64-2x` and 16 on `warp-ubuntu-latest-x64-16x`. That is convenient until the suite is memory bound, at which point the larger label starts killing workers. Distribution mode matters as much as the count. The default `--dist load` sends each test to whichever worker is free, which rebalances at run time and also spreads one module across several workers, so module and class scoped fixtures are set up more than once. `--dist loadfile` and `--dist loadscope` keep tests grouped by file or by class ([pytest-xdist distribution modes](https://pytest-xdist.readthedocs.io/en/stable/distribution.html), checked on 2026-08-13). ### [Shards across jobs](#shards-across-jobs)Copy Anchor Link A shard is a separate job on a separate runner with its own checkout, its own dependency install, and its own slice of the suite decided before the run starts. That fixed split is where wall clock leaks. Splitting by test count gives four jobs of four different lengths, and the run waits for the longest one while the other three sit finished. | Symptom | What it means | Where to look | | ---------------------------------------- | ----------------------------------------------------------------------- | ------------------------------ | | Wall clock flat as \-n rises | Workers above the vCPU count, or the suite waits on one shared database | CPU P75 and P90 per job | | Workers restart or the job exits 137 | Memory per worker below what the suite holds | Memory P75 and P90 per job | | Shards finish minutes apart | Fixed split with no recorded durations | Per-shard job durations | | Passes locally, fails with \-n | Tests sharing a file, a port, or database rows | Test code, not the workflow | | Billed minutes rise, wall clock does not | Setup work repeated per shard | Setup steps against test steps | | One job hits the 6 hour ceiling | Suite too large for a single job | GitHub Actions job limit | Duration, queue time, CPU, and memory land at P75 and P90 for every repository, workflow, and job name combination in the Jobs report, with CSV export ([reports documentation](/docs/ci/features/reports)); the CPU and memory columns need observability enabled. On GitHub-hosted runners, read peak memory yourself inside the job and subtract `created_at` from `started_at` per job for queue wait. A single job stops at 6 hours ([GitHub Actions usage limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), which is the hard reason a long suite has to shard rather than only add workers. ## [Fix](#fix)Copy Anchor Link Work these in order. The first three change the worker lever, the last three change the shard lever. **Record durations before splitting anything.** `pytest --store-durations` writes a `.test_durations` file that pytest-split reads to build balanced groups ([pytest-split](https://github.com/jerry-git/pytest-split), checked on 2026-08-13). Commit the file and refresh it on a schedule, since a split built from last quarter's timings drifts as tests are added. **Set the worker count from the label, then check memory.** Start at the vCPU count and read Memory P90 after a day of runs. Both layouts in the cost model below give each worker 4 GB: 16 workers across the 64 GB of `warp-ubuntu-latest-x64-16x`, or 4 workers across the 16 GB of `warp-ubuntu-latest-x64-4x`. **Pick the distribution mode from fixture scope.** Suites built on module or class scoped fixtures belong on `--dist loadfile` or `--dist loadscope`. Suites of independent function scoped tests keep the default and get run-time rebalancing for free. **Give each worker its own state.** xdist sets `PYTEST_XDIST_WORKER` to `gw0`, `gw1`, and so on inside each worker. Derive the database name, the temp directory, and any bound port from it in a session fixture, and the cross-worker failures in the table above stop. **Shard once one machine is saturated.** The trigger is a machine ceiling rather than a preference: memory the largest label cannot hold, a suite approaching the 6 hour job limit, or a wall clock target below what the vCPU count of one runner can reach. **Merge the results in a dedicated job.** Four shards produce four coverage files and four JUnit XML files, and branch protection wants one check. The merge job in the next section runs with `if: always()` so a failing shard still reports. A Python suite with platform specific paths can keep the same shard and worker structure on a `warp-macos-latest-arm64-6x` or `warp-windows-latest-x64-4x` label and change only `runs-on`. ## [Configuration](#configuration)Copy Anchor Link Four shards, four workers per shard, durations-based splitting, and a merge job. Copy Text ``` name: pytest on: pull_request: push: branches: [main] jobs: test: name: pytest ${{ matrix.shard }}/4 runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.13" cache: pip - run: pip install -r requirements.txt -r requirements-dev.txt - name: Run shard env: COVERAGE_FILE: .coverage.${{ matrix.shard }} run: | pytest -n 4 --dist loadfile \ --splits 4 --group ${{ matrix.shard }} \ --durations-path .test_durations \ --junitxml=junit-${{ matrix.shard }}.xml \ --cov=src --cov-report= - uses: actions/upload-artifact@v4 with: name: pytest-results-${{ matrix.shard }} path: | junit-${{ matrix.shard }}.xml .coverage.${{ matrix.shard }} include-hidden-files: true merge: needs: test if: always() runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/setup-python@v5 with: python-version: "3.13" - run: pip install coverage junitparser - uses: actions/download-artifact@v4 with: pattern: pytest-results-* merge-multiple: true - run: | junitparser merge junit-*.xml junit.xml coverage combine coverage report --fail-under=85 ``` What each piece does: * `--splits 4 --group N` comes from pytest-split and partitions the suite by recorded time from `.test_durations`, so the four jobs aim at equal duration rather than equal test count. * `-n 4` matches the 4 vCPU of `warp-ubuntu-latest-x64-4x`, so the two levers multiply to 16 worker processes across the matrix. * `--dist loadfile` keeps every test in a module on one worker, which is what makes module scoped fixtures cost one setup each. * `COVERAGE_FILE` per shard avoids four jobs writing the same `.coverage` name. `coverage combine` looks for files prefixed `.coverage.` in the working directory and merges them ([coverage combine](https://coverage.readthedocs.io/en/latest/cmd.html#combining-data-files), checked on 2026-08-13). * Artifact names are unique per shard because `actions/upload-artifact@v4` writes immutable artifacts and rejects a second upload under an existing name ([upload-artifact](https://github.com/actions/upload-artifact), checked on 2026-08-13). `include-hidden-files: true` is required for the dotfile coverage data. * `merge-multiple: true` on the download unpacks every matching artifact into one directory, which is what lets the two merge commands use globs. * `fail-fast: false` keeps the other three shards running after one fails, so a single flaky test does not cost a full rerun of the grid. Labels resolve to these machines, from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The last column is the per-minute rate divided by the vCPU count. | runs-on label | vCPU | RAM | Storage | USD per minute | USD per vCPU minute | | -------------------------- | ---- | ------ | --------- | -------------- | ------------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | $0.002 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | $0.002 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | $0.002 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | $0.002 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | $0.002 | ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Two formulas cover the pair of levers. Wall clock per run is `s + c + T / (N * w)`, and billed minutes are `N * (s + c + T / (N * w))`, where `T` is serial suite execution time, `N` is the shard count, `w` is workers per shard, `s` is per-job setup, and `c` is the collection each worker pays before its first test. Assumptions for the model: `T` of 64 minutes, `s` of 3 minutes for checkout, `setup-python`, and a warm pip cache, `c` of 0.5 minutes, an even durations-based split, no retries, and queue wait excluded. Replace all five with your own numbers from the Jobs report. | Layout | Worker processes | Wall clock | Billed minutes | Rate | Cost per run | | --------------------------- | ---------------- | ---------- | -------------- | ------ | ------------ | | 1 job on \-x64-16x, \-n 16 | 16 | 7.5 min | 7.5 | $0.032 | $0.24 | | 4 jobs on \-x64-4x, \-n 4 | 16 | 7.5 min | 30 | $0.008 | $0.24 | | 2 jobs on \-x64-16x, \-n 16 | 32 | 5.5 min | 11 | $0.032 | $0.35 | | 8 jobs on \-x64-4x, \-n 4 | 32 | 5.5 min | 44 | $0.008 | $0.35 | | 16 jobs on \-x64-4x, \-n 4 | 64 | 4.5 min | 72 | $0.008 | $0.58 | Rows one and two are the comparison the question usually turns on, and they price identically. The Linux x64 catalog above charges $0.002 per vCPU minute at every size, so 16 vCPU for 7.5 minutes costs $0.24 whether it arrives as one machine or four. The repeated setup in the four-job layout is paid on quarter-size machines, which is why it does not show up as a premium: `4 * 3 * $0.008` is the same $0.096 as `1 * 3 * $0.032`. The price separation appears when the worker total goes up rather than when it is rearranged. Going from 16 workers to 32 buys 2 minutes of wall clock and adds $0.11 per run; the next doubling to 64 workers buys 1 more minute and adds $0.23\. Stop where per-shard test time approaches `s + c`, which is 32 workers for this suite. Three things break the tie between rows one and two at equal price. An uneven split hurts the shard layout, because xdist rebalances at run time inside a job while a matrix split is fixed before the run: shards holding 24, 16, 14, and 10 minutes of work finish at 9.5, 7.5, 7, and 6 minutes, so the run takes 9.5 minutes against 7.5 for the single machine, at the same 30 billed minutes. Rerunning one failed shard costs a quarter of the grid, where rerunning the single job costs all of it. And a suite that needs more than 64 GB or more than 6 hours has no single-machine option left. Those 30 billed minutes carry a list price. GitHub rates below are from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Shape | GitHub-hosted rate | WarpBuild label and rate | Cost of one run | | -------------- | ----------------------------- | ---------------------------------- | ----------------------------------- | | 4 vCPU, 16 GB | 4-core larger runner, $0.012 | warp-ubuntu-latest-x64-4x, $0.008 | $0.36 against $0.24 for 30 minutes | | 16 vCPU, 64 GB | 16-core larger runner, $0.042 | warp-ubuntu-latest-x64-16x, $0.032 | $0.32 against $0.24 for 7.5 minutes | `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list prices checked on 2026-08-13. Scale the four-shard layout to a working month at 40 runs a day: 1,200 billed minutes a day, $9.60 on `warp-ubuntu-latest-x64-4x` and $14.40 on the 4-core GitHub-hosted larger runner, or $211.20 against $316.80 over 22 working days. Full rates by runner type are on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link **Should I use pytest-xdist workers or matrix shards?** Use workers first, because they cost one setup and rebalance at run time. Add shards once the largest runner you are willing to rent is saturated, once the suite needs more memory than one machine holds, or once a single job approaches the 6 hour GitHub Actions job limit. Most suites end up with both: a shard matrix for the fan-out and a worker count inside each shard. **How many pytest-xdist workers should I run per runner?** Start at the vCPU count of the label, so 4 on `warp-ubuntu-latest-x64-4x` and 16 on `warp-ubuntu-latest-x64-16x`, then lower it if memory peaks near the ceiling. Each worker is a separate process holding its own imports and fixtures, so a suite that needs 4 GB per worker fits 4 workers in the 16 GB of a 4 vCPU runner and 16 workers in the 64 GB of a 16 vCPU runner. **How do I merge coverage and JUnit XML from pytest shards?** Write one file per shard, upload each under a unique artifact name, then merge in a dependent job that runs with `if: always()` so a failed shard still reports. Set `COVERAGE_FILE` to a per-shard path and run `coverage combine` over the downloaded files, and merge the per-shard JUnit XML into one report before the branch protection check reads it. --- # Moving ARM64 Docker Builds Off QEMU | WarpBuild URL: https://www.warpbuild.com/guides/qemu-vs-native-arm64-builds Description: A buildx arm64 build on an x64 GitHub Actions runner is slow because QEMU translates every instruction. Move it to a native ARM64 runner or builder. # Moving ARM64 Docker Builds Off QEMU A buildx arm64 build on an x64 GitHub Actions runner is slow because QEMU translates every instruction. Move it to a native ARM64 runner or builder. Last verified: 2026-08-13 A `docker buildx build --platform linux/arm64` on an x64 GitHub Actions runner is slow because the arm64 half of the build runs under QEMU user mode emulation, which translates aarch64 instructions into x64 instructions before the host CPU executes them and marshals every syscall between the two ABIs. Removing the emulation means running the build where aarch64 is the native instruction set: either a native runner label such as `warp-ubuntu-latest-arm64-8x` at $0.012 per minute, or a remote Docker builder profile with arm64 enabled. This guide covers what the interpreter is doing during a buildx run, which Dockerfile steps absorb the cost, the before and after workflow diff for both routes, the builder profile constraints that shape the choice, and a cost model with the assumptions written down. Joining two per-architecture images into one tag with a manifest list belongs to the [multi-platform Docker image guide](/guides/multi-platform-docker-builds); this page is about getting the interpreter out of the build. ## [Diagnosis](#diagnosis)Copy Anchor Link ### [What the emulation layer does inside a buildx run](#what-the-emulation-layer-does-inside-a-buildx-run)Copy Anchor Link `docker/setup-qemu-action` runs a privileged container that writes entries into `/proc/sys/fs/binfmt_misc`. Each entry tells the kernel that a binary whose ELF header says `aarch64` should be handed to `qemu-aarch64-static` rather than executed directly. That is the whole mechanism. Nothing about the runner changes; the kernel simply gains a rule for foreign binaries. BuildKit then resolves the Dockerfile for `linux/arm64` and builds that graph on the x64 machine. It pulls arm64 base images, unpacks them into a snapshot, and executes each `RUN` step inside a container whose root filesystem is full of aarch64 binaries. Every one of those binaries hits the binfmt rule and runs as translated code. The translation works a block at a time. QEMU reads a run of aarch64 instructions, compiles it into x64 instructions, caches the translated block, and jumps into it. A hot loop pays the translation once and then runs the cached form, so a long compile is not as bad as a per-instruction model would suggest, and it is still well short of native. Syscalls follow a different path: the interpreter catches each one, converts the arm64 ABI arguments into the host ABI, and forwards it to the x64 kernel. A process that makes many small syscalls, such as a linker walking thousands of object files or a package manager unpacking an archive, pays that marshalling on every call. Two things stay x64 no matter how faithful the translation is. The kernel is the host kernel, so `/proc/cpuinfo`, core counts, and CPU feature detection describe the x64 machine. And the wall clock comes from the host, so a build step that scales its parallelism from detected cores makes its decision on x64 information while running aarch64 code. ### [Which steps carry the cost](#which-steps-carry-the-cost)Copy Anchor Link The exposure is uneven, which is why two projects on the same runner size can see very different emulated build times. Work that BuildKit performs on the host is untouched. Work that runs inside the target root filesystem is translated. | Build step | Executed by | Emulation exposure | | -------------------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------- | | FROM pull and unpack | BuildKit on the host | None. Bytes move at host speed. | | COPY and ADD from the build context | BuildKit on the host | None. | | Layer export and registry push | BuildKit on the host | None. | | RUN curl, RUN git clone | Network bound inside the container | Low. The wait dominates. | | RUN apt-get install | Mixed | Download is native speed; dpkg unpack and maintainer scripts are translated. | | RUN compile steps: gcc, clang, rustc, javac, go build with cgo | Translated | Highest. CPU bound from start to finish. | | Link steps: ld, lld, ar | Translated | High, and syscall heavy on top of the translation. | | RUN npm ci with native modules through node-gyp | Translated | High. Each module compiles inside the interpreter. | | RUN pip install where no aarch64 wheel exists | Translated | High. The wheel is built from source under the interpreter. | | Test suites executed during the image build | Translated | High, and the least predictable. | A Dockerfile that is mostly `COPY` of prebuilt assets onto a slim base barely notices emulation. A Dockerfile that compiles a service from source spends nearly all of its time in the interpreter. ### [Failure modes that read like Dockerfile bugs](#failure-modes-that-read-like-dockerfile-bugs)Copy Anchor Link Four log signatures point at emulation rather than at your build. `exec format error` at the top of a step means the kernel had no binfmt handler for that binary at the moment it ran. The usual causes are a `setup-qemu-action` step placed after the build, scoped to a different job, or skipped by a conditional. `Illegal instruction` or `SIGILL` comes from code that probed for a CPU feature and got an answer the interpreter cannot honor. Crypto libraries and codecs with runtime dispatch land here most often. `qemu: uncaught target signal 11` wraps a segfault that happened inside the translated program. JIT-heavy runtimes and threading-heavy builds produce these under emulation while running clean on real hardware. A step that hangs with no output is the fourth. Emulated builds change timing enough to expose races that never fire natively, and a lock ordering bug that takes a real machine a month to hit can become reproducible under translation. ### [Confirming it in one run](#confirming-it-in-one-run)Copy Anchor Link Add a temporary step ahead of the build and read three lines. Copy Text ``` - name: report architecture run: | uname -m ls /proc/sys/fs/binfmt_misc/ | grep -i qemu || echo "no qemu handlers" docker buildx inspect --bootstrap | grep -i platforms ``` On an emulated job `uname -m` prints `x86_64`, the binfmt listing shows a `qemu-aarch64` entry, and the buildx platform list carries `linux/arm64` without the asterisk that marks a native platform. On a native ARM64 runner `uname -m` prints `aarch64` and no handler is involved. Then measure. Run the same Dockerfile twice on the same x64 runner, once with `--platform linux/amd64` and once with `--platform linux/arm64`, and record both durations. The gap is what the interpreter costs you, and it is the number the cost model below wants. Resizing the x64 runner does not close that gap, because a larger x64 machine still translates every instruction. Two more constraints worth checking before you plan the fix. Layer cache entries are keyed per platform, so a warm amd64 cache does nothing for an arm64 build and a workflow that builds both stores two sets of entries. And a GitHub Actions job runs for at most 6 hours, which an emulated build of a large compiled service can approach once a dependency starts building from source. ## [Fix](#fix)Copy Anchor Link Two routes remove the interpreter. Both keep the same Dockerfile. **Route A: run the job on a native ARM64 runner.** WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the ARM64 runners attach through the `runs-on` label. The edit is to delete the QEMU setup step and change one label. Everything in the job, including the tests and packaging steps around the image build, then executes on aarch64 hardware. These are the Linux ARM64 labels and rates, from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing): | Label | vCPU | RAM | Storage | Rate per minute | Pinned alias | | ---------------------------- | ---- | ----- | --------- | --------------- | -------------------------- | | warp-ubuntu-latest-arm64-2x | 2 | 8GB | 150GB SSD | $0.003 | warp-ubuntu-2404-arm64-2x | | warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 | warp-ubuntu-2404-arm64-4x | | warp-ubuntu-latest-arm64-8x | 8 | 32GB | 150GB SSD | $0.012 | warp-ubuntu-2404-arm64-8x | | warp-ubuntu-latest-arm64-16x | 16 | 64GB | 150GB SSD | $0.024 | warp-ubuntu-2404-arm64-16x | | warp-ubuntu-latest-arm64-32x | 32 | 128GB | 150GB SSD | $0.048 | warp-ubuntu-2404-arm64-32x | Ubuntu 26.04 ships under `warp-ubuntu-2604-arm64-8x` and the rest of that series at the same rates. Pin the alias in the `runs-on` label when you want the image version to change on your schedule rather than when `latest` moves. The full label list, including the preinstalled tooling, is on the [Linux ARM64 runner page](/runners/linux-arm64). One porting detail applies to the Ubuntu 24.04 ARM64 image: its work dir is `/runner/_work`, which differs from GitHub's `/home/runner/work/`, so a script with a hardcoded runner path needs to read `$GITHUB_WORKSPACE` instead. Workloads that need nested virtualization stay on x64. **Route B: keep the job where it is and send the build to an arm64 builder profile.** Remote Docker builders are one of the WarpBuild product surfaces alongside snapshot runners, CI observability, an MCP server, and the Action Debugger. A builder profile maps to one dedicated builder VM with a persistent layer cache on local disk, and the GitHub Actions job becomes a thin driver that ships the context and streams the log. The build runs on native arm64 hardware inside the builder, so no interpreter is involved even though the runner is x64. Pick between them on the shape of the job. * The job is ARM64 work end to end, including tests and packaging: Route A. One label change, one billed resource. * The job also compiles or tests x64 artifacts and the image build is one step among several: Route B, so the runner stays x64 while the image build goes native. * One job has to publish both architectures under one tag: Route B with a multi-arch profile, or the per-architecture split covered in the [multi-platform Docker image guide](/guides/multi-platform-docker-builds). * The image build is the whole job and rebuilds are frequent with a high layer reuse rate: Route B, because the layer cache lives on the builder disk instead of being restored over the network each run. There is a third option that keeps the build on the x64 runner: cross compile, so a native x64 toolchain emits an aarch64 binary that a thin runtime stage copies in. That works when the toolchain cross compiles cleanly and breaks on cgo, native npm modules, and any `RUN` step that executes a freshly built aarch64 binary during the build. The tradeoffs are worked through in [cross compiling compared with native ARM64 builds](/guides/cross-compiling-vs-native-arm64-builds). Generally available Linux runners do not have plan-level concurrency caps, so an x64 and ARM64 matrix is not serialized by the WarpBuild plan. ## [Configuration](#configuration)Copy Anchor Link Here is the workflow most teams start from. The runner is x64, the QEMU step installs the binfmt handlers, and the build targets arm64. Copy Text ``` name: image on: push: branches: [main] jobs: image: runs-on: ubuntu-latest timeout-minutes: 90 steps: - uses: actions/checkout@v4 - uses: docker/setup-qemu-action@v3 with: platforms: arm64 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . platforms: linux/arm64 push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} ``` ### [Route A: native ARM64 runner](#route-a-native-arm64-runner)Copy Anchor Link The diff is two edits. Change the label, delete the QEMU step. Copy Text ``` jobs: image: - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-arm64-8x timeout-minutes: 90 steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 - - uses: docker/setup-buildx-action@v3 ``` Nothing below that line changes. `platforms: linux/arm64` is now the native platform of the machine, so buildx builds it directly. Drop `timeout-minutes: 90` back to something that reflects the new duration once you have measured a few runs, so a genuinely stuck build fails fast again. Add the architecture to any cache key in the workflow. Cache entries are keyed by a hash that includes the runner OS, so entries written on an x64 runner will not restore on the ARM64 one, and mixing them wastes storage on entries neither fleet reads. Copy Text ``` - uses: actions/cache@v4 with: path: ~/.cargo/registry key: cargo-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/Cargo.lock') }} ``` ### [Route B: arm64 builder profile](#route-b-arm64-builder-profile)Copy Anchor Link Create a builder profile in the [Docker builders dashboard](/docs/ci/docker-builders), enable arm64 on it, then swap the build action. The WarpBuild action configures the remote builder for you, so the local buildx setup step comes out as well. Copy Text ``` jobs: image: - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-x64-2x timeout-minutes: 90 steps: - uses: actions/checkout@v4 - - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 - - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/build-push-action@v6 + - uses: Warpbuilds/build-push-action@v6 with: context: . platforms: linux/arm64 push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} + profile-name: arm64-images ``` On a WarpBuild runner the action authenticates automatically. From a GitHub-hosted runner or another platform, add `api-key: ${{ secrets.WARPBUILD_API_KEY }}`. To produce both architectures from this one job, enable both on the profile and set `platforms: linux/amd64,linux/arm64`. `cache-from` and `cache-to` come out of the workflow entirely on this route. The builder holds the layer cache on its own disk, so there is nothing to export to a registry or to the Actions cache between runs. ### [Builder profile constraints](#builder-profile-constraints)Copy Anchor Link Four facts from the [Docker builders documentation](/docs/ci/docker-builders) shape the sizing and the bill, and the [remote Docker builder page](/runners/docker-builders) carries the full size list. | Profile size | Disk | Rate per minute | Architectures | | ------------------ | ----- | --------------- | ------------------- | | 16 vCPU, 32GB RAM | 100GB | $0.06 | amd64, arm64, multi | | 32 vCPU, 64GB RAM | 200GB | $0.12 | amd64, arm64, multi | | 64 vCPU, 128GB RAM | 200GB | $0.24 | amd64, arm64, multi | First, arm64 and multi-arch profiles cap at 64 vCPU. The 96 and 192 vCPU sizes, from $0.36 to $0.88 per minute, exist for amd64-only profiles, so an ARM64 build tops out at the 64 vCPU row above. Second, a multi-arch build runs one session per architecture, because each architecture runs on its own builder instance. Two sessions bill independently for the same profile, and both stay alive until the post-action steps finish. Third, billing is per session, measured from when the builder action starts until the job completes, and concurrent jobs sharing one profile share one session billed from the first job's start to the last job's completion. Fourth, the runner and the builder are two separate resources and both are charged. Two operational notes to file away. The profile cache resets automatically after 10 unused days, so the first build after a quiet period is a cold one. And `exec format error` on this route almost always means the profile is missing the architecture the build asked for. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Assumptions, stated so you can substitute your own numbers from the two-run measurement in the diagnosis section: * One Dockerfile producing a single `linux/arm64` image for a compiled service, where the compile and link steps dominate. * Example durations: 36 billed minutes emulated on an 8 vCPU x64 runner, 8 billed minutes native on an 8 vCPU ARM64 runner. These are placeholders that make the arithmetic visible. Measure your own; the ratio depends entirely on how much of your Dockerfile is CPU bound inside the target rootfs. * 300 image builds per month. * WarpBuild rates from the [pricing page](/pricing), billed per minute. | Path | Arithmetic | Cost per build | Monthly at 300 builds | | ------------------------------------- | ----------- | -------------- | --------------------- | | Emulated on warp-ubuntu-latest-x64-8x | 36 x $0.016 | $0.576 | $172.80 | | Native on warp-ubuntu-latest-arm64-8x | 8 x $0.012 | $0.096 | $28.80 | Two levers move independently in that table. The architecture change removes 28 billed minutes per build. The rate change is worth $0.004 per minute at the 8 vCPU size, since the ARM64 rate sits below the x64 rate at every size in the catalog above. ### [Against GitHub-hosted list prices](#against-github-hosted-list-prices)Copy Anchor Link GitHub publishes its per-minute rates in the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13, with shapes from the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Same shape on both sides: | Shape | GitHub-hosted ARM64 label | GitHub rate | WarpBuild label | WarpBuild rate | Difference | | --------------- | --------------------------------- | ----------- | ---------------------------- | -------------- | --------------------------- | | 2 vCPU, 8 GB | ubuntu-24.04-arm | $0.005 | warp-ubuntu-latest-arm64-2x | $0.003 | 40 percent lower list price | | 4 vCPU, 16 GB | 4-core Linux ARM64 larger runner | $0.008 | warp-ubuntu-latest-arm64-4x | $0.006 | 25 percent lower list price | | 8 vCPU, 32 GB | 8-core Linux ARM64 larger runner | $0.014 | warp-ubuntu-latest-arm64-8x | $0.012 | 14 percent lower list price | | 16 vCPU, 64 GB | 16-core Linux ARM64 larger runner | $0.026 | warp-ubuntu-latest-arm64-16x | $0.024 | 8 percent lower list price | | 32 vCPU, 128 GB | 32-core Linux ARM64 larger runner | $0.050 | warp-ubuntu-latest-arm64-32x | $0.048 | 4 percent lower list price | The GitHub ARM64 rate on private repositories was checked on 2026-08-13 and the 2 vCPU row uses that private-repository shape, which is what paying teams run. Applying those rates to the same example durations: the emulated build on an 8 vCPU x64 GitHub-hosted larger runner at $0.022 per minute costs 36 x $0.022 = $0.792 per build, or $237.60 per month, and the native build on the 8 vCPU GitHub-hosted ARM64 larger runner costs 8 x $0.014 = $0.112 per build, or $33.60 per month. Removing the emulation is the larger lever on either provider; the per-minute rate stacks on top of it. ### [The builder route, priced](#the-builder-route-priced)Copy Anchor Link The builder route bills two resources, so model it on its own. Take a 16 vCPU arm64 profile at $0.06 per minute and a driver job on `warp-ubuntu-latest-x64-2x` at $0.004 per minute. | Scenario | Arithmetic | Cost per build | | ----------------------------------------------------- | ------------------------------ | -------------- | | Cold builder, 8 minute session and 8 minute job | (8 x $0.06) + (8 x $0.004) | $0.512 | | Warm layer cache, 2 minute session and 2 minute job | (2 x $0.06) + (2 x $0.004) | $0.128 | | Multi-arch, two 8 minute sessions and an 8 minute job | (2 x 8 x $0.06) + (8 x $0.004) | $0.992 | The third row is the one to check against your own numbers before committing, since a multi-arch build creates one session per architecture and both bill independently. When the job also runs tests and packaging around the image, Route A usually prices better, because you are paying for runner minutes you needed anyway. To rerun this with real inputs: take your emulated duration and your native duration from the two-run experiment, multiply each by the rate at your size, and multiply by your monthly build count. Every cost number on this page carries a source link and a checked-on date so you can verify it before you plan around it. SSO is available for a flat $250 per month, whatever the user count, and it is the one line on this page that does not move with usage. ## [FAQ](#faq)Copy Anchor Link ### [What is QEMU actually doing during a buildx arm64 build?](#what-is-qemu-actually-doing-during-a-buildx-arm64-build)Copy Anchor Link `docker/setup-qemu-action` writes binfmt\_misc entries so the kernel routes any aarch64 binary to `qemu-aarch64-static`. BuildKit then runs each `RUN` step of the arm64 graph in a container full of aarch64 binaries, so the interpreter translates blocks of aarch64 instructions into x64 instructions and marshals every syscall across the two ABIs. ### [Which Dockerfile steps get slower under emulation and which do not?](#which-dockerfile-steps-get-slower-under-emulation-and-which-do-not)Copy Anchor Link `FROM` pulls, `COPY`, and `ADD` are executed by BuildKit on the host and are unaffected. The cost lands on `RUN` steps that burn CPU inside the target rootfs: compilers, linkers, node-gyp rebuilds, pip installs that build a wheel from source, and test suites run during the image build. Steps that mostly wait on the network stay close to their native duration. ### [Do I need a native ARM64 runner, or is an arm64 builder profile enough?](#do-i-need-a-native-arm64-runner-or-is-an-arm64-builder-profile-enough)Copy Anchor Link Either removes the interpreter. Use a `warp-ubuntu-latest-arm64` runner when the whole job is ARM64 work. Use an arm64 or multi-arch builder profile when the job also does x64 work, or when one job has to produce both architectures. Note that arm64 and multi-arch profiles cap at 64 vCPU and that a multi-arch build runs one session per architecture. ### [Why does the build still fail with exec format error after I remove setup-qemu-action?](#why-does-the-build-still-fail-with-exec-format-error-after-i-remove-setup-qemu-action)Copy Anchor Link On a native ARM64 runner that message means something in the build is still x64, usually a binary baked into the context or downloaded by a step with a hardcoded amd64 URL. On a remote Docker builder it usually means the builder profile does not have the architecture you asked for enabled, so enable both architectures on the profile. --- # Cutting GitHub Actions Costs Without Rewrites | WarpBuild URL: https://www.warpbuild.com/guides/reduce-github-actions-costs Description: Cut GitHub Actions spend by right-sizing runners, ending re-run waste, and fixing cache misses. WarpBuild Linux runners start at $0.004 per minute. # Cutting GitHub Actions Costs Without Rewrites Cut GitHub Actions spend by right-sizing runners, ending re-run waste, and fixing cache misses. WarpBuild Linux runners start at $0.004 per minute. Last verified: 2026-08-13 Most teams reduce GitHub Actions costs with three moves: right-size each job's runner, stop paying for wasted minutes from re-runs and cold caches, and put the remaining minutes on a lower per-minute rate. WarpBuild covers the rate move with a one-line label change: its Linux x64 runners start at $0.004 per minute, against the $0.006 GitHub lists for a standard 2 vCPU Linux runner on its [minute-multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) (checked on 2026-08-13). None of the fixes in this guide require rewriting workflows. The work ranges from a one-line edit in runs-on to an afternoon of reading billing reports, and every change shows up in the next billing cycle, where it can be verified or reverted. ## [Diagnosis](#diagnosis)Copy Anchor Link GitHub Actions spend rarely spreads evenly across an account. A few jobs usually generate most of the bill, and the waste concentrates in five patterns. GitHub's usage report, downloadable from the billing settings, shows minutes by SKU (standard Linux, Windows, macOS, and each larger runner tier), which is enough to see the shape of the spend before any per-job tooling gets involved. Read your last month against this list before changing anything. **Minutes on oversized runners.** A runner size gets picked once, when the workflow is first written, and stays fixed while the workload changes underneath it. The result is a test job on an 8 vCPU machine that peaks at 30 percent CPU, paying the 8 vCPU rate for work that fits a smaller size. The reverse also costs money: an under-provisioned job swaps or thrashes and stretches its wall clock. Sizing arithmetic for GitHub's larger runner tiers gets full coverage in [what GitHub Actions larger runners cost](/guides/github-actions-larger-runners-cost). **macOS minutes.** macOS runners bill at a per-minute rate well above standard Linux, so a modest iOS pipeline can outbill a much larger Linux fleet. If macOS is a visible slice of your usage report, the per-minute arithmetic against GitHub's list price gets its own treatment in [what macOS runners cost on GitHub Actions](/guides/github-actions-macos-runner-cost). **Idle time inside jobs.** Billed minutes track wall clock, and wall clock includes waiting. A job that spends three of its ten minutes waiting for a service container health check, polling an external deploy, or sitting in a fixed sleep bills those three minutes at the same rate as the compute it actually used. Look for steps whose duration dwarfs their log output; those are the waits. **Re-run storms.** Flaky tests convert into whole-workflow re-runs. Assume a 20-minute workflow and a suite that forces a re-run on 15 percent of pushes: the workflow's billed minutes rise by that same share, and the increase hides inside normal-looking usage because each re-run is a legitimate run. Stacked pushes compound it when every commit to a pull request runs the full suite even after a newer commit has made the older run irrelevant. **Cache misses forcing full rebuilds.** A dependency install that restores warm in under a minute can take several minutes cold. When the cache key churns on every run, or the entry gets evicted between runs, every job pays the cold path, every day, in billed minutes. The same misses slow the build down, which is why this list overlaps with [how to speed up GitHub Actions builds](/guides/speed-up-github-actions). ## [Fix](#fix)Copy Anchor Link Work the list in a fixed order: measure first, then move the rate, then remove the waste. Measurement comes first because every later step needs a number to verify against. ### [Measure with the Reports and Observability surfaces](#measure-with-the-reports-and-observability-surfaces)Copy Anchor Link The [Reports page](/docs/ci/features/reports) breaks WarpBuild billing down to the individual job. The CI Billing tab lists each job execution with its repository, runner label, execution time, billed time, and cost, and filters by repository, runner label, stack, and job name. Sort by cost, export the CSV, and the top rows are the worklist. The same page shows the daily cost chart grouped by repository or runner label, which is the fastest way to see which fleet a spend spike came from. The Jobs section aggregates every combination of repository, workflow, and job name over the selected period: run count, success rate, duration at P75 and P90, queue time at P75 and P90, and peak CPU and memory at P75 and P90\. The CPU and memory columns need [observability](/docs/ci/features/observability) enabled, and metrics only collect for jobs longer than about one minute. The Observability page turns those metrics into right-sizing recommendations. It aggregates from repository down to workflow, job, and instance type, then flags instances sustaining 80 percent or more CPU, memory, filesystem, or disk throughput as under-provisioned, and low-utilization instances as over-provisioned. That list is the right-sizing worklist: move over-provisioned jobs down one size, under-provisioned jobs up one, and re-check the same view a week later. ### [Move the per-minute rate](#move-the-per-minute-rate)Copy Anchor Link Each size is a runs-on label documented in the [cloud runners catalog](/docs/ci/cloud-runners), and the images carry the same tooling as GitHub-hosted runners, so the change is the label itself. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. The [pricing page](/pricing) lists the per-minute rate for every size, and signup includes $10 free credits, which is enough to run the cost model below against your own workload before committing. If a build compiles and tests cleanly on ARM64, the ARM64 labels carry lower rates again: warp-ubuntu-latest-arm64-2x bills $0.003 per minute, and the ARM64 sizes run up to 32 vCPU at $0.048 per minute. A shorter job bills fewer minutes at any rate. ### [Cut re-run and idle waste](#cut-re-run-and-idle-waste)Copy Anchor Link Add a concurrency group with cancel-in-progress to every push-triggered workflow, so a new commit cancels the superseded run instead of letting it bill to completion. The YAML is in the Configuration section below. Then rank workflows by the success rate column in the Jobs report and fix the flakiest suites first. Every failed run that needs a manual re-run bills its full duration twice. For idle time, the per-job CPU and memory percentiles show which jobs spend their minutes waiting rather than computing; replace fixed sleeps with condition polling, and move long external waits out of billed jobs entirely. ### [Keep caches warm](#keep-caches-warm)Copy Anchor Link WarpBuild's cache is available on Linux-based runners and enabled by default, and the [caching docs](/docs/ci/features/caching) cover key design and the drop-in cache action. A stable key plus a warm cache removes the cold-install path from routine runs, which cuts both the bill and the wait. Eviction behavior and key churn get deeper coverage in the speed guide linked above. One cost source sits outside this guide: egress charges from deploy-heavy workflows, covered in [GitHub Actions egress costs](/guides/github-actions-egress-costs) and, for the enterprise tier, [zero egress](/enterprise/zero-egress). ## [Configuration](#configuration)Copy Anchor Link The minimum change is one line in each job: Copy Text ``` jobs: test: - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-x64-4x ``` Pick the size from measurement rather than habit. The Linux x64 sizes, from the [cloud runners catalog](/docs/ci/cloud-runners): | Label | vCPU | Memory | Storage | Price | | -------------------------- | ---- | ------ | --------- | ------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004/minute | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008/minute | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016/minute | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032/minute | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064/minute | A complete workflow with the cost controls from the Fix section applied: Copy Text ``` name: ci on: pull_request: push: branches: [main] concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test ``` The concurrency block cancels the older run when a newer commit lands on the same ref, which removes the stacked-push waste named in the Diagnosis section. The label change moves the job onto the measured size at the WarpBuild rate. Nothing else in the file changes, and rolling back means restoring the previous label. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The model uses GitHub's public list prices from its [minute-multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13, against WarpBuild rates at the same vCPU counts. Assumptions: * 6,000 standard jobs per month averaging 8 billed minutes each, currently on GitHub-hosted 2 vCPU Linux runners at a $0.006 per minute list price * 1,000 heavier jobs per month averaging 12 billed minutes each, currently on GitHub-hosted 8 vCPU Linux larger runners at a $0.022 per minute list price * Included minutes are ignored: at this volume a private repository exhausts the Free plan's 2,000 or the Team plan's 3,000 included standard-runner minutes within days, and GitHub's larger runners never draw from included minutes The current monthly bill: | Workload | Minutes per month | Rate | Cost | | -------------------------------- | ----------------- | ------- | ------- | | 6,000 jobs at 8 minutes, 2 vCPU | 48,000 | $0.006 | $288.00 | | 1,000 jobs at 12 minutes, 8 vCPU | 12,000 | $0.022 | $264.00 | | Total | 60,000 | $552.00 | | The same jobs on WarpBuild labels at the same sizes: | Workload | Minutes per month | Label | Rate | Cost | | ------------------------ | ----------------- | ------------------------- | ------ | ------- | | 6,000 jobs at 8 minutes | 48,000 | warp-ubuntu-latest-x64-2x | $0.004 | $192.00 | | 1,000 jobs at 12 minutes | 12,000 | warp-ubuntu-latest-x64-8x | $0.016 | $192.00 | | Total | 60,000 | $384.00 | | | The like-for-like move takes the monthly bill from $552.00 to $384.00, a difference of $168.00 per month or $2,016 per year, before any right-sizing. Now add the measurement step. Suppose the Observability recommendations show the heavier job peaking at 35 percent CPU and 40 percent memory on 8 vCPU. Moving it to warp-ubuntu-latest-x64-4x at $0.008 per minute cuts that line from $192.00 to $96.00 and the monthly total to $288.00, provided the duration percentiles hold after the change. That proviso is the discipline of right-sizing: billed minutes track wall clock, so a downsize that stretches P90 duration gives back part of what the rate change earned. Re-check the Jobs report a week after each change and keep only the sizes that hold their duration. Cache fixes stack on top. If warm caches cut the standard job's average from 8 billed minutes to 6, that line drops from 48,000 minutes to 36,000, and its cost from $192.00 to $144.00, taking the monthly total to $240.00. Against the original $552.00, the measured end state runs $240.00 to $288.00 per month depending on how far the cache work goes, a difference of $264.00 to $312.00 per month, or $3,168 to $3,744 per year. The inputs were one label edit per job, one concurrency block, and an afternoon of reading reports. That result belongs to the inputs in this model, not every fleet. Substitute your measured durations and use the [pricing page](/pricing) for current per-size rates. ## [FAQ](#faq)Copy Anchor Link ### [Do WarpBuild runners require workflow changes beyond the runs-on label?](#do-warpbuild-runners-require-workflow-changes-beyond-the-runs-on-label)Copy Anchor Link No. The runner images carry the same tooling as GitHub-hosted runners, so swapping runs-on to a warp- label is the whole migration for most jobs. ### [How does WarpBuild pricing work?](#how-does-warpbuild-pricing-work)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Runners bill per minute, and signup includes $10 free credits. ### [Can a smaller runner raise my bill by running longer?](#can-a-smaller-runner-raise-my-bill-by-running-longer)Copy Anchor Link Yes. Billed minutes track wall clock, so check the Jobs report after each change. If duration P90 stretches enough to offset the lower rate, move back up a size. ### [Are there concurrency caps that force a plan upgrade?](#are-there-concurrency-caps-that-force-a-plan-upgrade)Copy Anchor Link Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. High concurrency on macOS runners goes through [support@warpbuild.com](mailto:support@warpbuild.com). --- # Release Build Workflows on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/release-builds-github-actions Description: A release workflow fires on a tag, builds each target once, tests those exact files, and publishes them. Here is the YAML, the runner sizing, and the cost. # Release Build Workflows on GitHub Actions A release workflow fires on a tag, builds each target once, tests those exact files, and publishes them. Here is the YAML, the runner sizing, and the cost. Last verified: 2026-08-13 A release build workflow starts on a tag push, builds each target once, and hands those exact files to a publish job that uploads them without running a compiler. Every step between the build and the publish reads artifacts rather than source, so the bytes a user downloads are the bytes the verify job checked. This guide covers the four ways release workflows drift from that shape, the edits that restore it, a workflow with a tag trigger and a build matrix per target, and the arithmetic for one release across Linux, macOS, and Windows labels. It sits under the [speed up GitHub Actions](/guides/speed-up-github-actions) hub. ## [Diagnosis](#diagnosis)Copy Anchor Link A release workflow fails in ways a pull request workflow never does. It runs a few times a week rather than a few times an hour, so bugs sit undetected between releases, and the run produces the file users install rather than a pass or fail signal. ### [The publish job rebuilds what the test job already built](#the-publish-job-rebuilds-what-the-test-job-already-built)Copy Anchor Link The common shape is a `build` job that produces binaries, a `test` job that exercises them, and a `publish` job that starts with a fresh checkout and calls the build script again before uploading. The publish job runs on a different machine at a different minute, with whatever a package manager resolves at that moment, so the uploaded binary and the tested binary are two different builds that happen to share a version string. The symptom is a release that passes every check and then fails on a user machine with a dependency error nobody saw in the run. The second symptom is arithmetic: the build matrix is paid twice per tag. ### [The tag push never starts the run](#the-tag-push-never-starts-the-run)Copy Anchor Link Three causes account for most of it. The glob under `on.push.tags` does not match the tag that was pushed, and `v*.*.*` matching `v1.2.0` while missing `v1.2` is the usual version of that ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows)). The tag was created locally and pushed with a bare `git push`, which sends commits and leaves the tag behind. Or the tag was created by another workflow using the built-in `GITHUB_TOKEN`, and events triggered by that token do not create a new workflow run ([GITHUB\_TOKEN reference](https://docs.github.com/en/actions/concepts/security/github%5Ftoken)). ### [The version comes from somewhere other than the tag](#the-version-comes-from-somewhere-other-than-the-tag)Copy Anchor Link Build scripts often read the version from a manifest file, while the release object takes its name from the tag. Nothing compares them. A tag cut before the version bump lands produces `v1.4.0` release assets that report `1.3.9` when run, and the mismatch surfaces weeks later in a bug report. ### [Artifacts collide or expire](#artifacts-collide-or-expire)Copy Anchor Link Artifacts created by `actions/upload-artifact` v4 are immutable, and artifact names must be unique because multiple jobs cannot modify one artifact ([upload-artifact documentation](https://github.com/actions/upload-artifact#readme)). A matrix whose cells all upload to `dist` fails on the second cell. Retention is the other half: GitHub stores build logs and artifacts for 90 days by default ([removing workflow artifacts](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/remove-workflow-artifacts)), so an artifact is a handoff between jobs in one run rather than a distribution channel. Release assets are the durable copy. | Symptom | What it means | Where to look | | ------------------------------------------------------- | ---------------------------------------------------- | --------------------------------------------------------- | | Release assets fail on a user machine after a green run | The publish job rebuilt instead of downloading | Steps in the publish job for a compile or package command | | No run appears after git push --tags | Tag glob mismatch, or a tag created by GITHUB\_TOKEN | on.push.tags value against the tag name | | Binary reports a version the release does not | Version read from a manifest rather than the tag | Build script arguments for github.ref\_name | | Second matrix cell fails on upload | Two cells writing one artifact name | name input on actions/upload-artifact | | Publish job downloads an empty directory | Artifact name pattern matched nothing | pattern and merge-multiple on actions/download-artifact | ## [Fix](#fix)Copy Anchor Link Five edits hold the shape. The first three make the release reproducible, the last two make it verifiable and give the matrix somewhere to run. **Build once per target per tag.** One job per target produces the files, and every later job downloads them. The publish job holds no build tooling and no checkout of the source it publishes. That rule is the same one that governs container deploys, where the digest travels instead of the source, covered in [build once and deploy many](/guides/build-once-deploy-many-github-actions). **Make the tag the version.** Pass `github.ref_name` into the build as the version argument and let the build fail when the manifest disagrees. The tag is then the only place a release version is written, and a mistyped tag fails in the first minute rather than in a bug report. **Give each target a distinct artifact name.** Name artifacts after the target, upload with `if-no-files-found: error` so an empty `dist` directory fails the cell that produced it, and collect them in one step downstream. The [artifact handoff pattern](/guides/build-artifacts-publishing-github-actions) covers retention settings and the storage arithmetic behind those uploads. **Put one gate between build and publish.** A verify job downloads every artifact, checks the checksums, and runs the binary once. Adding an `environment` to the publish job puts a human approval in front of the upload without touching the build ([managing environments](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments)). **Size each target separately.**. The whole matrix lands on `warp-` labels and the machine per target is a label in the matrix. Set the minutes in the cost model below from your own history rather than from a guess: the Jobs report gives duration P75 and P90 per repository, workflow, and job name, and the Billing tab gives billed time and cost per job execution ([reports documentation](/docs/ci/features/reports)). ## [Configuration](#configuration)Copy Anchor Link This workflow builds four targets on a `v` tag, verifies the collected artifacts once, and publishes them from a single job. Copy Text ``` name: release on: push: tags: - "v*.*.*" permissions: contents: read jobs: build: name: build-${{ matrix.target }} strategy: fail-fast: true matrix: include: - target: linux-x64 runner: warp-ubuntu-latest-x64-8x - target: linux-arm64 runner: warp-ubuntu-latest-arm64-8x - target: macos-arm64 runner: warp-macos-latest-arm64-6x - target: windows-x64 runner: warp-windows-latest-x64-8x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - name: Build ${{ matrix.target }} shell: bash run: ./scripts/build.sh --target ${{ matrix.target }} --version "${{ github.ref_name }}" - uses: actions/upload-artifact@v4 with: name: dist-${{ matrix.target }} path: dist/ if-no-files-found: error retention-days: 14 verify: needs: build runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: pattern: dist-* merge-multiple: true path: dist - name: Checksum and smoke test run: | cd dist && shasum -a 256 * > SHA256SUMS && cd .. ./scripts/smoke-test.sh dist "${{ github.ref_name }}" - uses: actions/upload-artifact@v4 with: name: checksums path: dist/SHA256SUMS if-no-files-found: error publish: needs: verify runs-on: warp-ubuntu-latest-x64-2x environment: release permissions: contents: write steps: - uses: actions/download-artifact@v4 with: pattern: dist-* merge-multiple: true path: dist - uses: actions/download-artifact@v4 with: name: checksums path: dist - name: Create the release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create "${{ github.ref_name }}" dist/* \ --repo "${{ github.repository }}" \ --generate-notes ``` What each piece does: * `on.push.tags` runs the workflow only for tags matching the glob. `github.ref_name` holds the tag name for that event and is the single version source for the build script ([contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts)). * The matrix uses `include` entries alone, so each entry is one job and the target and its runner label stay on one line together. Adding a target is one block. * `shell: bash` keeps one build script across Ubuntu, macOS, and Windows, since Windows runners default to PowerShell ([workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob%5Fidstepsshell)). * `fail-fast: true` cancels the remaining targets when one fails. A partial release has no use, and cancelling saves the minutes. * Each cell uploads to `dist-`, so the four names are distinct. The verify and publish jobs collect them with `pattern` and `merge-multiple: true`, which unpacks every match into one directory ([storing and sharing data](https://docs.github.com/en/actions/tutorials/store-and-share-data)). * `permissions` is read at the workflow level and `contents: write` only on the publish job, so the build and verify jobs cannot create a release. * `gh release create` takes the tag and a file list, and `--generate-notes` builds the notes from merged pull requests ([gh release create](https://cli.github.com/manual/gh%5Frelease%5Fcreate), [automatically generated release notes](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes)). * No job after `build` runs a compiler. That is the property the whole file exists to protect. The labels above resolve to these machines, from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | runs-on label | OS | vCPU | RAM | Storage | USD per minute | | --------------------------- | ------------------- | ---- | ----- | --------- | -------------- | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.012 | | warp-macos-latest-arm64-6x | macOS 15 | 6 | 14 GB | 120GB SSD | $0.08 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Assumptions for the model: 12 minutes for each Linux target, 18 minutes for the macOS target including signing, 16 minutes for the Windows target including signing, 6 minutes for verify, 4 minutes for publish, per minute billing, and the catalog rates above. Substitute your own duration P75 from the Jobs report before making a decision on these numbers. | Job | Label | Minutes | USD per minute | Cost | | ----------------- | --------------------------- | ------- | -------------- | ------ | | build linux-x64 | warp-ubuntu-latest-x64-8x | 12 | $0.016 | $0.192 | | build linux-arm64 | warp-ubuntu-latest-arm64-8x | 12 | $0.012 | $0.144 | | build macos-arm64 | warp-macos-latest-arm64-6x | 18 | $0.08 | $1.440 | | build windows-x64 | warp-windows-latest-x64-8x | 16 | $0.032 | $0.512 | | verify | warp-ubuntu-latest-x64-2x | 6 | $0.004 | $0.024 | | publish | warp-ubuntu-latest-x64-2x | 4 | $0.004 | $0.016 | | Total | 68 billed minutes | $2.33 | | | The four build jobs run at once, so wall clock is the longest build plus verify plus publish: 18 plus 6 plus 4, or 28 minutes from tag push to release. Now price the version where the publish job rebuilds. It repeats the four build jobs, which adds $2.29 and brings the release to $4.62, and it adds the longest build to the critical path, which brings wall clock to 46 minutes. At 8 tags a month, that is $18.62 against $36.93, and 144 minutes of extra waiting for a release that ships untested bytes. Two legs of the matrix carry the highest rates, so they are where the label choice moves the invoice most. GitHub per minute prices below are from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Leg | GitHub-hosted runner and rate | WarpBuild label and rate | Leg on GitHub-hosted | Leg on WarpBuild | | ------------------------- | ------------------------------------------------------ | ---------------------------------------------------- | -------------------- | ---------------- | | macOS build, 18 minutes | Largest macOS ARM64 runner, 5 vCPU and 14 GB, $0.102 | warp-macos-latest-arm64-6x, 6 vCPU and 14 GB, $0.08 | $1.84 | $1.44 | | Windows build, 16 minutes | 8-core Windows larger runner, 8 vCPU and 32 GB, $0.042 | warp-windows-latest-x64-8x, 8 vCPU and 32 GB, $0.032 | $0.67 | $0.51 | `warp-macos-latest-arm64-6x` at 6 vCPU and 14 GB costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14 GB, which is 22 percent lower list price at one more vCPU and the same memory. `warp-windows-latest-x64-8x` at 8 vCPU and 32 GB costs $0.032 per minute against $0.042 per minute for the 8-core Windows larger runner at the same shape, which is 24 percent lower list price. GitHub list prices checked on 2026-08-13. Release tags are rarely the whole story. A team cutting three release candidates per shipped version pays the tag cost four times, or $9.31 per version on the model above, which is the number to compare against a [nightly build](/guides/nightly-builds-github-actions) that catches the same failures before the candidate exists. Full rates by runner type are on the [WarpBuild pricing page](/pricing). For the credential side of the publish job, see [how to publish a package from GitHub Actions](/answers/how-do-i-publish-a-package-from-github-actions). ## [FAQ](#faq)Copy Anchor Link **Should a release workflow trigger on a tag push or on a release event?** Trigger the build on the tag push, because the tag is the first durable record of the version and it exists before any release object does. Use the `release: published` event for downstream consumers such as a package index or a documentation deploy. One caveat applies to both: a release or tag created with the built-in `GITHUB_TOKEN` does not start another workflow run, so a downstream workflow needs a separate credential or an explicit `workflow_dispatch` call. **Can the publish job rebuild the artifact instead of downloading it?** It can, and then the bytes users download are not the bytes the verify job checked. A rebuild also pays the build matrix a second time. On the model above, one release costs $2.33 in runner minutes when the publish job downloads, and $4.62 when it rebuilds, with 18 more minutes of wall clock before the release appears. **How does one publish job collect artifacts from a build matrix?** Give every matrix cell a distinct artifact name, because artifacts created by `actions/upload-artifact` v4 are immutable and two cells writing one name fail. Then download them in a single step with `pattern: dist-*` and `merge-multiple: true`, which unpacks every matching artifact into one directory. --- # Remote Docker Builders for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/remote-docker-builders Description: A remote Docker builder runs docker build on a dedicated VM with a persistent layer cache outside your GitHub Actions runner. Setup, YAML, and costs. # Remote Docker Builders for GitHub Actions A remote Docker builder runs docker build on a dedicated VM with a persistent layer cache outside your GitHub Actions runner. Setup, YAML, and costs. Last verified: 2026-08-13 A remote Docker builder runs `docker build` on a dedicated machine outside the GitHub Actions runner, with a layer cache that stays on that machine's local disk between jobs. WarpBuild remote Docker builders are created as a builder profile in the dashboard and selected from a workflow with one `profile-name` input on `Warpbuilds/build-push-action`. This guide covers the four cases where a remote builder is the right answer, the setup walkthrough, workflow YAML for both WarpBuild runners and GitHub-hosted runners, and the session billing and sizing arithmetic you need before you roll it out. ## [Diagnosis](#diagnosis)Copy Anchor Link A remote builder changes where the image build happens and where its cache lives. That helps with four specific problems and does nothing for the rest of your workflow, so start by checking which of these your job logs show. ### [Image builds pinned to runner CPU](#image-builds-pinned-to-runner-cpu)Copy Anchor Link When `docker build` runs inside the job, the build gets whatever vCPU the runner has. BuildKit parallelizes across independent stages, so a Dockerfile with four independent stages has four stages competing for the same 4 vCPU that also has to run checkout, the test steps, and the push. The usual reaction is to move the whole job to a bigger runner. That works, and it charges the bigger rate for every minute of the job. On the WarpBuild catalog, `warp-ubuntu-latest-x64-4x` is $0.008 per minute and `warp-ubuntu-latest-x64-32x` is $0.064 per minute. A 45 minute job that spends 8 minutes in `docker build` pays $2.88 on the 32x label against $0.36 on the 4x label, and the extra 28 vCPU sit idle for 37 of those 45 minutes. A builder profile breaks that coupling. The size of the Docker machine has no relation to the size of the GitHub Actions runner; the builder profile you select decides it. So the job keeps a small runner for checkout, tests, and the push, and the image build lands on a machine sized for image builds. ### [Cache that cannot outlive an ephemeral runner](#cache-that-cannot-outlive-an-ephemeral-runner)Copy Anchor Link Cloud runner storage is ephemeral and is deleted when the runner terminates. Any layer cache built during a job disappears with the machine unless the job exports it somewhere. That is what `cache-to: type=gha` does: it serializes the layers, uploads them to a cache backend, and the next run downloads and imports them before the build starts. The export and import are real work in the job, and they grow with image size. A builder profile corresponds to one dedicated builder VM with caching attached to it. Layers stay on that VM's local disk, so the next build reuses them in place with no export and no import step. The builder cache has a TTL of 10 days, and a profile that goes unused for more than 10 days is reset automatically. Two details matter for correctness. The cache is shared across builds on a profile but is eventually consistent, so layers written by one concurrent build may not be visible to another build running at the same time and will be visible to later builds after synchronization. And you can force a clean state per profile through the API when a bad layer gets pinned. ### [Multi-architecture builds](#multi-architecture-builds)Copy Anchor Link Building `linux/arm64` from an x64 runner without a native arm64 machine means QEMU emulation for every RUN instruction in the arm64 leg. Compile-heavy images feel that on every build. Builder profiles handle multi-architecture builds for amd64 and arm64 out of the box. Each architecture runs on a separate builder instance, so a multi-arch build produces one session per architecture rather than one emulated build. The profile has to have both architectures enabled in the dashboard; a profile with only amd64 enabled fails an arm64 build with `exec format error`. If you would rather split the matrix across native runners instead of one multi-arch profile, the alternative is a job per architecture on `warp-ubuntu-latest-x64-4x` and `warp-ubuntu-latest-arm64-4x`, then a manifest merge step. [Multi-platform Docker builds for amd64 and arm64](/guides/multi-platform-docker-builds) walks through both shapes, and the [Linux ARM64 runner catalog and rates](/runners/linux-arm64) lists the native ARM64 labels and their per-minute prices. ### [Builds running outside GitHub Actions](#builds-running-outside-github-actions)Copy Anchor Link A cache that only exists inside GitHub Actions leaves local development and any non-GitHub pipeline rebuilding from scratch. Remote builders are reachable through the API, which covers local development and other CI platforms with the same profile and the same cache. ### [When to skip a remote builder](#when-to-skip-a-remote-builder)Copy Anchor Link Three cases where the added session cost buys you little. First, jobs where the image build is a small share of wall clock, such as a 20 minute test suite followed by a 90 second build of a thin runtime image. Second, workflows that need the built image on the runner filesystem for the very next step, because the image has to travel back over the network from the builder before that step can start. Third, images whose build time is dominated by the push to a registry rather than by the layers themselves. ## [Fix](#fix)Copy Anchor Link The setup is four steps and takes about ten minutes. The full reference lives in the [WarpBuild Docker Builders documentation](/docs/ci/docker-builders). **1\. Create a builder profile.** Open the [Docker Builders page](https://app.warpbuild.com/ci/docker-builders) in the dashboard and create a profile. Give it a name you will reference from YAML, such as `api-images`. Each builder profile corresponds to one dedicated Docker builder VM with caching, so treat the profile as a long-lived resource that accumulates a cache, and give distinct build workloads their own profiles when their layer sets do not overlap. **2\. Pick the size and the architecture set.** Size choices run from 16 vCPU, 32GB RAM, 100GB disk up to 192 vCPU, 384GB RAM, 2TB disk. Architecture is part of the profile: arm64 and multi-arch profiles support a maximum size of 64 vCPU, and the 96 vCPU and 192 vCPU sizes are amd64 only. Enable both architectures on the profile if any workflow will build multi-arch from it. **3\. Authenticate.** The workflow talks to the builder over secure TLS authentication, with the CA and client certificates issued per builder session. On WarpBuild runners the action picks up credentials automatically and no API key is needed. Everywhere else, create an API key from the [API keys page](https://app.warpbuild.com/settings/api-keys) with the `ci` and `cache` scopes, store it as a repository or organization secret named `WARPBUILD_API_KEY`, and pass it through the `api-key` input. The [automation API documentation](/docs/ci/api-keys/automation) covers key creation and the surrounding endpoints. **4\. Reference the profile from the action.** `Warpbuilds/build-push-action` is a drop-in replacement for `docker/build-push-action` that sets up the remote builder for you. Remove the `docker/setup-buildx-action` step if it exists only to set up a builder. Copy Text ``` - - name: Setup Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push - uses: docker/build-push-action@v6 + - name: Build and push + uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: user/app:latest - cache-from: type=gha - cache-to: type=gha,mode=max + profile-name: "api-images" ``` At run time the action requests a builder assignment for that profile, waits for the builder to report ready, receives the host address plus the CA, client certificate, and client key, creates a buildx node with the remote driver pointed at the builder over TLS, and runs the build there. The post step completes the builder session. Billing runs until the session is completed, which is why the session is closed for you rather than left to the job. The `timeout` input controls how long the action waits for the builder to be ready, in milliseconds, and defaults to 10 minutes for `build-push-action`. ## [Configuration](#configuration)Copy Anchor Link ### [On a WarpBuild runner](#on-a-warpbuild-runner)Copy Anchor Link No API key is required when the job runs on a WarpBuild runner. Copy Text ``` name: build-image on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . file: ./Dockerfile push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} profile-name: "api-images" ``` ### [On a GitHub-hosted runner](#on-a-github-hosted-runner)Copy Anchor Link The same builder profile works from a runner WarpBuild does not operate. The only additions are the `api-key` input and an explicit `timeout`. Copy Text ``` jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} profile-name: "api-images" api-key: ${{ secrets.WARPBUILD_API_KEY }} timeout: 600000 ``` ### [Multi-architecture from one profile](#multi-architecture-from-one-profile)Copy Anchor Link Set `platforms` on the action and make sure the profile has both architectures enabled. Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Build and push multi-arch uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ghcr.io/${{ github.repository }}:${{ github.sha }} profile-name: "api-images-multi" ``` ### [Custom build steps](#custom-build-steps)Copy Anchor Link When the build needs steps that `build-push-action` does not cover, `Warpbuilds/docker-configure` sets the builder up and outputs its connection details for your own commands to use. Invoke it immediately before the step that runs the build, because builders have a built-in idle timeout that exists so that idle builders are not charged to you. Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - name: Configure WarpBuild Docker Builders uses: Warpbuilds/docker-configure@v1 with: profile-name: "api-images" timeout: 300000 - name: Build with custom flags run: | docker buildx build \ --build-arg GIT_SHA=${{ github.sha }} \ --secret id=npmrc,src=$HOME/.npmrc \ --push -t ghcr.io/${{ github.repository }}:${{ github.sha }} . ``` `Warpbuilds/bake-action` is the equivalent drop-in for `docker/bake-action` and takes the same `profile-name`, `api-key`, and `timeout` inputs. ### [Resetting a profile cache](#resetting-a-profile-cache)Copy Anchor Link A profile that has accumulated a bad layer set can be reset through the API rather than by deleting and recreating the profile. Copy Text ``` curl -s -X POST \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ https://api.warpbuild.com/api/v1/builder-profiles/$BUILDER_PROFILE_ID/cache/reset ``` ### [Platform notes](#platform-notes)Copy Anchor Link Docker builds are driven from the Linux labels in that catalog; macOS runners do not support nested virtualization and cannot run Docker, so a macOS job cannot host a Docker build with or without a remote builder. If the image build is one step in a slower pipeline, [Docker builds on GitHub Actions with WarpBuild](/solutions/docker-builds-github-actions) covers how those pieces fit together, and [Docker layer caching on GitHub Actions](/guides/docker-layer-caching-github-actions) covers the Dockerfile ordering work that makes any cache pay off. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Two prices are in play. The runner is billed per minute at its catalog rate. The builder is billed per session, measured from when the builder action starts until the job completes. Both are charged, because the runner and the builder are two separate, independent resources. Multiple concurrent jobs that use the same builder profile share one session. That session is billed from the first job's start to the last job's completion, so a profile serving a fan-out of parallel builds is one billable stretch of time rather than one per job. ### [Builder profile sizes and concurrency](#builder-profile-sizes-and-concurrency)Copy Anchor Link Prices below are the WarpBuild Docker Builder rates. The concurrency column applies the documented recommended minimum of roughly 8 vCPU and 16GB memory per build job. There is no hard limit on concurrent builds per profile; this is the sizing guide, and exceeding it means builds share cores. | Profile size | Price per minute | Architectures | Concurrent builds at 8 vCPU and 16GB each | | ------------------------------- | ---------------- | ------------------- | ----------------------------------------- | | 16 vCPU, 32GB RAM, 100GB disk | $0.06 | amd64, arm64, multi | 2 | | 32 vCPU, 64GB RAM, 200GB disk | $0.12 | amd64, arm64, multi | 4 | | 64 vCPU, 128GB RAM, 200GB disk | $0.24 | amd64, arm64, multi | 8 | | 96 vCPU, 192GB RAM, 600GB disk | $0.36 | amd64 only | 12 | | 96 vCPU, 192GB RAM, 2TB disk | $0.52 | amd64 only | 12 | | 192 vCPU, 384GB RAM, 600GB disk | $0.72 | amd64 only | 24 | | 192 vCPU, 384GB RAM, 2TB disk | $0.88 | amd64 only | 24 | Disk is the second sizing input. A profile holding a persistent layer cache for several large images wants headroom above the sum of those image sizes, which is what the 600GB and 2TB variants are for. ### [Worked model: one service, one image per push](#worked-model-one-service-one-image-per-push)Copy Anchor Link Assumptions, which you should replace with numbers from your own job logs: the job runs 6 minutes end to end on `warp-ubuntu-latest-x64-4x`, of which the builder session covers 4 minutes, on a 16 vCPU profile. | Line item | Rate | Quantity | Cost | | -------------------------------- | ----------------- | --------- | ------ | | Runner warp-ubuntu-latest-x64-4x | $0.008 per minute | 6 minutes | $0.048 | | Builder session, 16 vCPU profile | $0.06 per minute | 4 minutes | $0.24 | | Total per build | $0.288 | | | | 1,000 builds per month | $288.00 | | | The runner line is where the GitHub list-price comparison applies: `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB), which is 33 percent lower list price. GitHub publishes its per-minute rates in the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and on the [GitHub pricing page](https://github.com/pricing); both were checked on 2026-08-13. ### [Worked model: monorepo fan-out](#worked-model-monorepo-fan-out)Copy Anchor Link Twelve services, each with its own image, all built in parallel on one pull request from the same profile. Twelve concurrent builds at 8 vCPU each is 96 vCPU, so the 96 vCPU, 192GB profile at $0.36 per minute is the size that matches the sizing rule. Assume the runner jobs are 5 minutes each and the shared session spans 9 minutes from the first builder start to the last job completion. | Line item | Rate | Quantity | Cost | | --------------------------- | ----------------- | ---------- | ----- | | Runner jobs, 12 x 5 minutes | $0.008 per minute | 60 minutes | $0.48 | | One shared builder session | $0.36 per minute | 9 minutes | $3.24 | | Total per pull request | $3.72 | | | | 200 pull requests per month | $744.00 | | | The alternative is a smaller profile with the builds queued behind each other. A 32 vCPU profile at $0.12 per minute sizes for four concurrent builds, so the twelve builds run in three waves and the single shared session runs longer. If that session runs 22 minutes, it costs $2.64 against $3.24, and the pull request waits longer. Rate times session length is the whole trade, and the session length is the number you take from your own logs. ### [Worked model: multi-architecture](#worked-model-multi-architecture)Copy Anchor Link Multi-arch builders create two sessions on the same builder profile, one per architecture, and those sessions are billed independently. Each session stays alive until its post-action steps complete. On a 16 vCPU multi-arch profile at $0.06 per minute with a 5 minute build per architecture, that is 2 sessions x 5 minutes x $0.06 = $0.60 per build, plus the runner minutes. Budget multi-arch as double the single-arch session cost before you compare it against a matrix of native per-architecture jobs. ### [Reading the bill](#reading-the-bill)Copy Anchor Link Docker Builder usage has its own tab under Reports, separate from GitHub Actions runner and cache usage. It shows per-session cost data: a daily stacked bar chart grouped by profile or architecture, summary cards for total cost and total sessions, and a table where each row is a single builder session with its profile, architecture, duration, and cost. Filter by profile and architecture to find the profile that is running long sessions for short builds, which is the signature of a builder that is being held open by a step placed after the build. Nothing about this needs a plan change. ## [FAQ](#faq)Copy Anchor Link ### [Do I pay for both the GitHub Actions runner and the Docker builder?](#do-i-pay-for-both-the-github-actions-runner-and-the-docker-builder)Copy Anchor Link Yes. The runner and the builder are two separate resources and both are billed. The runner is billed per minute at its catalog rate and the builder is billed per session, measured from when the builder action starts until the job completes. ### [How many concurrent builds fit on one builder profile?](#how-many-concurrent-builds-fit-on-one-builder-profile)Copy Anchor Link There is no limit on the number of builds that can run concurrently on a builder profile. The documented recommended minimum is roughly 8 vCPU and 16GB memory per build job, so a 32 vCPU, 64GB profile sizes cleanly for about four concurrent builds. ### [Do I still need cache-from and cache-to with a remote builder?](#do-i-still-need-cache-from-and-cache-to-with-a-remote-builder)Copy Anchor Link No. With WarpBuild Docker Builders the cache-to and cache-from options are not required. A cached builder keeps the layers on its own disk and reuses them for later builds. The builder cache has a TTL of 10 days and resets if the profile goes unused for longer. ### [Can I use a remote Docker builder from a GitHub-hosted runner?](#can-i-use-a-remote-docker-builder-from-a-github-hosted-runner)Copy Anchor Link Yes. The builder works with both WarpBuild runners and non-WarpBuild runners. On a runner that WarpBuild does not operate, pass an API key through the `api-key` input so the action can request a builder assignment. --- # Re-run Storms and What They Cost | WarpBuild URL: https://www.warpbuild.com/guides/rerun-storms-github-actions Description: A re-run storm bills the same commit twice or three times over. Price the re-run rate against monthly minutes, then cut it with cancellation and retries. # Re-run Storms and What They Cost A re-run storm bills the same commit twice or three times over. Price the re-run rate against monthly minutes, then cut it with cancellation and retries. Last verified: 2026-08-13 A re-run storm is the pattern where a large share of your GitHub Actions minutes goes to running commits that already ran, and its cost is the re-run rate multiplied by the billed minutes per re-run and the per-minute rate. On 700 first-attempt runs a month of a 6-job matrix that bills 54 minutes per run, a 25 percent re-run rate adds 9,450 billed minutes, which is $151.20 at the `warp-ubuntu-latest-x64-8x` rate of $0.016 per minute from the [WarpBuild pricing page](/pricing), checked on 2026-08-13. This guide covers the three causes worth separating, the measurement that turns a complaint into a rate, the fixes in the order that pays best, and a model that prices each fix against monthly minutes. ## [Diagnosis](#diagnosis)Copy Anchor Link Measure the rate before changing a workflow, because the three causes take different fixes and two of them look identical in a job log. The re-run rate is the count of workflow run attempts beyond the first, divided by the count of first attempts, over a fixed window. GitHub's workflow runs REST API returns a `run_attempt` field on every run, so counting the runs where `run_attempt` is greater than 1 gives the numerator directly ([GitHub REST API for workflow runs](https://docs.github.com/en/rest/actions/workflow-runs)). The billed side comes from the CI billing tab in [WarpBuild Reports](/docs/ci/features/reports), which is one row per job execution carrying repository, job name, runner label, stack, execution time, billed time, and cost, and exports to CSV with every row matching the current filters. ### [The three causes](#the-three-causes)Copy Anchor Link | Cause | What you see | What confirms it | | -------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Flaky tests | The same commit goes red then green with no code change, and the failing test name moves between attempts | Success rate under 98 percent on the Jobs report row with CPU P90 and memory P90 both low | | Transient infrastructure failure | Registry 429 or 502, DNS failure, a checkout that times out, a package download that resets | Several unrelated jobs go red inside the same ten minute window, then everything recovers | | Required checks that re-run the whole matrix | One shard fails and every job in the matrix runs again on attempt 2 | The job list on attempt 2 has the same length as attempt 1 | **Flaky tests** produce re-runs one commit at a time and are the cause most teams name first. The Jobs section of Reports aggregates per unique repository, workflow, and job name and carries run count, success rate, duration percentiles, queue time percentiles, and CPU and memory percentiles, which is enough to sort the unstable jobs from the merely failing ones. **Transient infrastructure failures** cluster in time rather than by test. They are cheap to absorb with a retry on the one step that touches the network and expensive to absorb by pressing re-run on the whole workflow. **Required checks that re-run the whole matrix** are the multiplier on both of the others. GitHub offers re-running a full workflow run, re-running all failed jobs, or re-running a specific job, each available for up to 30 days after the initial run ([re-running workflows and jobs](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs)). A team in the habit of pressing the first option pays the width of the matrix on every flake. ## [Fix](#fix)Copy Anchor Link Apply these in order. The first cuts baseline minutes, the second and third cut the re-run rate. **1\. Cancel superseded runs.** A `concurrency` group keyed on the workflow and the ref with `cancel-in-progress: true` stops the previous attempt as soon as a new commit lands on the same pull request ([workflow syntax for concurrency](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency)). This is the largest single reduction on a busy repository because a developer pushing three fixup commits in ten minutes otherwise pays for three full matrices. Cancelled jobs bill for the time they ran, which is covered in [how a cancelled GitHub Actions job is billed](/answers/how-is-a-cancelled-github-actions-job-billed). **2\. Retry the step, not the workflow.** A network install, a registry login, or an artifact download is worth two or three attempts inside the step itself, with a wait between them. Retrying at that level costs seconds and keeps the other five shards from running a second time. See [how to retry a failed step in GitHub Actions](/answers/how-do-i-retry-a-failed-step-in-github-actions) for the step-level and job-level options. **3\. Quarantine known-flaky tests.** Move the tests with a documented history of instability into a list the gate skips and a separate job runs with `continue-on-error: true`. The gate stops going red for a known reason, the quarantined set stays visible, and the diagnosis work continues in [finding and fixing flaky GitHub Actions jobs](/guides/flaky-github-actions-jobs). ## [Configuration](#configuration)Copy Anchor Link Copy Text ``` name: pr-gate on: pull_request: concurrency: group: pr-gate-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: test: runs-on: warp-ubuntu-latest-x64-8x timeout-minutes: 20 strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Install dependencies uses: nick-fields/retry@v3 with: max_attempts: 3 retry_wait_seconds: 15 timeout_minutes: 10 command: npm ci - name: Run shard run: npm test -- --shard=${{ matrix.shard }}/6 --testPathIgnorePatterns "$(paste -sd '|' - < quarantine.txt)" quarantined: runs-on: warp-ubuntu-latest-x64-2x timeout-minutes: 20 continue-on-error: true steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test -- --testPathPattern "$(paste -sd '|' - < quarantine.txt)" ``` Guarding `cancel-in-progress` on the event name keeps pushes to the default branch running to completion while pull request attempts get cancelled, which is what you want when the default branch feeds deployments. The `fail-fast: false` line looks like it raises the bill and lowers it in practice. With `fail-fast: true` the first red shard cancels its five siblings, so the run reports one failure and nothing about the other five, and the next step is a full re-run of the matrix. With it set to false every shard reports, and one press of re-run failed jobs settles the run. Sizes and labels for the `runs-on` line are in the [cloud runners catalog](/docs/ci/cloud-runners). A matrix spanning platforms prices each row from the same table. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Take one pull request gate: a 6-job matrix, 9 billed minutes per job, so 54 billed minutes per run, at 700 first-attempt runs a month on `warp-ubuntu-latest-x64-8x`. Baseline is 700 x 54 = 37,800 minutes, which is $604.80 at $0.016 per minute. | Re-run rate | Full-matrix re-runs per month | Extra billed minutes | Extra cost per month | | ----------- | ----------------------------- | -------------------- | -------------------- | | 5 percent | 35 | 1,890 | $30.24 | | 15 percent | 105 | 5,670 | $90.72 | | 25 percent | 175 | 9,450 | $151.20 | | 40 percent | 280 | 15,120 | $241.92 | The arithmetic on the 25 percent row: 700 x 0.25 = 175 re-runs, 175 x 54 = 9,450 minutes, 9,450 x $0.016 = $151.20. Now price the three fixes against that starting point of 47,250 billed minutes, or $756.00 a month. **Cancellation.** Assume 210 of the 700 runs are superseded by a newer push. Running to completion they bill 210 x 54 = 11,340 minutes, or $181.44\. Cancelled after about 2 minutes per job they bill 210 x 12 = 2,520 minutes, or $40.32\. The difference is 8,820 minutes and $141.12. **Targeted re-runs.** Re-running only the failed shard bills 9 minutes rather than 54\. The same 175 re-runs cost 175 x 9 = 1,575 minutes, or $25.20, against $151.20\. The difference is $126.00. **Quarantine.** Moving the known-flaky set out of the gate takes the rate from 25 percent to 15 percent, so 105 targeted re-runs bill 945 minutes, or $15.12. All three together: 37,800 minus 8,820 plus 945 = 29,925 billed minutes, or $478.80 a month, against $756.00\. That is 17,325 minutes and $277.20 a month, and the engineer-time column moves further: 175 re-runs at 9 minutes of waiting plus 5 minutes of context switching is 40.8 hours a month, and 105 of them is 24.5 hours. The rate itself is the reason the arithmetic is worth doing twice a year. `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price ([GitHub Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). Applied to the 47,250 minute month above, the same workload sits at $1,039.50 on the GitHub-hosted size. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so every minute a re-run stops consuming leaves the invoice at the next billing period rather than at the next contract date. Signup includes $10 free credits, which covers a first month of measurement on one workflow, and full rates are on the [pricing page](/pricing). For the per-pull-request view that this model rolls into, see [GitHub Actions cost per pull request](/guides/ci-cost-per-pull-request). For the flake work that lowers the rate, start at [finding and fixing flaky GitHub Actions jobs](/guides/flaky-github-actions-jobs). ## [FAQ](#faq)Copy Anchor Link **How do I calculate what re-runs cost per month?** Multiply first-attempt runs by the re-run rate, multiply that by the billed minutes a re-run consumes, and multiply again by the per-minute rate. On 700 first-attempt runs of a 6-job matrix billing 54 minutes per run, a 25 percent re-run rate is 175 re-runs, 9,450 billed minutes, and $151.20 at the `warp-ubuntu-latest-x64-8x` rate of $0.016 per minute, checked on 2026-08-13. **Does re-running failed jobs cost less than re-running all jobs?** Yes, by the width of the matrix. Re-run failed jobs re-runs only the failed jobs and their dependents, so one failed shard out of six bills 9 minutes instead of the 54 that a full re-run bills. GitHub allows both up to 30 days after the initial run. **Do cancelled jobs still bill minutes?** They bill for the time they actually ran before the cancellation landed, so `cancel-in-progress` converts a superseded 9 minute job into roughly a 2 minute one rather than into nothing. That is still the largest single reduction available on a busy pull request. --- # Running Reusable Workflows Across Many Repositories | WarpBuild URL: https://www.warpbuild.com/guides/reusable-workflows-at-scale-github-actions Description: A shared workflow holds up across fifty repositories on three things: a tagged version contract, a runner label input per caller, and a staged rollout. # Running Reusable Workflows Across Many Repositories A shared workflow holds up across fifty repositories on three things: a tagged version contract, a runner label input per caller, and a staged rollout. One reusable workflow can serve fifty repositories, and what decides whether it holds up is the contract around the file: a tagged reference every caller pins, an input for anything a repository needs to vary, and a rollout that reaches repositories in waves rather than all at once. The shared YAML is the small part of the job, and the version contract and the rollout order are what keep one edit from turning fifty pipelines red. This guide covers taking an inventory of callers before changing anything, the compatibility rules that decide a version bump, a called workflow that takes its runner label as an input so each repository picks its own size, and a cost model for fifty repositories running one shared file. ## [Diagnosis](#diagnosis)Copy Anchor Link Start with an inventory, because the blast radius of the next change is the number of repositories on each ref. One search lists every caller and the ref it pins: Copy Text ``` gh search code --owner acme \ "uses: acme/ci-workflows/.github/workflows/service-build.yml" \ --limit 200 --json repository,path,textMatches ``` Group the results by the string after the `@`. A fleet split across `@main`, `@v2`, and three patch tags behaves differently under one push than a fleet where every caller pins a full version, and the table below is the usual set of shapes. | Symptom | Underlying cause | What it costs | | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | Every caller pins @main | The shared repository carries no tags, so the branch is the only ref available | One push edits fifty pipelines, and the revert lands after the failures do | | Several repositories carry near identical copies of the file | Someone needed a different runner size or one extra step and forked instead of adding an input | Fixes land in one copy while the others drift | | A caller sets runs-on and the workflow is rejected | A calling job may only use name, uses, with, secrets, strategy, needs, if, concurrency, and permissions | The repository forks the shared file to get a bigger machine | | One merge on the shared repository broke every caller | A new input shipped as required rather than with a default | Fifty red pipelines from one commit | | Required checks disappeared after an upgrade | The check name comes from the caller's job id plus the called workflow's job name, so a job rename renames the check | Branch protection blocks merges until each repository's settings are edited | | Callers stopped resolving after a repository rename | GitHub supports no redirects for reusable workflows | Every caller fails until its uses line is edited by hand | Rows three, five, and six come from the [GitHub reusable workflow reference](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations), checked on 2026-08-13. Two structural facts sit behind all six. Runner assignment and billing stay with the caller, so the shared file cannot hand a repository a machine it does not have access to; the mechanics of that are in [how to reuse a workflow across repositories](/answers/how-do-i-reuse-a-workflow-across-repositories). And nothing about a caller's ref is enforced centrally, so the version discipline has to live in the shared repository's release process and in each caller's update path. ## [Fix](#fix)Copy Anchor Link ### [Write the version contract down](#write-the-version-contract-down)Copy Anchor Link The public interface of a reusable workflow is wider than the input list. It covers the inputs, the secrets, the outputs, the job names that branch protection matches, and the permissions the file needs from a caller. Treat a change to any of those as a version event, tag every change on the shared repository, and have callers pin the full version. | Change to the shared file | Version bump | What each caller does | | ------------------------------------------------- | ------------ | ---------------------------------------------------- | | Add an input that carries a default | Minor | Nothing | | Add an optional secret | Minor | Nothing | | Add an output | Minor | Nothing | | Add a step that runs inside the existing jobs | Minor | Nothing, once the canary wave is green | | Change the default value of an existing input | Major | Review the new default before bumping | | Add a required input | Major | Edit the with block | | Remove or rename an input | Major | Edit the with block | | Rename a job inside the shared file | Major | Update the required check names in branch protection | | Require a permission the file did not need before | Major | Edit the permissions block | The deprecation path is what turns a major bump from an outage into a schedule. Accept both the old and the new spelling for two minor releases, warn on the old one, and delete it at the next major: Copy Text ``` - name: Deprecation notice if: ${{ inputs.node_version != '' }} run: | echo "::warning title=Deprecated input::node_version is replaced by node-version and is removed in v4" ``` Publish `v3.4.1` style tags for callers that want a fixed target, and keep a moving `v3` for teams that accept patches automatically. A changelog in the shared repository listing the bump type per release is what makes a Dependabot pull request reviewable in thirty seconds. ### [Let each repository choose its runner size](#let-each-repository-choose-its-runner-size)Copy Anchor Link A calling job cannot set `runs-on`, so a repository that needs a bigger machine either accepts the library default or forks the file. Declaring a `runner-label` input and pointing `runs-on` at it gives the choice back, and an empty default plus a fallback expression keeps callers that say nothing on the library default. Rates below come from the [cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing). GitHub rates come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | runner-label value | Shape | Per minute | Nearest GitHub-hosted runner | Per minute | | --------------------------- | --------------- | ---------- | ------------------------------------- | ---------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | ubuntu-latest on a private repository | $0.006 | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | the 4-core Linux larger runner | $0.012 | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | the 8-core Linux larger runner | $0.022 | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | the 16-core Linux larger runner | $0.042 | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064 | the 32-core Linux larger runner | $0.082 | | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | the 4-core Linux ARM64 larger runner | $0.008 | One shared library can build a Go service on a Linux label and an iOS app on a macOS label with the same version contract. Which labels an organization should standardize on is covered in [a runner label strategy for a growing org](/guides/runner-labels-strategy-github-actions). The label a caller passes is worth routing through a variable rather than a literal. A repository variable overrides an organization variable of the same name, and an unset variable resolves to the empty string ([GitHub variables reference](https://docs.github.com/en/actions/reference/workflows-and-actions/variables), checked on 2026-08-13). Setting `CI_RUNNER_LABEL` once at the organization level and overriding it on the four repositories that need more cores gives one central knob and a per repository escape hatch, with no edit to the shared file. ### [Stage the rollout](#stage-the-rollout)Copy Anchor Link A pinned tag means a change reaches a repository only when that repository merges a bump, which is what makes waves possible. | Wave | Repositories | Advance when | | --------- | ------------------------------------------- | ------------------------------------------------- | | 0, canary | 1 high traffic repository | 20 green runs and no new warning annotations | | 1 | 5, spread across languages and runner sizes | 48 hours with no new failures | | 2 | 20 | 48 hours, and the p95 job duration matches wave 1 | | 3 | The remaining 24 | Steady state | Dependabot raises the bump pull requests for the `github-actions` ecosystem, which covers reusable workflow refs alongside action refs. Group the shared library into one pull request per repository and cap the open count so a wave does not flood reviewers ([Dependabot options reference](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference), checked on 2026-08-13). Stage the waves by enabling the schedule for wave 1 repositories after the canary passes rather than by editing fifty files at once. When the shared repository needs to kick off work in other repositories instead of waiting for their next push, that is a separate mechanism; see [triggering workflows across repositories](/guides/cross-repository-workflows-github-actions). ## [Configuration](#configuration)Copy Anchor Link The called workflow carries the interface, the fallback label, and a fail-fast check that a caller pinned the major version it thinks it pinned. Copy Text ``` # acme/ci-workflows/.github/workflows/service-build.yml name: service-build on: workflow_call: inputs: runner-label: description: "Runner label for the build job. Empty falls back to the library default." type: string default: "" go-version: type: string default: "1.24" run-integration-tests: type: boolean default: true contract-version: description: "Major version the caller expects." type: string default: "3" secrets: REGISTRY_TOKEN: required: false outputs: image-digest: description: Digest of the image this run pushed value: ${{ jobs.build.outputs.image-digest }} jobs: build: runs-on: ${{ inputs.runner-label != '' && inputs.runner-label || 'warp-ubuntu-latest-x64-4x' }} outputs: image-digest: ${{ steps.push.outputs.digest }} steps: - name: Check the contract version if: ${{ inputs.contract-version != '3' }} run: | echo "::error title=Contract mismatch::caller expects v${{ inputs.contract-version }}, this file is v3" exit 1 - uses: actions/checkout@v5 - uses: actions/setup-go@v5 with: go-version: ${{ inputs.go-version }} cache: true - run: go build ./... - run: go test ./... - if: ${{ inputs.run-integration-tests }} run: go test -tags=integration ./... - uses: Warpbuilds/build-push-action@v6 id: push with: context: . push: true profile-name: service-builder tags: ghcr.io/acme/${{ github.event.repository.name }}:${{ github.sha }} ``` Each caller is the same nine lines with a different label source and a pinned tag. Copy Text ``` # acme/checkout-service/.github/workflows/ci.yml name: ci on: push: branches: [main] pull_request: jobs: build: uses: acme/ci-workflows/.github/workflows/service-build.yml@v3.4.1 permissions: contents: read packages: write with: runner-label: ${{ vars.CI_RUNNER_LABEL }} contract-version: "3" secrets: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} ``` The bump path is a Dependabot config committed once per calling repository. Copy Text ``` # .github/dependabot.yml version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly day: tuesday groups: ci-workflows: patterns: - "acme/ci-workflows*" open-pull-requests-limit: 3 ``` Four details carry the behavior. `runner-label` defaults to the empty string so the fallback expression in `runs-on` decides, which keeps the library default in one file. `contract-version` turns a caller pinned to the wrong major into a one second failure with a readable message instead of a confusing input error. The caller reads `vars.CI_RUNNER_LABEL`, so moving a repository to a larger machine is a settings change rather than a pull request. And the Dependabot group means one review per repository per wave, whatever the shared library released that week. Where the shared library also pins a custom runner image, the image build and the runner operations behind it can be driven from the API ([automation documentation](/docs/ci/api-keys/automation)), which keeps the image version and the workflow version moving on the same release. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The interesting cost lever in a shared library is the default label, because it multiplies by every caller that never overrides it. ### [Assumptions](#assumptions)Copy Anchor Link | Input | Value | Source | | ---------------------------------------- | --------------------------- | -------------------------------------------- | | Repositories calling the shared workflow | 50 | Your caller inventory | | Runs per repository per month | 120 | Your workflow run history | | Total runs per month | 6,000 | 50 times 120 | | Job duration on the 4 vCPU label | 7.0 minutes | Your run logs | | Runner rates | $0.004 to $0.064 per minute | [pricing page](/pricing), checked 2026-08-13 | ### [What the default label costs across the fleet](#what-the-default-label-costs-across-the-fleet)Copy Anchor Link Job durations below are placeholders to replace with your own run logs, and they flatten as cores grow because checkout and image push are network bound. | Default label | vCPU | Minutes per run | Rate per minute | Fleet cost per month | | -------------------------- | ---- | --------------- | --------------- | -------------------- | | warp-ubuntu-latest-x64-2x | 2 | 11.4 | $0.004 | $273.60 | | warp-ubuntu-latest-x64-4x | 4 | 7.0 | $0.008 | $336.00 | | warp-ubuntu-latest-x64-8x | 8 | 5.1 | $0.016 | $489.60 | | warp-ubuntu-latest-x64-16x | 16 | 4.3 | $0.032 | $825.60 | Moving the library default from the 4 vCPU label to the 8 vCPU label costs $153.60 a month at this volume and takes 1.9 minutes off every run in the fleet, including the forty repositories whose builds were already fast enough. The per caller override is the cheaper shape: leave the default at 4 vCPU and let the ten repositories that need it pass the 8 vCPU label. That fleet costs $366.72 a month, which is $30.72 above the flat 4 vCPU fleet and $122.88 below the flat 8 vCPU fleet, and it still removes 2,280 minutes of wall clock a month from the ten repositories that were waiting. The same 42,000 minutes on the 4 vCPU default bill at $336.00, against $504.00 at GitHub's published $0.012 per minute for the 4-core Linux larger runner ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). Every figure here carries its rate, its source, and a checked-on date, and the durations are yours to substitute. ### [How long a change takes to reach fifty repositories](#how-long-a-change-takes-to-reach-fifty-repositories)Copy Anchor Link | Rollout style | Repositories changed on the next run | Time to reach all 50 | Rollback | | -------------------------------------------------- | ------------------------------------ | ------------------------------------ | --------------------------------------------------------- | | Callers pin @main | 50 | One push | Revert the shared repository, then wait for reruns | | Callers pin a moving @v3 | 50 | One tag move | Move the tag back | | Callers pin @v3.4.1, Dependabot weekly, four waves | 1 | About 9 days at the wave gates above | Revert one line in one repository, and stop the next wave | The nine days is the cost of the staged version, and the thing it buys is that a bad release is a single red pull request rather than fifty broken pipelines and an org-wide message. For a library that changes weekly, that trade usually pays for itself the first time a step regresses. Ordering this work against the other levers in a pipeline is covered in the guide to [speeding up GitHub Actions](/guides/speed-up-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [How do I change a shared workflow without breaking fifty repositories?](#how-do-i-change-a-shared-workflow-without-breaking-fifty-repositories)Copy Anchor Link Tag every change on the shared repository, have callers pin the full version, and move repositories in waves. A pinned tag reaches nobody until a caller merges a bump, so a bad change fails one pull request in one repository and the rollback is one line. The [reusable workflow glossary entry](/glossary/reusable-workflow) covers the reference syntax on its own. ### [What counts as a breaking change to a reusable workflow?](#what-counts-as-a-breaking-change-to-a-reusable-workflow)Copy Anchor Link Anything a caller has to edit. A new required input, a removed or renamed input, a changed default that changes behavior, a job rename that renames the required status check, and any permission the file starts needing all belong in a major version. Additive changes ship with a default and go out as a minor version, which is what lets Dependabot merge them on a schedule. ### [How do repositories with different build sizes share one workflow?](#how-do-repositories-with-different-build-sizes-share-one-workflow)Copy Anchor Link Declare a `runner-label` input and point `runs-on` at it, since a calling job cannot set `runs-on` itself. Each caller passes the label it wants, an empty value falls back to the library default in the shared file, and rates run from $0.004 to $0.064 per minute across the Linux x64 sizes on the [pricing page](/pricing). --- # A Runner Label Strategy for a Growing Org | WarpBuild URL: https://www.warpbuild.com/guides/runner-labels-strategy-github-actions Description: Map every workload class to one runner label, pass that label into workflows as an input, and migrate one class at a time. Catalog rows, YAML, and arithmetic. # A Runner Label Strategy for a Growing Org Map every workload class to one runner label, pass that label into workflows as an input, and migrate one class at a time. Catalog rows, YAML, and arithmetic. Last verified: 2026-08-13 A runner label strategy is a fixed map from workload class to runner label, held in one place and referenced by every workflow, so changing the size of a class is one edit rather than one pull request per repository. The map holds four to six entries on Linux plus one entry per other platform, and every entry names a class of work such as lint or integration tests rather than a repository, a team, or a machine. This guide covers the symptoms of label sprawl and how to tell them apart, the rules that keep a map small, the class map with catalog rows and rates, workflow YAML that takes the label as an input, the migration path from stock labels to `warp-` labels one class at a time, and the arithmetic that prices the whole scheme. For the plain definition of the term see the [runner label entry](/glossary/runner-label). ## [Diagnosis](#diagnosis)Copy Anchor Link Label sprawl arrives in two opposite shapes, and both start the same way: someone copies a `runs-on` line from a workflow that already worked. **One label everywhere.** The first workflow in the organization needed 16 vCPU for a compile step, so every workflow written afterwards asks for 16 vCPU. A lint job that runs a single-threaded formatter for 40 seconds holds 16 vCPU while it does it. Nothing fails, so nothing surfaces the problem except the invoice. **A different label in every repository.** Sixty repositories each pinned a size, an OS version, and sometimes an alias by hand. Raising one class from 4 vCPU to 8 vCPU now costs sixty pull requests across sixty codeowners, so it never happens and the sizes stay wrong in whichever direction they were first written. Both shapes produce the same downstream problems. | Symptom | Cause | Where to look | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | Small jobs bill at large-runner rates | One label reused across every workload class | runs-on lines grouped by job name across the org | | A size change stalls for a quarter | The label is hardcoded in every workflow | Count of files containing runs-on: per repository | | Two lines in per-label reporting for one machine | An alias and its canonical label both in use, such as warp-ubuntu-2404-x64-4x beside warp-ubuntu-latest-x64-4x | The aliases column of the [cloud runners catalog](/docs/ci/cloud-runners) | | A pinned OS label outlives its reason | warp-ubuntu-2204-x64-4x chosen for one dependency that was removed two years ago | Version-pinned labels with no comment explaining the pin | | A job queues and never starts after a label edit | No online runner advertises the label, or the runner group excludes the repository | [Common issues](/docs/ci/common-issues) and [runner groups](/guides/github-actions-runner-groups) | | Cost per team is unanswerable | Labels encode hardware and nothing about the work | Whether any label maps to a workload class | The last row is the one that compounds. A label that says `16x` answers what the machine is. It cannot answer what the machine was doing, which is the question finance asks and the question that decides whether the size was right. ## [Fix](#fix)Copy Anchor Link Six rules produce a map small enough to hold in one page and stable enough to survive a year of new repositories. **One label per workload class.** Classes are defined by their resource shape and their tolerance for wall clock, so lint, unit tests, integration tests, and compile-and-package are usually four distinct classes and everything else folds into one of them. **Keep the class name outside the label.** The catalog string is the label. The class name lives in the variable that holds the string, such as `RUNNER_UNIT` or `RUNNER_BUILD`. Workflows reference the variable, so the class name is the stable interface and the label underneath is free to change. **One spelling per class.** Every size has an alias in the catalog, and a fleet running both spellings reads as two fleets in per-label reporting. Pick the canonical label, write it in the map, and treat any alias found in a workflow as a defect. **Pin the OS only where the toolchain needs it.** Use the `latest` form by default. When a pin is required, write the reason next to the map entry, because a pin with no stated reason will be inherited forever. **Change the size in one place.** If raising a class means editing more than one file, the map is not yet the source of truth. **Review the map against duration data every quarter.** Sizes are chosen from job durations, and job durations move as the codebase grows. [Right sizing GitHub Actions runners](/guides/runner-right-sizing-github-actions) covers how to read the per-job numbers that set each entry. ### [The class map](#the-class-map)Copy Anchor Link This map covers all four platforms in seven entries, with shapes and per-minute rates from the [cloud runners catalog](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13. | Class | Variable | Label | vCPU | RAM | Rate per minute | | --------------------- | ------------------- | --------------------------- | ---- | ----- | --------------- | | Lint and small checks | RUNNER\_LINT | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | Unit tests | RUNNER\_UNIT | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | Integration tests | RUNNER\_INTEGRATION | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | Compile and package | RUNNER\_BUILD | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | ARM64 packaging | RUNNER\_ARM | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | | iOS and macOS | RUNNER\_MACOS | warp-macos-15-arm64-6x | 6 | 14 GB | $0.08 | | Windows | RUNNER\_WINDOWS | warp-windows-latest-x64-4x | 4 | 16 GB | $0.016 | Seven entries is a ceiling worth defending. Every entry is a thing to review each quarter, and a map with twenty entries goes unreviewed, which returns the organization to sprawl with extra steps. ## [Configuration](#configuration)Copy Anchor Link ### [Pass the label rather than writing it](#pass-the-label-rather-than-writing-it)Copy Anchor Link GitHub's [contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts) lists `vars` and `inputs` among the contexts available to `jobs..runs-on` (checked on 2026-08-13), so both halves of the scheme work with stock workflow syntax. Organization variables hold the map ([variables reference](https://docs.github.com/en/actions/reference/workflows-and-actions/variables)), and a reusable workflow takes the label as a `workflow_call` input ([reusing workflow configurations](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations)). The reusable workflow lives once, in `my-org/.github/.github/workflows/test.yaml`: Copy Text ``` name: test on: workflow_call: inputs: runner: description: Runner label from the organization class map type: string default: warp-ubuntu-latest-x64-4x jobs: test: runs-on: ${{ inputs.runner }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test ``` Each repository calls it and names a class rather than a size: Copy Text ``` name: pr on: pull_request jobs: lint: uses: my-org/.github/.github/workflows/test.yaml@v1 with: runner: ${{ vars.RUNNER_LINT }} unit: uses: my-org/.github/.github/workflows/test.yaml@v1 with: runner: ${{ vars.RUNNER_UNIT }} integration: strategy: matrix: shard: [1, 2, 3, 4] runs-on: ${{ vars.RUNNER_INTEGRATION }} steps: - uses: actions/checkout@v4 - run: ./scripts/integration.sh --shard=${{ matrix.shard }}/4 build: needs: [unit, integration] runs-on: ${{ vars.RUNNER_BUILD }} steps: - uses: actions/checkout@v4 - run: ./scripts/build.sh ``` Raising the unit test class across all sixty repositories is now one edit to `RUNNER_UNIT` in organization settings. [Running reusable workflows across many repositories](/guides/reusable-workflows-at-scale-github-actions) covers the release tagging that keeps `@v1` meaningful once the shared workflow starts changing. One failure mode is worth knowing before you rely on this. A variable that does not exist evaluates to an empty string, and an empty string passed through `with` overrides the default in the called workflow, so the job arrives with no label to match and the run fails at that job. Set every variable in the map at the organization level and let a repository override it only when a real exception exists. Labels also stop at the runner group boundary. A correct label in a repository the group excludes still queues forever, which reads as a label bug and is not one. [Runner groups](/guides/github-actions-runner-groups) and [common issues](/docs/ci/common-issues) cover that check. ### [Migrating one class at a time](#migrating-one-class-at-a-time)Copy Anchor Link Rank the classes by billed minutes per month, then move the largest one first, because that is where a wrong size costs the most and where a week of observation tells you the most. 1. **Route the class through a variable while it still points at the stock label.** Set `RUNNER_UNIT` to `ubuntu-latest` and ship the workflow edits. Nothing changes about where jobs run, so this step is reviewable on its own. 2. **Flip the variable to the `warp-` label.** One edit in organization settings moves every repository in the class at once. 3. **Hold for a week.** Watch the class for tooling gaps rather than for speed. Two documented disparities catch teams out: Ubuntu 24.04 ARM64 runners set the work dir to `/runner/_work` where GitHub uses `/home/runner/work/`, and macOS runners cannot run Docker or nested virtualization. 4. **Take the next class.** Repeat until every class points at a catalog label. The revert path is the same single edit, which is what makes the ordering safe. A class that misbehaves goes back to its stock label in seconds and the remaining classes stay migrated. | Class | Stock label today | Class map target | Shape | | --------------------- | --------------------------- | --------------------------- | -------------- | | Lint and small checks | ubuntu-latest | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | | Unit tests | ubuntu-latest | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | | Integration tests | 8-core Linux larger runner | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | | Compile and package | 16-core Linux larger runner | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | | ARM64 packaging | ubuntu-24.04-arm | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | | iOS and macOS | macos-latest | warp-macos-15-arm64-6x | 6 vCPU, 14 GB | | Windows | windows-latest | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Every Linux x64 size costs the same per vCPU minute](#every-linux-x64-size-costs-the-same-per-vcpu-minute)Copy Anchor Link Divide each catalog rate by its vCPU count: $0.004 divided by 2, $0.008 divided by 4, $0.016 divided by 8, $0.032 divided by 16, and $0.064 divided by 32 all come to $0.002 per vCPU minute. Linux ARM64 works out at $0.0015 per vCPU minute, since $0.012 divided by 8 gives that figure. That flat curve is what makes the class map worth building. Size is free for a job that uses every core it is given, because doubling the cores halves the minutes at the same per vCPU rate. Size is pure waste for a job that cannot use them, because the minutes stay flat while the rate doubles. The class map exists to keep the second kind of job off the large label. ### [Pricing a gate before and after](#pricing-a-gate-before-and-after)Copy Anchor Link Take a repository whose pull request gate runs four jobs, at 2,000 runs per month across the organization. Everything on one 16 vCPU label: * lint 1.5 minutes, unit tests 4 minutes, integration 9 minutes, compile 12 minutes, all at $0.032 per minute. * 26.5 minutes multiplied by $0.032 is $0.848 per run. * 2,000 runs multiplied by $0.848 is $1,696 per month. The same gate on the class map, with the smaller jobs taking longer on smaller machines: * lint 1.5 minutes at $0.004 is $0.006. * unit tests 6 minutes at $0.008 is $0.048. * integration 10 minutes at $0.016 is $0.16. * compile 12 minutes at $0.032 is $0.384. * $0.598 per run, and 2,000 runs multiplied by $0.598 is $1,196 per month. The gate takes $500 per month less and finishes at the same time, because the four jobs run in parallel and the 12 minute compile job sets the wall clock in both cases. Shrinking the jobs that were never on the critical path is the part of right sizing that costs nothing in feedback time. ### [The list-price arithmetic behind two classes](#the-list-price-arithmetic-behind-two-classes)Copy Anchor Link warp-ubuntu-latest-x64-2x (2 vCPU, 8 GB) costs $0.004 per minute against $0.006 per minute for GitHub-hosted ubuntu-latest (2 vCPU, 8 GB on private repositories): 33 percent lower list price. GitHub list price checked on 2026-08-13. warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13\. Both GitHub figures come from the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Every cost figure on this page carries its number, its source, and the date it was checked, and the same numbers appear on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link ### [How many runner labels should a growing organization standardize on?](#how-many-runner-labels-should-a-growing-organization-standardize-on)Copy Anchor Link One label per workload class rather than one per repository or per team. Four Linux x64 classes cover most organizations (lint, unit tests, integration tests, compile and package), plus one ARM64 class and one class per other platform. Seven entries is a workable ceiling, because each entry has to be reviewed against duration data every quarter and a map nobody reviews drifts back into sprawl. ### [Should the runner label be hardcoded in each workflow or passed in?](#should-the-runner-label-be-hardcoded-in-each-workflow-or-passed-in)Copy Anchor Link Pass it in. GitHub's contexts reference lists `vars` and `inputs` among the contexts available to `jobs..runs-on`, so a job can read an organization variable directly or take the label as a `workflow_call` input. Keep a literal default on the reusable workflow input so a caller that omits the value still starts, and set the variable at the organization level so a repository that never overrides it inherits the class map. ### [How do I move from GitHub-hosted labels to warp- labels without a big-bang change?](#how-do-i-move-from-github-hosted-labels-to-warp--labels-without-a-big-bang-change)Copy Anchor Link One workload class at a time, ordered by billed minutes. Route the class through a variable first, change the variable, hold for a week, then take the next class. Because the label lives in one place, the revert is the same single edit as the migration, so no repository needs a pull request either way. --- # Right Sizing GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/guides/runner-right-sizing-github-actions Description: GitHub Actions reports job duration and nothing about the machine. Right size runners from peak CPU, peak memory and the per job recommendation endpoint. # Right Sizing GitHub Actions Runners GitHub Actions reports job duration and nothing about the machine. Right size runners from peak CPU, peak memory and the per job recommendation endpoint. Last verified: 2026-08-13 Right sizing a GitHub Actions runner means matching the vCPU and memory of the label to the peak the job actually reaches, measured across many runs rather than one. On WarpBuild that comes from two inputs: a per job recommendation endpoint that returns a suggested label for every job in the last 7 days that should move, and the underlying utilization metrics, which let you redo the arithmetic before you edit a workflow file. This guide covers the signals that separate an undersized job from an oversized one, the request and response shape of the recommendation endpoint, the manual method from peak CPU, peak memory and the duration profile, and a worked monthly model for a fleet where the qualifying job families move one size down. ## [Diagnosis](#diagnosis)Copy Anchor Link GitHub Actions reports how long a job took. It reports nothing about how hard the machine worked while the job ran, so duration alone cannot separate a job that needs more cores from a job that spends its minutes waiting on a network call. WarpBuild agents collect CPU, memory, filesystem, and network utilization from every runner they operate. The [observability documentation](/docs/ci/features/observability) defines each metric and the threshold at which the Recommendations view flags an instance as under provisioned. | Metric | What it records | Threshold that raises a flag | | -------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | | Max sustained CPU | Maximum rolling average CPU over the last 30 seconds | 80 percent or above, labelled High CPU Usage | | Max memory utilization | Maximum memory usage percentage | 80 percent or above, labelled High Memory Usage | | Max filesystem utilization | Maximum storage usage percentage | 80 percent or above, labelled High Filesystem Usage | | Max disk I/O | Maximum rolling average of read plus write throughput over the last 30 seconds | 80 percent or above of supported throughput, labelled High Disk IO | Three readings cover most jobs. Peak CPU near 100 percent with memory headroom is a job that returns wall clock time for cores. Peak memory near 100 percent with CPU in the middle of the range is a memory bound job, and adding cores at the same ratio changes nothing. Both metrics in the low tens for the whole run is an oversized job paying for capacity it never touches, which is where a downgrade lives. Two collection limits shape how you read the numbers. Metrics are collected only for jobs longer than about one minute, so short jobs carry no utilization history and stay at whatever label they were given. And a single run is not evidence: a cold cache, a retry, or one unusually large pull request moves a peak far enough to mislead. Aggregate across runs and read the P90, which is what the Jobs report and the recommendation endpoint both do. [Runner level metrics for GitHub Actions jobs](/guides/github-actions-runner-metrics) covers those reports field by field, and [runner utilization](/glossary/runner-utilization) defines the underlying term. Queue time is a separate axis. A job that waits before it starts is not a sizing problem, and moving it up a size makes the bill worse without touching the wait. [How do I detect an undersized runner](/answers/how-do-i-detect-an-undersized-runner) separates the two signals. ## [Fix](#fix)Copy Anchor Link Two paths lead to the same decision. Start with the endpoint, then check the arithmetic by hand on the jobs where the money is. ### [Read the recommendation endpoint](#read-the-recommendation-endpoint)Copy Anchor Link Create a key with the `ci` scope on the [API keys page](https://app.warpbuild.com/settings/api-keys), as described in the [automation documentation](/docs/ci/api-keys/automation), then call the recommendations endpoint. Copy Text ``` curl -sS -G 'https://api.warpbuild.com/api/v1/org_metrics/job_runner_recommendations' \ -H 'Authorization: Bearer wkey-xxxx' \ -H 'Accept: application/json' \ --data-urlencode 'search=integration' \ --data-urlencode 'page=1' \ --data-urlencode 'per_page=200' ``` The query parameters are `search` (text across repository, workflow name, and job name), `repo_workflows` (repeatable, each value a repository and workflow name joined by a newline), `account_id` (a GitHub org or user id, keeping only repositories that account owns), `page` (1-based, default 1), and `per_page` (default 50, maximum 200). The response returns one entry per job that should move. Jobs already on the right size are absent, so an empty `recommendations` array is the healthy result. Copy Text ``` { "organization_id": "worg-xxxx", "page": 1, "per_page": 200, "total_pages": 1, "total_rows": 12, "recommendations": [ { "repository": "acme/platform", "workflow_name": "ci.yml", "job_name": "unit-tests", "workflow_url": "https://github.com/acme/platform/actions/workflows/ci.yml", "total_runs": 412, "recommendation": { "type": "downgrade", "current_label": "warp-ubuntu-latest-x64-16x", "current_vcpu": 16, "current_memory_gb": 64, "recommended_label": "warp-ubuntu-latest-x64-8x", "recommended_vcpu": 8, "recommended_memory_gb": 32, "resources": ["cpu", "memory"], "already_at_max_size": false }, "runs": [ { "runner_instance_id": "wrun-xxxx", "runner_display_name": "warp-ubuntu-latest-x64-16x", "started_at": "2026-08-11T09:14:02Z", "ended_at": "2026-08-11T09:21:36Z", "max_sustained_cpu": 31.4, "max_memory_utilization": 27.8, "max_filesystem_utilization": 18.2, "avg_filesystem_utilization": 15.6, "max_sustained_disk_io_bytes": 41943040, "max_sustained_network_bytes": 10485760, "job_link": "https://github.com/acme/platform/actions/runs/1234567890/job/9876543210" } ] } ], "available_filters": { "repos": [] } } ``` Three fields carry the decision. `recommendation.type` is `upgrade` or `downgrade`. `recommendation.resources` is the subset of `cpu`, `memory`, and `disk` that is hot on an upgrade or underused on a downgrade, which tells you whether cores or RAM drove the call. And `already_at_max_size` is set when the job is on the largest runner and still needs more, which is a signal to shard the work rather than buy a wider machine. The endpoint is marked alpha in the API reference, so pin the fields you parse and re-check them at each verification pass. ### [Do the arithmetic yourself](#do-the-arithmetic-yourself)Copy Anchor Link Utilization arrives as a percentage of the machine, so one multiplication turns it into an absolute figure that transfers between labels. Take a job on `warp-ubuntu-latest-x64-16x`, which carries 16 vCPU and 64 GB, with max sustained CPU at 34 percent and max memory utilization at 30 percent, both read at P90 across a month of runs. Copy Text ``` 0.34 x 16 vCPU = 5.4 vCPU held 0.30 x 64 GB = 19.2 GB held on warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB): 5.4 / 8 = 68 percent CPU 19.2 / 32 = 60 percent memory ``` Both projections stay under the 80 percent flag threshold, so the smaller label fits with headroom. Apply the same projection in the other direction and the memory floor becomes obvious: a job peaking at 62 percent of 64 GB holds 39.7 GB, which is above the 32 GB of the 8 vCPU size, so 16 vCPU is its floor on this ladder. The Linux ratio is fixed at 4 GB per vCPU across every size, so a memory bound job buys cores it does not need on the way to the RAM it does. | Runner label | vCPU | RAM | Rate per minute | | -------------------------- | ---- | ------ | --------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 | Rates come from the [pricing page](/pricing) and are billed per minute. Each step down the ladder halves the rate and halves both resources, which is why the projection above is the whole decision for a Linux x64 job. The same two multiplications apply on each once you read the vCPU and memory figures for the label. CPU projections carry one caveat. A CPU bound job stretches roughly in proportion to the cores you remove, while a job dominated by package installs, network calls, or a database barely moves. Treat the projection as a candidate and confirm it with a week of runs at both labels. ## [Configuration](#configuration)Copy Anchor Link Set the label from the measurement, and run the recommendation check on a schedule so drift shows up as a diff rather than a surprise on the invoice. Copy Text ``` name: ci on: pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - run: make test integration-tests: runs-on: warp-ubuntu-latest-x64-16x steps: - uses: actions/checkout@v4 - run: make integration ``` To compare two sizes with evidence instead of argument, run the same job at both for a week and read duration at P90 from the Jobs report: Copy Text ``` jobs: size-trial: strategy: fail-fast: false matrix: label: - warp-ubuntu-latest-x64-8x - warp-ubuntu-latest-x64-16x runs-on: ${{ matrix.label }} steps: - uses: actions/checkout@v4 - run: make test ``` The weekly drift check turns the endpoint into a report your team reads without opening a dashboard: Copy Text ``` name: runner-right-sizing on: schedule: - cron: "0 8 * * 1" workflow_dispatch: jobs: recommendations: runs-on: warp-ubuntu-latest-x64-2x steps: - name: Fetch recommendations env: WARPBUILD_API_KEY: ${{ secrets.WARPBUILD_API_KEY }} run: | curl -sS -G 'https://api.warpbuild.com/api/v1/org_metrics/job_runner_recommendations' \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ -H 'Accept: application/json' \ --data-urlencode 'per_page=200' \ > recs.json echo "| Repository | Job | Move | From | To |" >> $GITHUB_STEP_SUMMARY echo "| --- | --- | --- | --- | --- |" >> $GITHUB_STEP_SUMMARY jq -r '.recommendations[] | "| \(.repository) | \(.job_name) | \(.recommendation.type) | \(.recommendation.current_label) | \(.recommendation.recommended_label) |"' \ recs.json >> $GITHUB_STEP_SUMMARY ``` ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Substitute your own numbers. This fleet runs four job families on Linux x64 for a month, and the recommendation endpoint returns a downgrade for two of them. | Job family | Label | Jobs per month | Billed minutes per job | Minutes | Rate | Cost | | ------------------ | -------------------------- | -------------- | ---------------------- | ------- | ------ | --------- | | Unit tests | warp-ubuntu-latest-x64-16x | 6,000 | 7 | 42,000 | $0.032 | $1,344.00 | | Lint and typecheck | warp-ubuntu-latest-x64-8x | 9,000 | 3 | 27,000 | $0.016 | $432.00 | | Integration tests | warp-ubuntu-latest-x64-16x | 1,200 | 22 | 26,400 | $0.032 | $844.80 | | Package build | warp-ubuntu-latest-x64-4x | 2,000 | 9 | 18,000 | $0.008 | $144.00 | | Total | 18,200 | 113,400 | $2,764.80 | | | | Unit tests peak at 34 percent CPU and 30 percent memory, and lint peaks at 39 percent CPU and 22 percent memory, so both project under 80 percent one size down. Integration tests peak at 91 percent CPU and stay where they are. The package build peaks at 74 percent of 16 GB, which is 11.8 GB, above the 8 GB of the 2 vCPU size, so it stays too. | Job family | New label | Minutes | Rate | Cost | | ------------------ | -------------------------- | --------- | ------ | ------- | | Unit tests | warp-ubuntu-latest-x64-8x | 42,000 | $0.016 | $672.00 | | Lint and typecheck | warp-ubuntu-latest-x64-4x | 27,000 | $0.008 | $216.00 | | Integration tests | warp-ubuntu-latest-x64-16x | 26,400 | $0.032 | $844.80 | | Package build | warp-ubuntu-latest-x64-4x | 18,000 | $0.008 | $144.00 | | Total | 113,400 | $1,876.80 | | | That is $888.00 per month against $2,764.80, or 32 percent of the runner line, from two label edits and no workflow rewrites. The model assumes duration holds at the smaller size. Rerun it with the pessimistic case: if both moved families take 10 percent longer, their minutes become 46,200 and 29,700, costing $739.20 and $237.60, for a monthly total of $1,965.60 and a reduction of $799.20\. Either way the direction holds, and a week of matrix runs replaces the assumption with a measurement. The remaining lever is the rate itself on the jobs that keep their size. `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price, GitHub list price checked on 2026-08-13 in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The 26,400 integration minutes cost $844.80 here against $1,108.80 at that list price for identical machine shapes. Two structural notes for the budget. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so a label change moves the bill by nothing more than the minute arithmetic above, and per repository or per label attribution of the result is covered in [how do I see cost per runner label](/answers/how-do-i-see-cost-per-runner-label). Signup includes $10 free credits, which covers a week of one job family at the new label before you commit the change. The metrics this guide reads come from CI observability. For the estate-wide view that these per job decisions roll up into, see [GitHub Actions observability](/solutions/github-actions-observability). ## [FAQ](#faq)Copy Anchor Link ### [How do I know whether a GitHub Actions runner is oversized?](#how-do-i-know-whether-a-github-actions-runner-is-oversized)Copy Anchor Link Read peak CPU and peak memory across many runs rather than one, then project both onto the next size down. A job on `warp-ubuntu-latest-x64-16x` peaking at 34 percent CPU and 30 percent memory is holding 5.4 vCPU and 19.2 GB, which lands at 68 percent CPU and 60 percent memory on `warp-ubuntu-latest-x64-8x`. Both stay under the 80 percent threshold that WarpBuild observability uses to flag an instance, so the smaller label fits. ### [What does the WarpBuild runner recommendation endpoint return?](#what-does-the-warpbuild-runner-recommendation-endpoint-return)Copy Anchor Link One entry per job in the organization's last 7 days that should move to a different runner size, plus a sample of that job's recent runs. Each entry carries the repository, workflow name, job name, total run count, and a recommendation object holding the type (upgrade or downgrade), the current label with its vCPU and memory, the recommended label with its vCPU and memory, the resources that drove the call, and an `already_at_max_size` flag. The endpoint is marked alpha in the API reference, so pin the fields you parse. ### [Will moving down a size make my jobs slower?](#will-moving-down-a-size-make-my-jobs-slower)Copy Anchor Link It depends on what the job waits for. A CPU bound job stretches roughly in proportion to the cores you remove, while a job that spends its time on network calls, package installs, or a database barely moves. Run both labels through a matrix for a week and compare duration at P90 before you delete the larger one, and rerun the cost model with the observed duration rather than the assumed one. --- # Rust Incremental Compilation in GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/rust-incremental-compilation-github-actions Description: Cargo incremental state lives in target and dies with the runner. Set CARGO_INCREMENTAL=0 on stateless GitHub Actions jobs, or keep the target directory warm. # Rust Incremental Compilation in GitHub Actions Cargo incremental state lives in target and dies with the runner. Set CARGO\_INCREMENTAL=0 on stateless GitHub Actions jobs, or keep the target directory warm. Rust incremental compilation rarely fires on GitHub Actions because cargo keeps its incremental state in the target directory, and every job starts on a clean VM that has neither that directory nor the file mtimes cargo fingerprints against. Two configurations work: set `CARGO_INCREMENTAL=0` and cache compiled dependencies instead, or run the job on a machine that already holds the target directory from the previous run. This guide covers the flags that control incremental compilation, when each configuration pays, a workflow that pairs an incremental profile with a persistent target directory, and a time model with its assumptions written down. ## [Diagnosis](#diagnosis)Copy Anchor Link Incremental compilation writes a dependency graph and a set of cached query results into `target//incremental/`, one session directory per crate. On the next compile, rustc reloads that graph and recompiles only the query results that the edit invalidated. Cargo enables it for workspace members and path dependencies only, so crates pulled from a registry compile without it regardless of your settings ([Cargo profile reference](https://doc.rust-lang.org/cargo/reference/profiles.html#incremental)). Three things break that on a stateless runner. The directory is deleted with the machine. Nothing reloads, so rustc starts a fresh session and compiles the crate from scratch. Restoring the directory from a cache action does not bring it back to life. Cargo decides whether a path package is fresh by comparing recorded mtimes against the files on disk, and `actions/checkout` writes a new working tree whose mtimes are all newer than the artifacts you restored. Cargo marks the crate dirty and recompiles it, discarding the incremental session it just spent minutes downloading. The [answer on why cargo rebuilds everything](/answers/why-does-cargo-rebuild-everything-in-github-actions) works through that fingerprint check. The data is also the most expensive part of the transfer. Incremental sessions commonly account for more of a debug target directory than the compiled artifacts do, and both the upload and the download are billed as runner minutes. This is why the standard Rust caching actions set `CARGO_INCREMENTAL` to 0 and exclude `target/*/incremental` from what they save. | Control | Default | Scope | | ------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | CARGO\_INCREMENTAL env var | unset | Wins over every profile setting when set to 0 or 1 ([Cargo environment variables](https://doc.rust-lang.org/cargo/reference/environment-variables.html)) | | \[profile.dev\] incremental | true | dev and test profiles | | \[profile.release\] incremental | false | release and bench profiles | | \--config profile.dev.incremental=true | none | One cargo invocation | | RUSTC\_WRAPPER set to [sccache](https://github.com/mozilla/sccache) | unset | sccache does not cache incrementally compiled crates, so the two mechanisms exclude each other | ## [Fix](#fix)Copy Anchor Link Pick the regime that matches where the target directory lives. On a stateless runner with a cache action, set `CARGO_INCREMENTAL=0`, cache `~/.cargo/registry`, `~/.cargo/git`, and the compiled artifacts under `target`, and skip `target/*/incremental`. The reuse you get is dependency compilation, which is the larger half of the wall clock on most workspaces, and the workspace crates recompile every run. Sizing that path is covered on the [Rust builds on GitHub Actions page](/solutions/rust-github-actions). The [guide to sccache on GitHub Actions](/guides/sccache-github-actions) covers the object-cache route for the same shape of job, and the [hub on persistent caches for GitHub Actions](/guides/persistent-caches-github-actions) compares the storage options behind both. To get real incremental compilation, keep the target directory on the machine. A [WarpBuild snapshot runner](/docs/ci/features/snapshot-runners) captures the runner VM mid-workflow, and later jobs boot from that image with the target directory, the checked-out tree, and its mtimes in place. Because `actions/checkout` reuses an existing repository whose remote matches, unchanged sources keep the mtimes the previous run left on them, and cargo finds most workspace crates fresh. The same pattern applied to C and C++ trees is in the [guide to incremental builds on GitHub Actions](/guides/incremental-builds-github-actions). Four rules keep that configuration honest. Set `CARGO_TARGET_DIR` to a fixed absolute path outside the workspace. Incremental artifacts record absolute paths, so the directory has to reappear at the same place on every boot, and a cleanup step that runs `git clean -ffdx` would otherwise delete it. Keep it out of `/tmp`. That directory is cleared on reboot and a snapshot boot is a reboot. Write snapshots from the default branch only, so a pull request cannot poison the image other pull requests boot from. Check the platform. Snapshots are supported on WarpBuild Cloud Ubuntu runners only. A snapshot label elsewhere is silently ignored, so the job runs with no warning and no change in duration. Snapshot runners sit alongside remote Docker builders, CI observability, an MCP server, and the Action Debugger in the product surface, and the CPU chart in observability is the fastest way to confirm a run went incremental. ## [Configuration](#configuration)Copy Anchor Link A CI profile that turns incremental on explicitly and trims debug info to line tables, which is where most of the link time goes on a debug build: Copy Text ``` [profile.ci] inherits = "dev" incremental = true debug = 1 ``` The workflow. Pushes to `main` boot from the base image and write the alias; pull requests boot from it. Copy Text ``` name: rust on: push: branches: [main] pull_request: env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: "1" CARGO_TARGET_DIR: /home/runner/cargo-target jobs: test: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=api-rust-main' }} steps: - name: Checkout code uses: actions/checkout@v5 - name: Restore cargo home uses: WarpBuilds/cache@v1 with: path: | ~/.cargo/registry ~/.cargo/git key: cargo-home-${{ hashFiles('**/Cargo.lock') }} restore-keys: cargo-home- - name: Build and test run: cargo test --workspace --profile ci - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: rm -rf $HOME/.ssh $HOME/.aws - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: api-rust-main fail-on-error: true wait-timeout-minutes: 45 ``` Both the env var and the profile setting say the same thing on purpose. `CARGO_INCREMENTAL` takes precedence, so a profile that reads `incremental = true` while the environment holds 0 compiles non-incrementally and gives no warning. The cargo home stays in a cache action rather than the image, documented on the [WarpBuild caching page](/docs/ci/features/caching), so a `Cargo.lock` bump invalidates one entry instead of the whole image. The cleanup step removes credentials but leaves the working tree, because the tree and its mtimes are the thing the image exists to carry. On a public repository anyone who can open a pull request can boot from that alias, so keep the pattern to private repositories or to trees you are willing to publish. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Assumptions, so you can substitute your own step timings: * A workspace of 28 first-party crates and 640 registry dependencies, with a 9.4 GB target directory at the `ci` profile, of which 5.3 GB is incremental session data. * Runner `warp-ubuntu-latest-x64-8x`, 8 vCPU and 32 GB, at $0.016 per minute from the [pricing page](/pricing). * Change size: a pull request touching 3 files across 2 workspace crates, with no dependency change. * 500 pull request runs per month against one alias. | Stage | Clean VM, cache action, CARGO\_INCREMENTAL=0 | Snapshot boot, warm target, CARGO\_INCREMENTAL=1 | | ----------------------------- | -------------------------------------------- | ------------------------------------------------ | | Boot | 0.25 min | 1.00 min | | Checkout | 0.35 min | 0.15 min | | Restore cargo home and target | 2.90 min | 0.00 min | | Fingerprint scan | 0.40 min | 0.35 min | | Compile workspace crates | 6.10 min | 1.20 min | | Link test binaries | 1.30 min | 0.90 min | | Save state, amortized | 1.10 min | 0.20 min | | Total per run | 12.40 min | 3.80 min | The compile row carries the difference. In the left column cargo recompiles all 28 workspace crates because checkout rewrote their mtimes; in the right column it recompiles the 2 the diff touched and reloads the dependency graph for the rest. Priced out across 500 runs: | Line | Clean VM plus cache action | Snapshot runner | | ----------------- | ------------------------------------ | ------------------------------------- | | Runner minutes | 6,200 at $0.016 = $99.20 | 1,900 at $0.016 = $30.40 | | Cache storage | 4.1 GB at $0.20 per GB-month = $0.82 | included in the cargo home entry | | Cache operations | 1,000 at $0.0001 = $0.10 | 1,000 at $0.0001 = $0.10 | | Snapshot restores | $0.00 | 500 at $0.04 = $20.00 | | Snapshot storage | $0.00 | 720 snapshot-hours at $0.025 = $18.00 | | Monthly total | $100.12 | $68.50 | The snapshot fees are flat at $38.00 per alias per month, so the trade improves with run count and with runner size. The same step timings on `warp-ubuntu-latest-x64-16x` at $0.032 per minute put the clean VM path at $199.32 and the snapshot path at $98.90, while at 60 runs per month the flat fees swallow the saving and `CARGO_INCREMENTAL=0` on a stateless runner is the cheaper answer. Against GitHub-hosted list prices for the same shapes, from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13: | Shape | GitHub-hosted per minute | WarpBuild label | WarpBuild per minute | | -------------- | ------------------------ | -------------------------- | -------------------- | | 8 vCPU, 32 GB | $0.022 | warp-ubuntu-latest-x64-8x | $0.016 | | 16 vCPU, 64 GB | $0.042 | warp-ubuntu-latest-x64-16x | $0.032 | At the 8 vCPU shape that is 27 percent lower list price, and the 6,200 clean VM minutes cost $136.40 on the GitHub-hosted larger runner against $99.20 on `warp-ubuntu-latest-x64-8x`. The model holds step timings identical across every column so that only the mechanism and the list price move. ## [FAQ](#faq)Copy Anchor Link ### [Should I set CARGO\_INCREMENTAL=0 in GitHub Actions?](#should-i-set-cargo%5Fincremental0-in-github-actions)Copy Anchor Link Yes on a stateless runner with a cache action. The incremental directory is the largest part of a target directory, and every byte of it is uploaded and downloaded as billed runner minutes, while cargo recompiles the workspace crates anyway once `actions/checkout` rewrites their mtimes. Set `CARGO_INCREMENTAL=1` only when the target directory stays on the machine between runs. ### [Does incremental compilation apply to my registry dependencies?](#does-incremental-compilation-apply-to-my-registry-dependencies)Copy Anchor Link No. Cargo passes the incremental flag for workspace members and path dependencies only, so crates from crates.io compile without it whatever `CARGO_INCREMENTAL` says. Reuse for those comes from caching the compiled artifacts in the target directory and the cargo home. ### [Can I use sccache and incremental compilation together?](#can-i-use-sccache-and-incremental-compilation-together)Copy Anchor Link No. sccache does not cache incrementally compiled crates, so a job that sets `RUSTC_WRAPPER` to sccache should keep `CARGO_INCREMENTAL` at 0\. Pick one mechanism per job. Use sccache for wide dependency graphs on stateless runners, or incremental compilation for a warm target directory on a persistent one. --- # Modeling S3 Transfer Costs for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/s3-transfer-costs-github-actions Description: S3 charges for GitHub Actions land on your AWS bill as artifact reads, fixture pulls, and deploy bundles. A worked monthly model with published AWS rates. # Modeling S3 Transfer Costs for GitHub Actions S3 charges for GitHub Actions land on your AWS bill as artifact reads, fixture pulls, and deploy bundles. A worked monthly model with published AWS rates. Last verified: 2026-08-13 ## [Diagnosis](#diagnosis)Copy Anchor Link S3 transfer costs on GitHub Actions come from the gigabytes your jobs read out of a bucket, and AWS prices those reads by where the runner sits rather than by how many jobs you run. A runner inside the same AWS Region as the bucket reads at $0.00 per GB, while a runner outside your account reads over the internet path at $0.09 per GB after the first 100 GB each month ([Amazon S3 pricing](https://aws.amazon.com/s3/pricing/), checked on 2026-08-13). Before you price anything, find the steps that actually move object storage bytes. Four of them cover most pipelines. ### [Artifact uploads at the end of the build job](#artifact-uploads-at-the-end-of-the-build-job)Copy Anchor Link Teams that outgrew `actions/upload-artifact` push build outputs into their own bucket: a compiled bundle, a container tarball, a set of compiled test binaries. Writes are the cheap direction. AWS charges $0.00 per GB for data transferred into S3 from the internet, so the upload shows up as request charges and as storage that keeps billing until something deletes it. ### [Artifact downloads in every downstream job](#artifact-downloads-in-every-downstream-job)Copy Anchor Link This is the line that grows without anyone deciding to grow it. One build job writes a 0.8 GB bundle, then three downstream jobs each read it back. The write was 0.8 GB and the reads were 2.4 GB, and adding a fourth downstream job adds another 0.8 GB to every run for the life of the workflow. ### [Dataset fixtures pulled by test jobs](#dataset-fixtures-pulled-by-test-jobs)Copy Anchor Link Integration suites that need seed data, recorded HTTP cassettes, or model weights pull them from a bucket at the start of each job. Fixtures are usually larger than build outputs and they change less often, which makes them the best candidate for sharding and for a content-addressed key that a runner-side cache can hold. ### [Deploy bundles pulled by release jobs](#deploy-bundles-pulled-by-release-jobs)Copy Anchor Link The release job reads the artifact one more time, often from a different workflow than the one that produced it. Deploy frequency drives this line, so it climbs whenever the team merges more often. The registry side of the same story is covered in [ECR pull costs on GitHub Actions](/guides/ecr-pull-costs-github-actions). ### [What AWS meters, and what it does not](#what-aws-meters-and-what-it-does-not)Copy Anchor Link Four separate meters run at once on a bucket that serves GitHub Actions, and they respond to different levers. Rates below are US East (N. Virginia) list prices from [Amazon S3 pricing](https://aws.amazon.com/s3/pricing/) and [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13. | Meter | Rate | What moves it | | --------------------------------------- | ---------------------------------------- | --------------------------------- | | S3 to EC2, same Region | $0.00 per GB | Runner placement | | Data transfer out to the internet | $0.09 per GB after 100 GB free per month | Runner placement | | Data transfer out to another AWS Region | $0.02 per GB | Bucket and stack Region drift | | Managed NAT data processing | $0.045 per GB, plus $0.045 per hour | Static IPs on runners | | GET and SELECT requests | $0.0004 per 1,000 | Number of objects read per job | | PUT, COPY, POST and LIST requests | $0.005 per 1,000 | Multipart part size, cache writes | | S3 Standard storage, first 50 TB | $0.023 per GB-month | Retention policy | To confirm the charge belongs to your workflows, open Cost Explorer, group by Usage Type, and filter to the account or tag that carries the runner fleet. Look for `DataTransfer-Out-Bytes`, `DataTransfer-Regional-Bytes`, `NatGateway-Bytes`, and the `Requests-Tier1` and `Requests-Tier2` usage types. Two signatures identify GitHub Actions as the customer of that line: the daily curve tracks merge volume rather than end-user traffic, and it collapses on weekends. ## [Fix](#fix)Copy Anchor Link Four levers, in the order that pays back fastest. ### [Read the object once per run](#read-the-object-once-per-run)Copy Anchor Link Fan-out is what turns a modest artifact into a large monthly number. Read the bundle once in a setup job, publish the key through job outputs, and let downstream jobs read only what differs. Shard fixtures by test group so each matrix leg pulls its own shard rather than the full dataset, and compress with `zstd` before upload so every future read moves fewer bytes. ### [Keep the read inside the Region](#keep-the-read-inside-the-region)Copy Anchor Link This is the structural fix and it is worth more than every workflow tweak combined. A runner in the same account and Region as the bucket reads at AWS's own published $0.00 per GB, so the largest line in the model below disappears. BYOC runs on AWS, GCP, and Azure, and the WarpBuild stack pins a Region at creation time, so the placement decision is made once. Terraform support exists for BYOC on AWS, which keeps that decision in the same module as the VPC and the bucket. Setup steps are in the guide to [running GitHub Actions runners in your own AWS account](/byoc/aws). Region drift is the failure mode to watch. A bucket in `us-east-1` read by runners in `us-west-2` is billed as transfer out to another Region at $0.02 per GB, which is quieter than the internet path and still adds up. ### [Route S3 traffic inside the VPC](#route-s3-traffic-inside-the-vpc)Copy Anchor Link The [BYOC AWS configuration docs](/docs/ci/byoc/aws/config) call for routing S3 traffic inside the VPC so runners reach the bucket without incurring data transfer charges, and for the bucket to live in the same Region as the stack. AWS documents this private route to S3 as available at no additional charge, checked on 2026-08-13\. Without it, a runner in a private subnet reaches S3 through managed NAT and pays data processing on every gigabyte in both directions. That is also the reason to leave static IPs off. Enabling them places runners in private subnets behind managed NAT, and the [BYOC documentation](/docs/ci/byoc) is explicit that this adds data transfer charges on everything a job moves. Turn static IPs on only for the workflows that need an allowlisted address. ### [Bound retention](#bound-retention)Copy Anchor Link On BYOC, the cache and the runner telemetry live in an object storage bucket in your stack Region. Cache objects land under `//artifact_cache/////` and telemetry under `/runner/logs/all/`, and the docs recommend a lifecycle policy with 7 days retention on both. Without one, storage compounds every month while the transfer line stays flat, and the storage meter quietly overtakes it after a year. Teams that want this line removed entirely qualify for the WarpBuild enterprise tier, where runners pull large artifacts from S3, ECR, and similar stores at zero egress cost during deploys, on both BYOC and WarpBuild-hosted runners; scope is on [zero egress on the enterprise tier](/enterprise/zero-egress). ## [Configuration](#configuration)Copy Anchor Link ### [A pipeline that reads each object once](#a-pipeline-that-reads-each-object-once)Copy Anchor Link The build job uploads a single compressed bundle and publishes its key. Each test leg reads that key once and pulls only its own fixture shard. The `runs-on` values are the only lines that differ from a GitHub-hosted setup. Copy Text ``` name: build-test-deploy on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x permissions: id-token: write contents: read outputs: bundle-key: ${{ steps.pack.outputs.key }} steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-ci aws-region: us-east-1 - run: make build - id: pack run: | key="builds/${{ github.sha }}/app.tar.zst" tar --zstd -cf app.tar.zst dist aws s3 cp app.tar.zst "s3://acme-ci-artifacts/${key}" echo "key=${key}" >> "$GITHUB_OUTPUT" test: needs: build runs-on: warp-ubuntu-latest-x64-4x strategy: matrix: shard: [1, 2] steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-ci aws-region: us-east-1 - run: aws s3 cp "s3://acme-ci-artifacts/${{ needs.build.outputs.bundle-key }}" app.tar.zst - run: aws s3 cp "s3://acme-ci-fixtures/shards/${{ matrix.shard }}/" fixtures/ --recursive - run: ./scripts/test.sh --shard ${{ matrix.shard }} ``` ### [Move the deploy job into the account that holds the bucket](#move-the-deploy-job-into-the-account-that-holds-the-bucket)Copy Anchor Link BYOC runners register with a `warp-custom-` prefix followed by the runner name you chose in the dashboard. Pointing the job at a runner in a stack whose Region matches the bucket is what converts a metered read into same-Region traffic. Copy Text ``` deploy: needs: test runs-on: warp-custom-ci-use1 ``` ### [The lifecycle policy the docs recommend](#the-lifecycle-policy-the-docs-recommend)Copy Anchor Link Apply 7 days retention to both documented prefixes, and abort incomplete multipart uploads so failed artifact writes stop billing as storage. Copy Text ``` { "Rules": [ { "ID": "expire-artifact-cache", "Status": "Enabled", "Filter": { "Prefix": "org_01hq9m4k/artifact_cache/" }, "Expiration": { "Days": 7 }, "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 } }, { "ID": "expire-runner-telemetry", "Status": "Enabled", "Filter": { "Prefix": "runner/logs/all/" }, "Expiration": { "Days": 7 } } ] } ``` Copy Text ``` aws s3api put-bucket-lifecycle-configuration \ --bucket warpbuild-ci-use1 \ --lifecycle-configuration file://lifecycle.json aws ec2 create-vpc-endpoint \ --vpc-id vpc-0abc123 \ --vpc-endpoint-type Gateway \ --service-name com.amazonaws.us-east-1.s3 \ --route-table-ids rtb-0def456 rtb-0ghi789 ``` ### [Runner labels and rates used above](#runner-labels-and-rates-used-above)Copy Anchor Link The Linux x64 labels in the workflow bill at the per-minute rates on the [pricing page](/pricing): | Runner label | OS | vCPU | RAM | Storage | Price per minute | | -------------------------- | ------------ | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.064 | BYOC Linux runners carry a $0.002 per minute WarpBuild fee with the compute billed by your own cloud account, and the cache and networking add-ons are included in that rate. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Assumptions](#assumptions)Copy Anchor Link Every input is stated so you can substitute your own. AWS rates are US East (N. Virginia) list prices. | Input | Value | Source | | -------------------------------- | ------------------------------ | --------------------------------------------------------- | | Workflow runs per weekday | 60 | Your workflow run history | | Weekdays per month | 22 | Calendar | | Bundle written per run | 0.8 GB | Your bucket metrics | | Bundle read by downstream jobs | 3 reads, 2.4 GB per run | Your workflow graph | | Fixture shards read by test jobs | 2 reads, 2.2 GB per run | Your bucket metrics | | Deploy bundle read per run | 0.6 GB | Your workflow graph | | GET-class requests per run | 2,000 | S3 Storage Lens | | PUT-class requests per run | 400 | S3 Storage Lens | | S3 to EC2, same Region | $0.00 per GB | [Amazon S3 pricing](https://aws.amazon.com/s3/pricing/) | | Transfer out to the internet | $0.09 per GB after 100 GB free | [Amazon S3 pricing](https://aws.amazon.com/s3/pricing/) | | Transfer out to another Region | $0.02 per GB | [Amazon S3 pricing](https://aws.amazon.com/s3/pricing/) | | S3 Standard storage | $0.023 per GB-month | [Amazon S3 pricing](https://aws.amazon.com/s3/pricing/) | | Managed NAT data processing | $0.045 per GB | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/) | | Managed NAT hourly charge | $0.045 per hour | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/) | Every AWS rate above was checked on 2026-08-13. ### [The arithmetic](#the-arithmetic)Copy Anchor Link Monthly runs: 60 times 22 equals 1,320. Bytes read per run: 2.4 plus 2.2 plus 0.6 equals 5.2 GB. Monthly reads: 1,320 times 5.2 equals 6,864 GB. Bytes written per run: 0.8 GB, so monthly writes are 1,056 GB and the total the bucket handles is 7,920 GB. Requests: 1,320 runs times 2,000 GET-class requests is 2,640,000, which is $1.06 at $0.0004 per 1,000\. The PUT side is 528,000 requests, or $2.64 at $0.005 per 1,000\. Requests total $3.70 a month. Storage with the recommended 7 days retention: writes of 48 GB per weekday hold about 240 GB in a rolling window, which is $5.52 a month at $0.023 per GB-month. Now price the same 6,864 GB of reads three ways. | Runner placement | Transfer | Managed NAT | Requests and storage | Monthly total | | ------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- | -------------------- | ------------- | | Outside your account, reading over the internet path | 6,764 GB billable times $0.09 equals $608.76 | $0.00 | $9.22 | $617.98 | | Same account and Region, private subnets, no private S3 route | $0.00 | 7,920 GB times $0.045 equals $356.40, plus 730 hours times $0.045 equals $32.85 | $9.22 | $398.47 | | Same account and Region, private S3 route in place | $0.00 | $0.00 | $9.22 | $9.22 | Row one credits the 100 GB monthly allowance once. If production already consumed it, add $9.00\. A bucket read across Regions instead of over the internet lands between rows one and two: 6,864 GB times $0.02 equals $137.28 a month. ### [What the model says](#what-the-model-says)Copy Anchor Link Compute for this pipeline is the smaller number. At 8 minutes of build time per run on `warp-ubuntu-latest-x64-4x`, 1,320 runs is 10,560 minutes, or $84.48 a month at $0.008 per minute. In row one, the bucket costs more than the runners that read from it, and no dashboard in GitHub Actions shows that line. Placement moves it and nothing else comes close. Row three is the same workload with the runners inside the account and Region that already holds the bucket, and with the private S3 route the docs ask for. Retention is the slower failure. Skip the lifecycle policy and the 1,056 GB written each month accumulates: after a year the bucket holds roughly 12,672 GB, which is $291.46 a month in storage on a workload whose transfer line is $0.00. Scale the model linearly for your own numbers, and remember that fan-out multiplies the read side. Run as many jobs as your workflows need, since generally available Linux and Windows runners do not have plan-level concurrency caps, and count each added leg as another full read of whatever it pulls. The registry pulls and the NAT charges that sit next to this line are covered in [GitHub Actions egress costs, explained](/guides/github-actions-egress-costs). ### [Where WarpBuild fits](#where-warpbuild-fits)Copy Anchor Link WarpBuild pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the runner side of this model is the per-minute rates in the table above. Signup includes $10 free credits, which covers 1,250 minutes on `warp-ubuntu-latest-x64-4x` at $0.008 per minute while you compare Cost Explorer before and after the move. On the placement side, BYOC puts the runners in your own AWS, GCP, or Azure account, so reads become same-account, same-Region traffic priced by your cloud provider, and the WarpBuild fee stays at $0.002 per minute on Linux. Estimate the result from your storage location, cloud transfer rates, instance prices, and measured job duration. Cloud list prices change. Re-check the two AWS pricing links before quoting these totals internally, and re-run the arithmetic with your own object sizes and run counts. ## [FAQ](#faq)Copy Anchor Link **Does S3 charge for data transfer to GitHub Actions runners?** It depends on where the runner sits. AWS prices data transferred from Amazon S3 to Amazon EC2 in the same Region at $0.00 per GB, and data transferred out to the internet at $0.09 per GB after the first 100 GB each month, aggregated across services. Both figures from [Amazon S3 pricing](https://aws.amazon.com/s3/pricing/) for US East (N. Virginia), checked on 2026-08-13. **Does routing S3 traffic inside the VPC cost anything?** AWS documents this private route to S3 as available at no additional charge, checked on 2026-08-13\. The WarpBuild [BYOC AWS configuration docs](/docs/ci/byoc/aws/config) recommend setting it up so runners reach the bucket without incurring data transfer charges. **What does WarpBuild store in the BYOC bucket?** Cache under `//artifact_cache/////` and runner telemetry under `/runner/logs/all/`. The docs recommend an S3 lifecycle policy with 7 days retention on both prefixes. **Is cache storage cheaper on BYOC than on WarpBuild-hosted runners?** On hosted runners, cache storage bills at $0.20 per GB-month and cache write or restore operations at $0.0001 each on the [pricing page](/pricing). On BYOC, the cache sits in your own bucket in the stack Region and is billed by your cloud provider, and the add-ons are included in the BYOC rate. --- # Using sccache on GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/guides/sccache-github-actions Description: sccache turns repeat compilations into cache fetches, but only when the cache directory outlives the job. Wire it up, persist it, and read the hit counts. # Using sccache on GitHub Actions Runners sccache turns repeat compilations into cache fetches, but only when the cache directory outlives the job. Wire it up, persist it, and read the hit counts. Last verified: 2026-08-13 sccache is a compiler cache that stores the output of a compilation keyed on the hashed preprocessed source, the compiler binary, and the command line, so the second time that exact work is requested it returns an object file instead of running the compiler. On GitHub Actions it pays off only when the store outlives the job, because every runner is a fresh VM: point `SCCACHE_DIR` at a directory you restore each run, or run the job on a machine that already holds it. This guide covers how to tell a cache miss from a non-cacheable call, the workflow wiring for a Rust or C++ build, every environment variable the tool reads in this setup, and a time model that converts hit rate into compile minutes and dollars. ## [Diagnosis](#diagnosis)Copy Anchor Link Start with the counters. sccache keeps per-server statistics and prints them with `sccache --show-stats`, and the shape of that output tells you which of three different problems you have. A run with the wiring in place looks like this: Copy Text ``` Compile requests 1284 Compile requests executed 1190 Cache hits 842 Cache hits (Rust) 842 Cache misses 348 Cache timeouts 0 Cache read errors 0 Forced recaches 0 Cache write errors 0 Compilation failures 0 Cache errors 0 Non-cacheable compilations 0 Non-cacheable calls 84 Non-compilation calls 10 Unsupported compiler calls 0 Average cache write 0.031 s Average compiler 3.442 s Average cache read hit 0.008 s ``` 842 hits against 1,190 executed requests is a 70.8 percent hit rate, and `Average cache read hit` at 0.008 seconds against `Average compiler` at 3.442 seconds is why the rate matters. Three failure shapes replace that output. **Every line reads zero except cache misses.** The store was empty when the job started. `SCCACHE_DIR` defaults to a path under the user cache directory, nothing on a stateless runner restores it, and the job compiles the whole tree and then throws the result away at the end. This is the common case and it is a workflow problem rather than a tuning problem. **Cache misses climb on a run where nothing changed.** The hash key covers the compiler binary itself, the preprocessed source, and the command line flags. A toolchain bump, a change to `RUSTFLAGS`, a switch from debug to release, or a build that runs from a different absolute path all produce a legitimate miss on every unit. Pin the toolchain in `rust-toolchain.toml` and keep the flags identical between the run that fills the cache and the runs that read it. **Non-cacheable calls climb and cache hits stay flat.** sccache refuses work it cannot key safely. For Rust the usual cause is incremental compilation, which sccache does not cache, so `CARGO_INCREMENTAL` has to be `0` in any job that uses it. For C and C++ the usual causes are flags that make the compilation depend on state outside the preprocessed source, and those calls fall through to the real compiler untouched. One property separates sccache from timestamp-driven incremental builds. `actions/checkout` writes a fresh working tree and git stamps every file with the moment of the write, so `make`, `ninja`, and Cargo's own change detection see every source file as newer than its output and rebuild the tree. sccache hashes content, so a restored store still hits after a fresh checkout. Timestamp state is a separate problem covered in [rust incremental compilation on GitHub Actions](/guides/rust-incremental-compilation-github-actions). ## [Fix](#fix)Copy Anchor Link Give the store a home that survives the job. Three routes work, and the choice is about where the bytes live. **A local directory plus a cache action.** `SCCACHE_DIR` holds the object store, a cache action restores it at job start and saves it at the end, and the whole mechanism is visible in the workflow file. WarpBuild's cache is a drop-in for `actions/cache@v4`, so the change is one line in the `uses:` field, and the [caching documentation](/docs/ci/features/caching) covers the key and restore-key behavior. This is the default recommendation for a single repository. **A snapshot runner that keeps the directory on the machine.** [Snapshot runners](/docs/ci/features/snapshot-runners) capture the whole runner VM mid-workflow, so later jobs boot with `SCCACHE_DIR` already populated and no archive transfer at job start. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only. The economics are covered in the cost model below, and the general trade between file caches and machine state is the subject of the [persistent caches guide](/guides/persistent-caches-github-actions). **An object storage backend.** sccache speaks S3, GCS, Azure Blob, Redis, and memcached natively, and the [sccache project documentation](https://github.com/mozilla/sccache) lists the variables each backend reads. Objects are fetched on demand rather than as one archive, which is the right shape when several repositories should share one store or when the directory has grown past a few gigabytes. The workflow below uses the first route on a Rust build. The install step writes `SCCACHE_DIR` into the job environment so the shell expands `$HOME` once and the cache path and the tool agree on the location. Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x env: RUSTC_WRAPPER: sccache CARGO_INCREMENTAL: "0" SCCACHE_CACHE_SIZE: 8G SCCACHE_IDLE_TIMEOUT: "0" SCCACHE_ERROR_LOG: /tmp/sccache.log SCCACHE_LOG: warn steps: - uses: actions/checkout@v5 - name: Install sccache run: | mkdir -p "$HOME/.local/bin" curl -fsSL "https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz" \ | tar -xz -C "$HOME/.local/bin" --strip-components=1 --wildcards '*/sccache' echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "SCCACHE_DIR=$HOME/.cache/sccache" >> "$GITHUB_ENV" - name: Restore sccache store uses: WarpBuilds/cache@v1 with: path: ~/.cache/sccache key: sccache-${{ runner.os }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }}-${{ github.run_id }} restore-keys: | sccache-${{ runner.os }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }}- sccache-${{ runner.os }}- - name: Start sccache server run: | sccache --start-server sccache --zero-stats - name: Build run: cargo build --release --locked - name: Test run: cargo test --release --locked - name: Print sccache stats if: always() run: sccache --show-stats | tee -a "$GITHUB_STEP_SUMMARY" ``` Four details in that file earn their place. `sccache --zero-stats` runs after the server starts so the counters describe this run alone. The stats step carries `if: always()` so a failed build still reports why it was slow. The primary key ends in `github.run_id`, which never matches on restore and forces a fresh entry every run while `restore-keys` supplies the previous one, so the store grows instead of freezing at its first contents. And `SCCACHE_IDLE_TIMEOUT` is `0`, which stops the background server shutting down between a long test step and the stats step. On pull requests, swap the save half for `WarpBuilds/cache/restore@v1` so branch runs read the store without writing new entries. Only the default branch then pays for storage. ## [Configuration](#configuration)Copy Anchor Link Every variable this setup depends on, and what breaks when it is wrong: | Variable | What it controls | Value used here | | -------------------------------- | ------------------------------------------------------------------------------- | -------------------- | | SCCACHE\_DIR | Location of the local object store | $HOME/.cache/sccache | | SCCACHE\_CACHE\_SIZE | Ceiling on the local store; entries are evicted least recently used past it | 8G | | SCCACHE\_IDLE\_TIMEOUT | Seconds the background server stays alive with no requests; 0 disables shutdown | 0 | | RUSTC\_WRAPPER | Tells Cargo to invoke rustc through sccache | sccache | | CARGO\_INCREMENTAL | Must be 0; incremental invocations are not cacheable | 0 | | SCCACHE\_ERROR\_LOG | File the server writes errors to, readable after a failed run | /tmp/sccache.log | | SCCACHE\_LOG | Log level for that file | warn | | CMAKE\_C\_COMPILER\_LAUNCHER | Routes C compilation through sccache under CMake | sccache | | CMAKE\_CXX\_COMPILER\_LAUNCHER | Routes C++ compilation through sccache under CMake | sccache | | SCCACHE\_BUCKET, SCCACHE\_REGION | S3 backend target, when the local directory is replaced | unset | `RUSTC_WRAPPER` and `CARGO_INCREMENTAL` are Cargo's own variables, documented in the [Cargo environment variable reference](https://doc.rust-lang.org/cargo/reference/environment-variables.html). For a C or C++ build the equivalent hook is CMake's [compiler launcher variable](https://cmake.org/cmake/help/latest/variable/CMAKE%5FLANG%5FCOMPILER%5FLAUNCHER.html), set on the configure line: Copy Text ``` cmake -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_C_COMPILER_LAUNCHER=sccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=sccache cmake --build build ``` Two sizing rules follow from the eviction behavior. Keep `SCCACHE_CACHE_SIZE` above the working set of a full build, because a ceiling below it means the tail of each build evicts the head and the next run misses on work it just did. And keep the archive small enough that restoring it costs less than the compile minutes it removes, which the model below makes concrete. Platform coverage decides where this pattern applies. Sccache runs on all four, so an ARM64 job takes the same wiring with `warp-ubuntu-latest-arm64-8x` in `runs-on`. Keep separate cache keys per architecture, since the compiler binary differs and no entry is shared across them. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Assumptions first, so you can substitute your own step timings: * A Rust workspace on `warp-ubuntu-latest-x64-8x`, 8 vCPU and 32 GB, at $0.016 per minute. * A cold build spends 20.0 minutes in compilation. * A cache hit costs 5 percent of the compile it replaces, a conservative planning ceiling above the raw `Average cache read hit` to `Average compiler` ratio in the stats output, which leaves margin for network transfer and decompression that raw compiler time does not carry. * 400 runs per month. * A 5 GB store: restore takes 1.0 minute, a save takes 1.0 minute on the one run in five that writes a new entry, so 1.2 minutes of transfer amortized per run. Compile minutes at hit rate `h` are `20 - 19h`: | Hit rate | Compile minutes | Compile minutes saved | Compile cost per run | | ---------- | --------------- | --------------------- | -------------------- | | 0 percent | 20.00 | 0.00 | $0.320 | | 30 percent | 14.30 | 5.70 | $0.229 | | 50 percent | 10.50 | 9.50 | $0.168 | | 70 percent | 6.70 | 13.30 | $0.107 | | 90 percent | 2.90 | 17.10 | $0.046 | The transfer sets the floor. 1.2 minutes of archive movement per run is paid whether the store hits or not, so the break-even hit rate is 1.2 divided by 19, which is 6.3 percent. Above that the store is worth carrying, and the counters in the diagnosis section tell you where you sit. At a 70 percent hit rate, close to the sample output above, the run saves 13.3 compile minutes and pays 1.2 minutes of transfer, so 12.1 minutes net. Across 400 runs that is 4,840 runner minutes, $77.44 at $0.016 per minute. The store costs $1.00 per month at $0.20 per GB-month for 5 GB, plus $0.05 for the 480 cache operations a month (400 restores plus 80 saves, one run in five writing a new entry) at $0.0001 each, so $76.39 net per month on one workflow. The snapshot route prices differently. A snapshot restore is $0.04 per job, which equals 2.5 minutes on `warp-ubuntu-latest-x64-8x`, against the 1.2 minutes the archive transfer costs. For an sccache directory alone the cache action wins. The snapshot wins when the same image also carries `target/`, the toolchain, and pulled container images, because all of that arrives in the same boot rather than as separate restores. Runner rate against the GitHub-hosted baseline: `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), which is 27 percent lower list price. GitHub list price from the [Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. The same 400 runs at the 70 percent row spend 6.70 compile minutes each, 2,680 minutes in total: $42.88 at $0.016 per minute against $58.96 at $0.022 per minute on the GitHub-hosted 8-core shape. CI observability reports the per-step timings that turn these assumptions into your numbers, and [ccache on GitHub Actions](/guides/ccache-github-actions) covers the same model for a C or C++ project that does not use Rust. ## [FAQ](#faq)Copy Anchor Link ### [Why is my sccache hit rate zero on GitHub Actions?](#why-is-my-sccache-hit-rate-zero-on-github-actions)Copy Anchor Link Three causes cover almost every case. The cache directory named by `SCCACHE_DIR` is never restored, so each job starts with an empty store. `CARGO_INCREMENTAL` is left at its default, and sccache treats incremental rustc invocations as non-cacheable. Or the toolchain moved, because the compiler binary is part of the hash key, so a new Rust or GCC version invalidates every entry at once. Run `sccache --show-stats` at the end of the job and read the counters before changing anything. ### [Does sccache work with Cargo incremental compilation?](#does-sccache-work-with-cargo-incremental-compilation)Copy Anchor Link No. sccache does not cache incremental rustc invocations, so a workflow that leaves incremental mode on gets a run where the counters climb under non-cacheable calls and the cache hits line stays flat. Set `CARGO_INCREMENTAL=0` for any job that uses sccache. Incremental compilation and a compiler cache solve the same problem through different state, so pick one per job. ### [Should I use a cache action or an S3 bucket for sccache storage?](#should-i-use-a-cache-action-or-an-s3-bucket-for-sccache-storage)Copy Anchor Link Use a cache action with the local directory backend when one repository drives the compilations, because the round trip is a single archive and the key logic is visible in the workflow file. Use the S3 or Redis backend when several repositories or several workflows should share one store, or when the directory has grown past the point where transferring it each run is cheaper than fetching individual objects on demand. --- # Security Scanning Jobs Without the Wait | WarpBuild URL: https://www.warpbuild.com/guides/security-scanning-jobs-github-actions Description: Scan jobs run long because image pulls, database refreshes, and full-tree analysis repeat on every pull request. Split the work and cache the database. # Security Scanning Jobs Without the Wait Scan jobs run long because image pulls, database refreshes, and full-tree analysis repeat on every pull request. Split the work and cache the database. Security scanning jobs in GitHub Actions run long because most of the wall clock goes to work that repeats on every run: pulling the scanner image, refreshing the vulnerability database, and walking the whole tree even when the pull request touched four files. Split that into a scheduled full-tree scan and a pull request scan restricted to changed paths, keep the scanner image and the database in a cache between runs, and the pull request gate returns in minutes while full coverage keeps running on its own timetable. This guide covers where the minutes actually go, the workflow YAML for both halves of the split, and a monthly cost model for a stated repository size that you can rerun with your own numbers. ## [Diagnosis](#diagnosis)Copy Anchor Link A scan job is five phases, and only one of them has anything to do with the size of your diff. Open a recent scan run, expand the step timings in the log, and write down the seconds each phase took before changing any configuration. | Phase | What runs | Scales with the diff | Fix | | ------------------------------ | --------------------------------------------------------- | -------------------- | ------------------------------------------ | | Runner start and checkout | VM boot, actions/checkout | No | Smaller checkout, warm runner | | Scanner image pull | Container image for the scanner, often several hundred MB | No | Cache or preload the image | | Vulnerability database refresh | Advisory database download on every job | No | Cache the database, refresh on a schedule | | Analysis | Tree walk, parsing, rule evaluation | Yes | Restrict to changed paths, size the runner | | Result upload | SARIF upload, dependency submission | No | Leave it alone, it is seconds | ### [An example breakdown](#an-example-breakdown)Copy Anchor Link The numbers below come from one scan job on a monorepo of roughly 900,000 lines across 12,000 tracked files in 40 packages. Substitute the step timings from your own workflow run history. | Phase | Minutes in the example run | | ------------------------------ | -------------------------- | | Runner start and checkout | 2 | | Scanner image pull | 3 | | Vulnerability database refresh | 2 | | Full-tree analysis | 10 | | Result upload | 1 | | Total | 18 | Eight of those 18 minutes are fixed cost. They are paid identically by a one-line documentation fix and by a 200-file refactor, and they are paid again on every push to the branch. At 40 pull request pushes a weekday, the fixed phases alone consume 320 minutes a day. The analysis phase is the other half of the problem. A full-tree walk on a repository this size reads every file in every package to answer a question about two of them. ## [Fix](#fix)Copy Anchor Link Three changes, in the order that returns the most minutes. ### [Move the full tree onto a schedule](#move-the-full-tree-onto-a-schedule)Copy Anchor Link Coverage of the whole repository is a daily concern rather than a per-push concern. New advisories land against dependencies nobody edited, so the scan that finds them has to run on a clock rather than on a diff. Run it once a day on a schedule with `workflow_dispatch` alongside it, and let it use a larger runner, because nobody is waiting on it. ### [Restrict the pull request scan to changed paths](#restrict-the-pull-request-scan-to-changed-paths)Copy Anchor Link Compute the merge base, take the changed files, collapse them to the packages that contain them, and hand the scanner only those paths. A pull request that touches one package then analyses one package. Skip the job entirely when the diff contains no scannable paths. Filesystem and dependency scanners take path arguments directly. For SAST that builds its own database, scope the build to the affected project rather than the workspace root. ### [Stop redownloading the database and the image](#stop-redownloading-the-database-and-the-image)Copy Anchor Link The advisory database is the phase teams forget, and it is the one that also hits upstream rate limits during a busy afternoon. Restore it from cache with `WarpBuilds/cache@v1`, which is a drop-in replacement for `actions/cache@v4`, and let the scheduled job own the refresh and the save. Pull request jobs then read a database written that morning instead of downloading their own copy 40 times a day. The scanner image is the same argument. Pin it by digest and cache it, or bake it into a snapshot runner so the image is already on disk when the job starts. Runner images run on ephemeral VMs that are created on demand and destroyed after each build, described in the [runner security documentation](/docs/ci/security), so the cache is what carries state between them rather than a reused machine. ### [Size the runner for the analysis phase](#size-the-runner-for-the-analysis-phase)Copy Anchor Link Analysis is CPU and IO bound. The scheduled full-tree job earns a larger size because it is the only job that reads everything; the pull request job usually does not, because the fixed phases dominate once the tree walk is scoped down. Measure both before committing to a size. The full label set is in the [cloud runners documentation](/docs/ci/cloud-runners). ## [Configuration](#configuration)Copy Anchor Link ### [Pull request scan, changed paths only](#pull-request-scan-changed-paths-only)Copy Anchor Link Copy Text ``` name: security-scan-pr on: pull_request: branches: [main] permissions: contents: read security-events: write jobs: changed-paths: runs-on: warp-ubuntu-latest-x64-2x outputs: packages: ${{ steps.filter.outputs.packages }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - id: filter run: | BASE=$(git merge-base "origin/${{ github.base_ref }}" HEAD) git diff --name-only "$BASE" HEAD > changed.txt PACKAGES=$(cut -d/ -f1-2 changed.txt | sort -u | paste -sd' ' -) echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT" scan-diff: needs: changed-paths if: needs.changed-paths.outputs.packages != '' runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/cache/restore@v1 with: path: ~/.cache/vuln-db key: vuln-db- restore-keys: vuln-db- - name: Scan the changed packages run: | scanner fs \ --db-path ~/.cache/vuln-db \ --skip-db-update \ --format sarif \ --output diff.sarif \ ${{ needs.changed-paths.outputs.packages }} - uses: github/codeql-action/upload-sarif@v3 with: sarif_file: diff.sarif ``` ### [Scheduled full-tree scan](#scheduled-full-tree-scan)Copy Anchor Link This job owns the database refresh, so it writes the cache entry that every pull request job reads. Copy Text ``` name: security-scan-full on: schedule: - cron: "0 6 * * *" workflow_dispatch: permissions: contents: read security-events: write jobs: full-tree: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - name: Refresh the vulnerability database run: scanner db update --db-path ~/.cache/vuln-db - name: Scan the full tree run: | scanner fs \ --db-path ~/.cache/vuln-db \ --skip-db-update \ --format sarif \ --output full.sarif . - uses: WarpBuilds/cache/save@v1 with: path: ~/.cache/vuln-db key: vuln-db-${{ github.run_id }} - uses: github/codeql-action/upload-sarif@v3 with: sarif_file: full.sarif ``` Both workflows upload SARIF, so findings land in the same code scanning view whichever job produced them. If the same pipeline also emits a component inventory, the mechanics are in [how to generate an SBOM during a GitHub Actions build](/answers/how-do-i-generate-an-sbom-during-a-github-actions-build) and the term itself is defined in the [SBOM glossary entry](/glossary/sbom). ### [Labels and rates used above](#labels-and-rates-used-above)Copy Anchor Link Per-minute rates from the [pricing page](/pricing): | Runner label | OS | vCPU | RAM | Storage | Price per minute | | ------------------------- | ------------ | ---- | ----- | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Assumptions](#assumptions)Copy Anchor Link | Input | Value | Source | | --------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------- | | Repository size | 900,000 lines, 12,000 files, 40 packages | Your repository | | Pull request pushes per weekday | 40 | Your workflow run history | | Weekdays per month | 22 | Calendar | | Scheduled full scans per month | 30 | One per day | | Full-tree scan duration | 18 minutes | The breakdown above | | Changed-paths scan duration | 4 minutes | The breakdown above, analysis scoped to one package | | Cached database and image size | 3 GB | Your cache metrics | | warp-ubuntu-latest-x64-4x | $0.008 per minute | [pricing](/pricing) | | warp-ubuntu-latest-x64-8x | $0.016 per minute | [pricing](/pricing) | | Cache storage | $0.20 per GB-month | [pricing](/pricing) | | Cache write or restore | $0.0001 per operation | [pricing](/pricing) | | GitHub 8-core Linux larger runner | $0.022 per minute | [GitHub Actions billing](https://docs.github.com/en/billing/reference/actions-minute-multipliers) | GitHub list prices checked on 2026-08-13. ### [Full scan on every push](#full-scan-on-every-push)Copy Anchor Link Monthly scan runs: 40 pushes times 22 weekdays equals 880. Monthly minutes: 880 times 18 equals 15,840. | Where those minutes run | Rate per minute | Monthly total | | --------------------------------- | --------------- | ------------- | | warp-ubuntu-latest-x64-8x | $0.016 | $253.44 | | GitHub 8-core Linux larger runner | $0.022 | $348.48 | At the same 8 vCPU shape, $0.016 per minute against $0.022 per minute is a 27 percent lower list price (GitHub pricing, checked on 2026-08-13). ### [The split model](#the-split-model)Copy Anchor Link | Job | Runs per month | Minutes each | Minutes | Rate | Cost | | ------------------------- | -------------- | ------------ | ------- | ------------------ | ------ | | Changed-paths scan on 4x | 880 | 4 | 3,520 | $0.008 | $28.16 | | Scheduled full scan on 8x | 30 | 18 | 540 | $0.016 | $8.64 | | Cache storage, 3 GB | \- | \- | \- | $0.20 per GB-month | $0.60 | | Cache operations, 910 | \- | \- | \- | $0.0001 | $0.09 | | Total | 4,060 | $37.49 | | | | The monthly bill for security scanning moves from $253.44 to $37.49, and the minute total moves from 15,840 to 4,060\. The number engineers feel is the other one: the gate on a pull request returns in about 5 minutes including the changed-paths job, rather than 18. Two of those savings are independent. Restricting the analysis phase cuts minutes on any runner you use, and the per-minute rate decides what each remaining minute costs. Apply both. The wider version of this arithmetic across a whole GitHub Actions bill is in [how to reduce GitHub Actions costs](/guides/reduce-github-actions-costs), and the update traffic that feeds these scans is covered in [Dependabot and Renovate jobs in GitHub Actions](/guides/dependabot-and-renovate-jobs-github-actions). ### [Where WarpBuild fits](#where-warpbuild-fits)Copy Anchor Link The full rate table is on the [pricing page](/pricing). For the review that usually accompanies this work, WarpBuild is SOC 2 Type 2 certified, with the report available through the [trust center](https://trust.warpbuild.com/). The isolation and storage answers a security team asks for are in the [runner security documentation](/docs/ci/security), and the longer questionnaire is walked through in [a security review checklist for GitHub Actions runners](/enterprise/ci-security-review). ## [FAQ](#faq)Copy Anchor Link **Does scanning only changed paths miss vulnerabilities?** It misses anything that changes outside your diff, such as a new advisory published against a dependency nobody touched this week. That is why the scheduled full-tree scan stays in place. The pull request job is the fast gate, and the scheduled job is the coverage. **Why does a scan take 18 minutes when the pull request changed four files?** Three of the five phases in a scan job are fixed cost. Runner start and checkout, scanner image pull, and vulnerability database refresh all run at the same length whether the diff is four files or four hundred. Only the analysis phase scales with what you feed it. **Is a cached vulnerability database safe to scan against?** It is as fresh as the job that last wrote it. A scheduled job that refreshes the database and saves it back every morning leaves pull request scans reading a database at most 24 hours old. Shorten the schedule if your policy needs a tighter window. --- # What Self-Hosted Runners Actually Cost | WarpBuild URL: https://www.warpbuild.com/guides/self-hosted-runner-total-cost Description: Self-hosted GitHub Actions runners bill idle instance hours plus image, autoscaling, upgrade and on-call work. A worked monthly model with the arithmetic. # What Self-Hosted Runners Actually Cost Self-hosted GitHub Actions runners bill idle instance hours plus image, autoscaling, upgrade and on-call work. A worked monthly model with the arithmetic. Last verified: 2026-08-13 A self-hosted GitHub Actions fleet bills three things your instance invoice shows partly or never: the instance hours you pay for while no job is running, the engineering time to build images, tune autoscaling and run upgrades, and the on-call attention when the fleet stops handing out runners. At 60,000 Linux job minutes per month, an example on-demand rate of $0.40 per hour and 12 hours of operating work at $120 per hour, the model below lands at $2,167.27 against $960.00 for the same minutes on `warp-ubuntu-latest-x64-8x` at $0.016 per minute. ## [Diagnosis](#diagnosis)Copy Anchor Link Teams compare a self-hosted fleet against per-minute pricing by putting one number on each side: the instance rate against the per-minute rate. That comparison leaves out four lines, and each of them scales differently from job minutes. **Idle instance hours.** Instances bill wall-clock time. Job minutes bill job time. The ratio between them is fleet utilization, and it is the single number that decides whether the compute line is competitive. A pool sized for the 5 pm merge rush is mostly idle at 10 am, and a pool sized for the average queues at peak. **Runner image maintenance.** GitHub sets the rebuild clock: "If you do not perform a software update within 30 days, the GitHub Actions service will not queue jobs to your runner" ([runner software updates](https://docs.github.com/en/actions/reference/runners/self-hosted-runners#runner-software-updates-on-self-hosted-runners), checked on 2026-08-13). The `actions/runner` project shipped 11 stable releases in the 12 months to 2026-08-13, v2.328.0 through v2.336.0 ([releases](https://github.com/actions/runner/releases)). Your toolchain versions ride on top of that cadence. **Autoscaling logic.** On [Actions Runner Controller](/glossary/actions-runner-controller), `maxRunners` and `minRunners` are static Helm values, and scheduled scaling is a cron job you write and maintain ([deploy runner scale sets](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets), checked on 2026-08-13). Whatever policy you build sits between the two failure modes above. **Upgrades and the support boundary.** GitHub documents that Helm cannot upgrade ARC and that the procedure is an uninstall and reinstall, with a second cluster in another region as the documented way to avoid downtime ([upgrading ARC](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets#upgrading-arc)). GitHub also recommends a Kubernetes expert on staff and publishes a list of Kubernetes topics outside GitHub Support's scope ([ARC support scope](https://docs.github.com/en/actions/concepts/runners/support-for-arc), checked on 2026-08-13). One more line hides behind caching. ARC ships no dependency cache of its own, and image caching sits on GitHub's out-of-scope list for ARC support, so a self-operated fleet usually grows a cache bucket, a registry mirror, or a persistent volume, each with its own storage bill and its own failure mode. ### [Where each line lands](#where-each-line-lands)Copy Anchor Link | Cost line | What it bills | Which budget it hits | | -------------------------------------------------- | ---------------------------------------- | -------------------- | | Instance hours while jobs run | Wall-clock time per instance | Cloud invoice | | Instance hours while no job runs | Wall-clock time per instance | Cloud invoice | | Cache bucket, registry mirror, artifact storage | Stored gigabytes and requests | Cloud invoice | | Egress and address translation for private subnets | Per gigabyte processed | Cloud invoice | | Image builds and toolchain updates | Engineer hours on a 30-day clock | Payroll | | Autoscaling policy and capacity tuning | Engineer hours | Payroll | | Controller and cluster upgrades | Engineer hours | Payroll | | Failure triage and on-call | Engineer hours, at the worst time of day | Payroll | ## [Fix](#fix)Copy Anchor Link Price the fleet with four numbers you already have, then compare the total against per-minute pricing at the same shape. 1. **Measure job minutes.** Sum the wall-clock duration of completed jobs over a full month from the GitHub Actions API. This is the number a per-minute bill would charge for. 2. **Measure paid instance hours.** Pull the same month from your cloud billing export, filtered to the tag or account that holds the runner fleet. Divide job hours by paid instance hours to get fleet utilization. Most fleets that never queue land well under 60 percent. 3. **Count operating hours.** Log the work for one month against the four payroll lines above. Multiply by a loaded hourly rate. 4. **Compare against per-minute pricing at the same shape.** Match vCPU and memory, then compare monthly totals rather than rates, because the two sides bill different units. The three shapes worth comparing after that are a fleet you operate, hosted managed runners billed per job minute, and the middle path where the compute stays in your own cloud account. On the hosted side, and BYOC runs on AWS, GCP, and Azure with WarpBuild charging $0.002 per minute while your account pays for the instance ([BYOC documentation](/docs/ci/byoc)). BYOC keeps the residency and network placement that drove self-hosting in the first place, and moves the image, autoscaling and upgrade lines onto WarpBuild. ## [Configuration](#configuration)Copy Anchor Link Measuring job minutes takes one command. Run it against a month of completed runs and sum the second column. Copy Text ``` gh api --paginate \ "/repos/$OWNER/$REPO/actions/runs?status=completed&created=>=2026-07-01" \ --jq '.workflow_runs[].id' \ | while read -r run_id; do gh api "/repos/$OWNER/$REPO/actions/runs/$run_id/jobs" \ --jq '.jobs[] | select(.completed_at != null) | [.runner_name, (((.completed_at | fromdateiso8601) - (.started_at | fromdateiso8601)) / 60)] | @tsv' done ``` Group the output by `runner_name` to separate the fleet you operate from anything else answering jobs. That column also tells you which runner labels carry the volume, which is where a rate change is worth making first. Moving a job off the fleet is one line. The array label routes to a runner you registered, and a `warp-` label routes to a managed runner of the same shape: Copy Text ``` name: ci on: push: branches: [main] jobs: build-self-operated: runs-on: [self-hosted, linux, x64] steps: - uses: actions/checkout@v5 - run: ./scripts/build.sh build-hosted: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - run: ./scripts/build.sh build-in-your-own-account: runs-on: warp-custom-use1-linux-8x steps: - uses: actions/checkout@v5 - run: ./scripts/build.sh ``` `warp-ubuntu-latest-x64-8x` is 8 vCPU and 32 GB from the [cloud runners catalog](/docs/ci/cloud-runners). `warp-custom-use1-linux-8x` is a Runner ID from the custom runners page in the dashboard; the `warp-custom-` prefix is part of the ID. Running all three for a week on the same workflow gives you three real durations at the same shape, which is a better input to the model below than any published rate. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Assumptions](#assumptions)Copy Anchor Link | Input | Value | Source | | ---------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------- | | Linux job minutes per month | 60,000 | Your run history, from the command above | | Job shape | 8 vCPU, 32 GB | Your runs-on labels | | Job hours implied | 1,000 | 60,000 divided by 60 | | Fleet utilization, self-operated | 55 percent | Your billing export divided by job hours | | Example on-demand instance rate | $0.40 per hour | Substitute your rate from [AWS EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/) | | Loaded engineering rate | $120 per hour | Your own figure | | Operating hours per month, self-operated | 12 | The breakdown below | | Operating hours per month, BYOC | 2 | Quota headroom, IAM review, region choice | ### [The 12 operating hours](#the-12-operating-hours)Copy Anchor Link | Work | Hours | What sets the clock | | -------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------- | | Image rebuilds and toolchain updates | 4 | The 30-day runner update rule, plus 11 actions/runner stable releases in the 12 months to 2026-08-13 | | Autoscaling policy and capacity tuning | 2 | maxRunners and minRunners are static Helm values; scheduled scaling is a cron job you write | | Controller and cluster upgrades | 3 | Helm cannot upgrade ARC; the documented path is uninstall and reinstall | | Failure triage and on-call | 3 | Stuck pods, exhausted quotas, failed registrations | ### [Monthly totals](#monthly-totals)Copy Anchor Link | Shape | Compute or fee | Engineering time | Monthly total | | ---------------------------------------------------- | --------------------------------------------------- | ---------------------------- | --------------------------------------- | | Fleet you operate, 55 percent utilization | 1,818.2 hours at $0.40 = **$727.27** | 12 hours at $120 = $1,440.00 | **$2,167.27**, plus storage and network | | GitHub-hosted 8-core Linux larger runner, list price | 60,000 at $0.022 = **$1,320.00** | none | **$1,320.00** | | warp-ubuntu-latest-x64-8x | 60,000 at $0.016 = **$960.00** | none | **$960.00** | | BYOC at the example on-demand rate | $120.00 fee plus 1,000 hours at $0.40 = **$520.00** | 2 hours at $120 = $240.00 | **$760.00** | | BYOC on spot at the AWS published ceiling | $120.00 fee plus 1,000 hours at $0.04 = **$160.00** | 2 hours at $120 = $240.00 | **$400.00** | GitHub list prices were checked on 2026-08-13 on the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). WarpBuild rates come from the runner catalog on the [pricing page](/pricing). BYOC runners cost $0.002 per minute in WarpBuild fees and the compute is billed by your own cloud account, where AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices. AWS discount checked on 2026-08-13 on the [AWS Spot page](https://aws.amazon.com/ec2/spot/). That ceiling is an AWS figure rather than a WarpBuild measurement, and the rate you see depends on instance family, region, and the hour. ### [The two numbers that move the answer](#the-two-numbers-that-move-the-answer)Copy Anchor Link Fleet utilization moves the compute line and nothing else: | Utilization | Paid instance hours | Instance line at $0.40 per hour | | ----------- | ------------------- | ------------------------------- | | 30 percent | 3,333.3 | $1,333.33 | | 45 percent | 2,222.2 | $888.89 | | 55 percent | 1,818.2 | $727.27 | | 70 percent | 1,428.6 | $571.43 | | 100 percent | 1,000.0 | $400.00 | Operating hours move everything else. At 55 percent utilization the compute line is $727.27, which sits $232.73 below the hosted total of $960.00, so the fleet wins only while it costs under 1.94 hours per month at $120 per hour. At 100 percent utilization, a level no queue-free fleet reaches, the headroom is 4.67 hours per month. Both thresholds sit below the 12-hour estimate above, and neither counts the cache bucket, the registry mirror, or address translation charges on private subnets. The model scales in one direction only. Compute lines and per-minute lines both grow with job minutes; the operating hours do not shrink when volume falls, which is why small fleets carry the worst ratio. WarpBuild pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, and the whole rate card is on the [pricing page](/pricing). Signup includes $10 free credits, which is 625 minutes on `warp-ubuntu-latest-x64-8x` at $0.016 per minute: enough to run the three-label workflow above and get real durations before committing to any of these numbers. ## [FAQ](#faq)Copy Anchor Link **Is a self-hosted runner fleet cheaper than per-minute managed runners?** Only when the operating work is small. At 60,000 Linux job minutes per month on an 8 vCPU shape, 55 percent fleet utilization and a $0.40 per hour example instance rate, the compute line is $727.27 against $960.00 for `warp-ubuntu-latest-x64-8x` at $0.016 per minute. The gap is $232.73, so the fleet stops being the cheaper option once it takes more than 1.94 hours of engineering time per month at a loaded rate of $120 per hour. **What is the largest cost line that does not appear on the compute invoice?** The rebuild clock on runner images. GitHub states that if you do not perform a software update within 30 days, the GitHub Actions service will not queue jobs to your runner, and the `actions/runner` project shipped 11 stable releases in the 12 months to 2026-08-13, so image work recurs whether or not your toolchain changed. **Does running runners in my own cloud account remove the maintenance work?** It removes most of it. On BYOC the images, the autoscaling logic and the control plane upgrades belong to WarpBuild, and the instance stays in your account on your rates. What stays with you is the cloud account itself: quota headroom, IAM, and the region choice per stack. The [hosted versus BYOC guide](/guides/hosted-vs-byoc-runners) has the break-even instance rate for every runner size. --- # Service Containers for Integration Tests | WarpBuild URL: https://www.warpbuild.com/guides/service-containers-github-actions Description: GitHub Actions service containers start and end with the job. Gate the first step on a real health check, then size the runner for services and test workers. # Service Containers for Integration Tests GitHub Actions service containers start and end with the job. Gate the first step on a real health check, then size the runner for services and test workers. Last verified: 2026-08-13 A service container is a Docker container that the GitHub Actions runner creates from the `services` block before your first step and destroys when the job completes, which is why every run starts with an empty database ([GitHub service container documentation](https://docs.github.com/en/actions/tutorials/use-containerized-services/use-docker-service-containers)). Two problems account for most of the time lost around them: steps that begin before the engine accepts connections, and a runner sized for the test process while the database, the broker, and the test workers share one machine. This guide covers the container lifecycle inside a job, the health-check gate that removes the startup race, a workflow with two services and connection details passed to the test step, and a sizing model that prices services plus workers against the Linux x64 ladder. For neighboring problems see [running database services in GitHub Actions](/answers/how-do-i-run-database-services-in-github-actions), [Docker Compose integration tests](/solutions/docker-compose-integration-tests-github-actions) when the stack outgrows the `services` block, and the [hub on speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link The lifecycle explains both failure modes. Every phase below happens inside the billed job, on a fresh virtual machine with an empty container store. | Phase | What the runner does | Where it shows up | | --------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | Initialize containers | Creates a user-defined bridge network, pulls each service image, creates each container with the options you supplied | The "Initialize containers" log group, timed | | Health wait | Polls the container health status and blocks the first step until every service with a health command reports healthy | The same log group, before step one | | Steps | Your job runs; services resolve at 127.0.0.1 on published ports, or at the service label from a job container | Step logs | | Stop containers | Stops and removes every service container and the network | The "Stop containers" post-job group | Without health flags in `options`, a container counts as ready the moment it starts, and the first step runs against a port that is open only because Docker published it. That gap is the flake. With health flags, the runner waits, and the gap closes. The subtler version survives a health check. The official Postgres image runs its initialization scripts against a temporary server that does not accept TCP connections ([postgres image documentation](https://hub.docker.com/%5F/postgres), checked on 2026-08-13). A `--health-cmd pg_isready` with no host argument talks to the Unix socket, sees that temporary server, and reports healthy while the real server is still restarting. The job then gets one connection refused on its first query. Memory is the second failure. Service containers run on the same virtual machine as the job, so their resident memory competes with the test workers rather than living somewhere else. A suite that passes at four workers and gets killed at eight is usually a memory ceiling, and the exit code is 137. | Symptom | Cause | Where to look | | ---------------------------------------- | ---------------------------------------------------------- | ------------------------------- | | Connection refused on the first query | No health flags, so the runner never waited | "Initialize containers" group | | Healthy, then refused during migration | Health command answered over the Unix socket during initdb | The \--health-cmd string | | Job exits 137 under load | Services plus workers exceed the runner memory | Memory P90 for the job | | Health retries exhausted on a cold image | Engine needs longer to boot than retries allow | \--health-start-period | | Minutes rise, test time flat | Image pull repeated on every run | Time in "Initialize containers" | On GitHub-hosted runners, read peak memory inside the job yourself. ## [Fix](#fix)Copy Anchor Link Work these in order. **Put health flags on every service.** `options` is passed through to [docker create](https://docs.docker.com/reference/cli/docker/container/create/), so `--health-cmd`, `--health-interval`, `--health-timeout`, `--health-retries`, and `--health-start-period` are all available. A service with no health command is a service the runner will not wait for. **Force the health command onto TCP.** For Postgres that means `pg_isready -h 127.0.0.1 -U app -d app_test` rather than bare `pg_isready`, which is what closes the initdb race described above. For Redis, `redis-cli ping` already speaks the protocol on the port. **Give slow engines a start period instead of more retries.** `--health-start-period 10s` holds the container out of the unhealthy state while it boots, so a 12-retry budget covers real trouble rather than normal startup. **Delete every `sleep` that was covering the gap.** A fixed sleep pays its full duration on every run and still fails on the slow one. **Pass connection details once, at job level.** Put `DATABASE_URL` and `REDIS_URL` in job `env` so the migration step, the test step, and any debug step read the same values. **Size the runner for the sum.** The next two sections turn this into arithmetic. ## [Configuration](#configuration)Copy Anchor Link Two services, health gates that hold, and connection details handed to the test step. Service containers require a Linux runner, so this workload sits on the labels in the [cloud runners documentation](/docs/ci/cloud-runners). Copy Text ``` name: integration-tests on: pull_request: branches: [main] jobs: integration: runs-on: warp-ubuntu-latest-x64-8x timeout-minutes: 20 env: DATABASE_URL: postgres://app:app@127.0.0.1:5432/app_test REDIS_URL: redis://127.0.0.1:6379/0 TEST_WORKERS: "6" services: postgres: image: postgres:17 env: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -h 127.0.0.1 -U app -d app_test" --health-interval 5s --health-timeout 5s --health-retries 12 --health-start-period 10s --shm-size 1g redis: image: redis:7 ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 3s --health-retries 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "22" cache: npm - run: npm ci - name: Migrate run: npm run db:migrate - name: Integration tests run: npm test -- --maxWorkers=${TEST_WORKERS} ``` What each piece does: * `ports: - 5432:5432` publishes the container port to the runner machine, which is what makes `127.0.0.1:5432` resolvable from a job that runs directly on the runner. A job that sets the `container` key would drop the mapping and use `postgres` as the hostname instead ([workflow syntax reference](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)). * The Postgres health command carries `-h 127.0.0.1`, so it fails while initialization runs and passes only once the server listens on TCP. * `--health-start-period 10s` plus 12 retries at 5 second intervals gives 70 seconds of grace before the container is declared unhealthy and the job fails at the container step rather than mid-suite. * `--shm-size 1g` raises the shared memory segment above the 64 MB Docker default, which is the parallel-query and large-sort ceiling for Postgres. * `TEST_WORKERS` is 6 on an 8 vCPU label, leaving headroom for the two service containers and the runner agent. * `timeout-minutes: 20` bounds the failure mode where a service never becomes healthy and the job sits waiting. Labels resolve to these machines, from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. The full ladder, including the Ubuntu 22.04 and 26.04 labels, sits on the [Linux x64 runner page](/runners/linux-x64). | runs-on label | vCPU | RAM | Storage | USD per minute | | -------------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Size on two axes at once. Memory is `services + (workers * per_worker) + 0.5 GB` of runner agent and Docker daemon overhead, and vCPU is `workers + 1` so the service containers keep a core while the suite runs. The figures below are stated assumptions for a Node suite with Postgres and Redis, not measurements. Replace them with the peak resident memory from your own job. | Component | Assumed peak | Notes | | ------------------------------- | ------------ | ----------------------------------- | | postgres:17 after migration | 1.0 GB | Shared buffers plus the WAL writer | | One Postgres backend per worker | 40 MB | One connection held per test worker | | redis:7 with a small keyspace | 0.2 GB | Grows with the fixture set | | Test worker process | 1.0 GB | Runtime, module graph, and fixtures | | Runner agent and Docker daemon | 0.5 GB | Constant | Six workers come to `1.0 + (6 * 1.0) + (6 * 0.04) + 0.2 + 0.5`, or about 7.9 GB, and eight workers come to about 10.0 GB. The 4 vCPU label at 16 GB holds the memory in both cases and starves the CPU axis at eight workers, so a suite that wants eight workers plus two services lands on `warp-ubuntu-latest-x64-8x` at 8 vCPU and 32 GB. Price that label against its GitHub-hosted equivalent. An 8 minute integration job at 500 runs a month is 4,000 billed minutes. | Line | warp-ubuntu-latest-x64-8x | 8-core Linux larger runner | | ---------------- | ------------------------- | -------------------------- | | Per-minute rate | $0.016 | $0.022 | | One 8 minute job | $0.128 | $0.176 | | 500 runs a month | $64.00 | $88.00 | | 12 months | $768.00 | $1,056.00 | `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13, from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The health gate pays for itself in the same arithmetic. Replacing a `sleep 30` with a gate that clears in 8 seconds returns 22 seconds per run, which is 183 minutes a month at 500 runs, or $2.93 on the 8 vCPU label. The image pull is the larger remaining line: it repeats on every run because the virtual machine is new, and a snapshot runner boots with those images already on disk ([snapshot runners documentation](/docs/ci/features/snapshot-runners)). The same mechanism carries a seeded data directory across jobs, which is covered in [keeping a database warm between jobs](/answers/can-i-keep-a-database-warm-between-github-actions-jobs). Full rates by runner type are on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link **Why do my tests get connection refused even though the service reports healthy?** The health command answered over the Unix socket while the engine was still initializing. The official Postgres image runs its initialization scripts against a temporary server that does not accept TCP connections, so `pg_isready` over the socket passes before the real server is listening. Force the check onto TCP with `--health-cmd "pg_isready -h 127.0.0.1 -U app -d app_test"` and add `--health-start-period` so the retries do not burn out during initdb. **Do service containers need the `ports` keyword?** Only when the job runs directly on the runner machine, which is the common case. That job reaches a service at `127.0.0.1` on the published port, so each service needs a `ports` mapping. A job that sets the `container` key joins the same Docker network and reaches each service at its service label as a hostname with no mapping at all. **What runner size do two services and eight test workers need?** Budget memory as services plus workers plus about 0.5 GB of runner and Docker daemon overhead, then take the first size on the Linux x64 ladder that clears it. Postgres at 1 GB, Redis at 0.2 GB, eight workers at 1 GB each and eight database backends at 40 MB each comes to about 10 GB, and the 8 vCPU size at 32 GB is the first label that holds it while leaving a core for the services. --- # Sharing Data Between GitHub Actions Jobs | WarpBuild URL: https://www.warpbuild.com/guides/share-data-between-github-actions-jobs Description: Three mechanisms move data between GitHub Actions jobs: job outputs for strings, artifacts for files, and cache or snapshot for runner state. # Sharing Data Between GitHub Actions Jobs Three mechanisms move data between GitHub Actions jobs: job outputs for strings, artifacts for files, and cache or snapshot for runner state. Last verified: 2026-08-13 GitHub Actions offers three ways to move data between jobs, and they are not interchangeable: a job output carries a string through the `needs` graph, an artifact carries files that outlive the run, and a cache or a [snapshot runner](/runners/snapshot) carries runner-local state a later job would otherwise rebuild. Pick by payload size and required lifetime, because the wrong pick either serializes a graph that could have run wide or pays runner minutes to move gigabytes that never had to move. ## [Diagnosis](#diagnosis)Copy Anchor Link Start with what actually crosses the job boundary. Every job in a workflow gets its own runner, and that runner is destroyed when the job ends, so nothing on disk survives unless something copies it. Three mechanisms do the copying, and each one fails differently. ### [Job outputs carry strings and add an edge to the graph](#job-outputs-carry-strings-and-add-an-edge-to-the-graph)Copy Anchor Link A step writes `key=value` to `$GITHUB_OUTPUT`, the job republishes it under `outputs:`, and a downstream job reads it as `needs..outputs.`. Outputs are evaluated when the producing job ends, and values that match a secret are redacted, so an output is an identifier: a commit sha, an image tag, a computed matrix definition, a deploy target. The cost here is structural rather than billed. `needs` is a dependency edge, so the consumer waits for the producer to finish before its runner is even requested. A workflow where five jobs each read one string from a 30-second setup job pays that 30 seconds plus a runner boot before any of the five starts. Outputs are free to move and expensive to overuse. ### [Artifacts carry files and bill runner minutes on both ends](#artifacts-carry-files-and-bill-runner-minutes-on-both-ends)Copy Anchor Link `actions/upload-artifact` compresses the paths you name and uploads them; `actions/download-artifact` pulls them back on the consumer. The artifact outlives the run, appears on the run summary page for a human to download, and is held for your repository retention setting. Both halves execute on a runner, so both are billed as runner minutes. A producer that uploads 8 GB and six consumers that each download it pay for seven transfers of the same bytes, and GitHub bills the artifact storage against your plan on top of that. Reach for an artifact when a person or a later workflow needs the file, and when a miss has to be impossible. ### [Runner-local state does not cross the boundary at all](#runner-local-state-does-not-cross-the-boundary-at-all)Copy Anchor Link The third mechanism is the one teams reach for by habit: leave the work on the runner. [Runners do not keep state between jobs](/answers/do-github-actions-runners-keep-state-between-jobs), so two products stand in for that, and they behave differently. A cache action is still a transfer, keyed rather than addressed. The entry is scoped to the key, the [cache version](/docs/ci/features/caching), and the branch, and it expires 7 days after its last use. A restore that misses is not an error by default, so a job that treats a cache as a handoff runs happily with an empty directory and fails later, in a test step, with a message that points nowhere near the cause. A snapshot runner skips the transfer. It captures the runner VM mid-workflow and boots later jobs from that image, so the state is on disk at boot and nothing streams at job start. That trades the transfer for a per-job restore fee and a boot of 45 to 60 seconds. Priced out, the three mechanisms look like this on WarpBuild hosted runners: * A job output costs nothing to move and costs you the wall clock time of the `needs` edge, billed at the consumer's per-minute runner rate. * An artifact costs upload minutes on the producer plus download minutes on every consumer, all at the runner rate, plus GitHub artifact storage. * A cache costs $0.0001 per write or restore operation, $0.20 per GB-month of storage, and the transfer time at the runner rate. A snapshot costs $0.04 per restore and $0.025 per snapshot-hour, with no transfer time. ## [Fix](#fix)Copy Anchor Link Match the mechanism to payload size and to how long the payload has to stay reachable. | What you are moving | Typical size | Lifetime needed | Mechanism | What it costs | | ---------------------------------------------------- | -------------------- | ---------------------------- | -------------------------------- | ----------------------------------------------------------- | | Commit sha, image tag, deploy target | Bytes, as a string | This run | Job output plus needs | Free to move, serializes the two jobs | | Matrix definition computed at runtime | A JSON string | This run | Job output read through fromJSON | Free to move, serializes the fan-out behind one job | | Build binary, test report, coverage file | MB to GB | Past the run, human readable | Artifact | Upload and download minutes, plus GitHub artifact storage | | Compiled output consumed by later jobs | Hundreds of MB to GB | This run only | Cache keyed on github.run\_id | $0.0001 per operation, $0.20 per GB-month, transfer minutes | | Dependency tree keyed by a lockfile | Hundreds of MB | Across runs, best effort | Cache keyed on hashFiles | Same cache fees, entry expires 7 days after last use | | Warm daemons, pulled images, incremental build trees | Tens of GB | Across runs | Snapshot runner on Cloud Ubuntu | $0.04 per restore, $0.025 per snapshot-hour | | Docker layers | GB | Across runs | Remote Docker builders | Builder session rate, no cache entry to manage | Three rules fall out of the table. **If the consumer cannot proceed without the payload, do not use a cache.** Use a job output for a string and an artifact for files. A cache entry can be evicted, can expire 7 days after its last use, and can miss because the branch or the [cache version differs across workflows](/answers/can-i-share-a-cache-between-github-actions-workflows). That is acceptable for an optimization and unacceptable for a contract. **If the consumer only runs faster with the payload, use a cache and handle the miss.** Set `fail-on-cache-miss` deliberately: `true` when a miss should stop the job early with a clear message, `false` when the job has a slow path it can fall back to. **If what you want is a machine rather than a file, stop transferring.** Warm daemons, a populated Docker image store, and incremental build trees do not survive a restore because they are machine state. A snapshot runner boots later jobs from the machine itself, and the [break-even arithmetic for that trade](/guides/persistent-caches-github-actions) is set by the $0.04 restore fee against your runner rate. Fan-out shapes the decision as much as size does. Six shards downloading the same artifact pay six transfers, and there is no queue penalty for running them at once: run as many jobs as your workflows need, because generally available Linux and Windows runners do not have plan-level concurrency caps. The transfer bill scales with the width of the matrix, so widening the fan-out makes the payload size question sharper rather than softer. ## [Configuration](#configuration)Copy Anchor Link ### [Job outputs and a computed matrix](#job-outputs-and-a-computed-matrix)Copy Anchor Link Copy Text ``` name: build-and-test on: [push, pull_request] jobs: plan: runs-on: warp-ubuntu-latest-x64-2x outputs: image_tag: ${{ steps.meta.outputs.image_tag }} shards: ${{ steps.meta.outputs.shards }} steps: - uses: actions/checkout@v5 - id: meta run: | echo "image_tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" echo 'shards=["1","2","3","4","5","6"]' >> "$GITHUB_OUTPUT" test: needs: plan runs-on: warp-ubuntu-latest-x64-8x strategy: matrix: shard: ${{ fromJSON(needs.plan.outputs.shards) }} steps: - run: ./scripts/test.sh --shard ${{ matrix.shard }} --tag ${{ needs.plan.outputs.image_tag }} ``` Keep the `plan` job on a small runner. It moves strings, so `warp-ubuntu-latest-x64-2x` at $0.004 per minute is the right size, and every job behind the `needs` edge waits on it. ### [A run-scoped cache with a delete step](#a-run-scoped-cache-with-a-delete-step)Copy Anchor Link When the payload is a compiled tree that only this run needs, key it on the run and remove it when the run finishes. The `delete-cache` input on the WarpBuild cache action turns a cleanup job into three lines: Copy Text ``` build: needs: plan runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - run: pnpm install --frozen-lockfile && pnpm build - name: Save build output for this run uses: WarpBuilds/cache/save@v1 with: path: dist key: ${{ runner.os }}-dist-${{ github.run_id }}-${{ github.run_attempt }} shard: needs: [plan, build] runs-on: warp-ubuntu-latest-x64-8x strategy: matrix: shard: ${{ fromJSON(needs.plan.outputs.shards) }} steps: - uses: actions/checkout@v5 - name: Restore build output uses: WarpBuilds/cache/restore@v1 with: path: dist key: ${{ runner.os }}-dist-${{ github.run_id }}-${{ github.run_attempt }} fail-on-cache-miss: true - run: ./scripts/test.sh --shard ${{ matrix.shard }} cleanup: needs: shard if: always() runs-on: warp-ubuntu-latest-x64-2x steps: - name: Delete the run-scoped entry uses: WarpBuilds/cache@v1 with: path: dist key: ${{ runner.os }}-dist-${{ github.run_id }}-${{ github.run_attempt }} delete-cache: true ``` `fail-on-cache-miss: true` is the line that turns a silent empty directory into a job that stops at the restore step. The cleanup job is what keeps storage flat, and the Cost or Time Model section prices exactly how much it saves. ### [Runner-local state with snapshot.key](#runner-local-state-with-snapshotkey)Copy Anchor Link The runner-local option carries its configuration in the `runs-on` label after a semicolon, so the whole thing stays one string. `snapshot.key=` boots from an existing snapshot for that alias when one exists and falls back to the base image when none does, which makes the first run of a new alias correct with no special handling. `snapshot.enabled=true` always boots from the base image and is what you pair with `WarpBuilds/snapshot-save@v1` on a branch you control. Copy Text ``` integration: runs-on: warp-ubuntu-latest-x64-16x;snapshot.key=integration-main steps: - uses: actions/checkout@v5 - name: Prime toolchain on a cold machine if: env.WARPBUILD_SNAPSHOT_KEY == '' run: ./scripts/install-toolchain.sh - run: ./scripts/integration.sh ``` A job booted from a snapshot carries `WARPBUILD_SNAPSHOT_KEY` set to the alias, which is how a step branches between the warm path and the cold path. The [snapshot runner documentation](/docs/ci/features/snapshot-runners) covers the `snapshot-save` inputs: `alias` is required, `fail-on-error` defaults to `true`, and `wait-timeout-minutes` defaults to 30\. Snapshots are deleted after 15 days, and `/tmp` does not persist because it is cleaned on reboot. Clean credentials with `rm -rf $HOME/.ssh $HOME/.aws` and `git clean -ffdx` before any save step, since a snapshot preserves whatever a step wrote into a home directory. ### [The same snapshot cannot be relied on across platforms](#the-same-snapshot-cannot-be-relied-on-across-platforms)Copy Anchor Link The runner-local mechanism does not span them. * Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. BYOC runners, Windows runners, and macOS runners are not supported, and a snapshot label on those runner types is silently ignored. A matrix that appends `;snapshot.key=build-main` to `warp-windows-latest-x64-8x` looks configured in the diff, produces no error at run time, and delivers no snapshot behavior. * Cache entries do not cross operating systems either. The cache version is a hash of the compression tool used on the runner OS and the paths being cached, so an entry written on `warp-macos-14-arm64-6x` is a different cache from the consumer's point of view on `warp-ubuntu-latest-x64-4x`. Caching is also not supported on Windows-based WarpBuild runners. * Absolute paths differ across images. Ubuntu 24.04 ARM64 runners set the work directory to `/runner/_work` rather than GitHub's `/home/runner/work/`, so a `path:` list built from an absolute path on one image will not match on the other. The mechanism that does cross every platform is the artifact, which is why a cross-platform handoff, such as a macOS build feeding a Linux packaging job, belongs in `upload-artifact` and `download-artifact` rather than in a cache key. The snapshot API supports listing and deleting aliases from a scheduled job as well as from the console. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Fees for moving state on WarpBuild hosted runners, from the [pricing page](/pricing): | Metric | Hosted rate | BYOC | | ----------------------------- | ------------------------ | ------------- | | Cache storage | $0.20 per GB-month | Free | | Cache write, restore, or list | $0.0001 per operation | Free | | Snapshot restore | $0.04 per job | Not supported | | Snapshot storage | $0.025 per snapshot-hour | Not supported | BYOC runs on AWS, GCP, and Azure, and cache storage and operations carry no WarpBuild fee there, so the model below applies to hosted runners and the transfer minutes are the only line that survives on BYOC. Runner rates for the sizes used in the workflow above, with the nearest GitHub-hosted shapes from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13: | Shape | WarpBuild label | WarpBuild per minute | GitHub-hosted per minute | | -------------- | -------------------------- | -------------------- | ------------------------ | | 2 vCPU, 8 GB | warp-ubuntu-latest-x64-2x | $0.004 | $0.006 | | 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | $0.008 | $0.012 | | 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | $0.016 | $0.022 | | 16 vCPU, 64 GB | warp-ubuntu-latest-x64-16x | $0.032 | $0.042 | ### [Moving 8 GB between one producer and six consumers](#moving-8-gb-between-one-producer-and-six-consumers)Copy Anchor Link Assumptions, stated so you can substitute your own step timings: * One build job and six test shards, all on `warp-ubuntu-latest-x64-8x` at $0.016 per minute. * The build job produces an 8 GB tree that all six shards need. * 40 runs per weekday and 22 weekdays, so 880 runs per month. * Artifact path: 4.0 minutes to compress and upload, 3.0 minutes to download and decompress on each shard. * Cache path: 3.5 minutes to save, 2.5 minutes to restore on each shard. * Snapshot path: the standing dependency tree and prior build outputs are already on disk at boot, so only a 1.2 GB delta moves, at 0.6 minutes to save and 0.45 minutes to restore on each shard. | Line | Artifact handoff | Run-scoped cache | Snapshot plus delta cache | | --------------------------------------- | ----------------------- | ---------------- | ------------------------- | | Transfer minutes per run | 22.0 | 18.5 | 3.3 | | Runner minutes per month | $309.76 | $260.48 | $46.46 | | Cache operations, 7 per run | GitHub artifact billing | $0.62 | $0.62 | | Cache storage, entry deleted at run end | GitHub artifact billing | $0.98 | $0.15 | | Snapshot restores, 7 jobs per run | none | none | $246.40 | | Snapshot storage, one alias | none | none | $18.00 | | Total priced here | $309.76 | $262.08 | $311.63 | ### [The line that surprises people](#the-line-that-surprises-people)Copy Anchor Link Cache storage looks negligible at $0.20 per GB-month until you multiply by retention. A run-scoped entry with no cleanup job lives until it expires 7 days after its last use, and a 7-day window holds five weekdays of runs, so steady state is 200 live entries of 8 GB, or 1,600 GB. At $0.20 per GB-month that is $320.00 per month, which turns the cache path from $262.08 into $581.10 and makes it the most expensive option on the page. With the cleanup job from the Configuration section and a 30-minute workflow, each entry lives half an hour. That is 440 entry-hours across 880 runs, or 0.61 entries live on average in a 720-hour month, which is 4.9 GB and $0.98\. The delete step is three lines of YAML and it is worth $319.02 per month on this workload. ### [Reading the snapshot column](#reading-the-snapshot-column)Copy Anchor Link The snapshot restore fee is charged per job, so a seven-job workflow pays it seven times. Divide $0.04 by $0.016 per minute and the break-even is 2.5 minutes: a snapshot has to remove more than 2.5 minutes of setup from each of those jobs before it wins on `warp-ubuntu-latest-x64-8x`. On this workload it removes the transfer and not much else, so the run-scoped cache with a delete step is the cheaper mechanism. On a workflow where each job installs dependencies, pulls container images, and warms a build daemon, the same fee clears the bar easily. The same 22 minutes of artifact transfer per run on GitHub-hosted runners is priced at $0.022 per minute for the 8 vCPU, 32 GB shape against $0.016 per minute for `warp-ubuntu-latest-x64-8x` at the same 8 vCPU and 32 GB (GitHub billing reference, checked on 2026-08-13). Across 880 runs that is $425.92 against $309.76, a difference of $116.16 per month on transfer minutes alone, before any change to which mechanism you use. Every number above is arithmetic over published rates, with the source and the checked-on date attached, so you can rerun it against your own step timings rather than trusting a headline. ## [FAQ](#faq)Copy Anchor Link ### [How do I pass a value from one GitHub Actions job to another?](#how-do-i-pass-a-value-from-one-github-actions-job-to-another)Copy Anchor Link Write the value to `$GITHUB_OUTPUT` in a step, republish it under the job's `outputs:` block, and read it downstream as `needs..outputs.`. The consumer job needs a `needs` edge on the producer, so the value arrives at the cost of serializing those two jobs. Outputs are strings, they are evaluated when the producing job ends, and values matching a secret are redacted. ### [Can I use a cache to pass files between jobs in the same workflow run?](#can-i-use-a-cache-to-pass-files-between-jobs-in-the-same-workflow-run)Copy Anchor Link Yes, with a key scoped to `github.run_id` and `github.run_attempt`, and it is best effort rather than a contract. A cache entry is scoped to the key, the cache version, and the branch, and it expires 7 days after its last use, so set `fail-on-cache-miss` to `true` on the consumer and delete the entry when the run finishes. When the consumer cannot proceed without the payload, an artifact is the mechanism that guarantees delivery. ### [Why did a cache saved on a macOS job fail to restore on a Linux job?](#why-did-a-cache-saved-on-a-macos-job-fail-to-restore-on-a-linux-job)Copy Anchor Link The cache version is a hash of the compression tool used on the runner OS and the paths being cached, and entries with different versions are treated as different caches during matching. A cache written on `warp-macos-14-arm64-6x` therefore cannot restore on `warp-ubuntu-latest-x64-4x`. Caching is also not supported on Windows-based WarpBuild runners. ### [Do snapshot runners work for sharing state on Windows or macOS jobs?](#do-snapshot-runners-work-for-sharing-state-on-windows-or-macos-jobs)Copy Anchor Link No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, on x64 and ARM64\. BYOC runners, Windows runners, and macOS runners are not supported, and a snapshot label on those runner types is silently ignored, so the job runs normally with no snapshot behavior and no error to alert you. --- # Sharing a Docker Layer Cache Across Jobs | WarpBuild URL: https://www.warpbuild.com/guides/share-docker-cache-between-jobs Description: Two GitHub Actions jobs share a Docker layer cache only when both build on one persistent builder. Point every job at the same WarpBuild builder profile. # Sharing a Docker Layer Cache Across Jobs Two GitHub Actions jobs share a Docker layer cache only when both build on one persistent builder. Point every job at the same WarpBuild builder profile. Last verified: 2026-08-13 Two GitHub Actions jobs share a Docker layer cache only when both builds run on a builder that outlives either job, and a buildx instance created inside a job is never that builder. On WarpBuild the shared builder is a builder profile: one dedicated Docker builder VM with a persistent layer cache on local disk, selected from the workflow by `profile-name`, so every job naming the same profile builds against the same cache whichever workflow, branch, or repository it came from. Per-minute rates for job runners and builder profiles are on the [pricing page](/pricing), and the model at the end works a fan-out through them. This guide covers the three reasons jobs do not share layers today, the profile model that fixes it, the concurrency and consistency boundaries you need to know before you fan out, and a session billing example with real rates. For the wider picture, start at [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link Three separate mechanisms keep two jobs from sharing layers. They stack, and each one alone is enough to make every job rebuild from scratch. ### [A fresh VM per job](#a-fresh-vm-per-job)Copy Anchor Link Every GitHub Actions job is provisioned on its own machine and that machine is destroyed when the job ends. A layer cache is disk state, so it lives and dies with the machine underneath the job that produced it. The consequence shows up hardest in a matrix. Three jobs building three service images off the same commit run on three machines. They share a base image, they usually share a dependency stage, and they each build both from scratch because none of them can read the other two disks. Check this against your own logs before changing anything. Open the second and third matrix job of a green run and count `CACHED` lines against the first job's log. If all three logs execute the same base stage with no `CACHED` prefix, the jobs never shared anything and no Dockerfile change will make them. ### [Per-job buildx state](#per-job-buildx-state)Copy Anchor Link `docker/setup-buildx-action` creates a builder inside the job. The container backing that builder is created when the step runs and removed when the job ends, and its layer store, its BuildKit history, and any cache mounts written by `RUN --mount=type=cache` go with it. There is also no address to share. A buildx builder created in job A exists only for the life of job A, so job B has nothing to point at even if it wanted to. Two steps inside one job share a builder. Two jobs never do by default. ### [Cache scoping that does not cross branches](#cache-scoping-that-does-not-cross-branches)Copy Anchor Link The usual workaround is an external cache backend through `cache-from` and `cache-to`. That moves the layers off the machine, and then scoping rules decide who can read them. GitHub's cache isolation means a branch reads its own scope and the default branch scope, and never another branch's scope. A pull request build therefore cannot read what a sibling pull request wrote, which is exactly the sharing most teams are trying to get. Fan-out makes it worse in a second way: matrix jobs that start at the same time all read the scope as it existed before any of them wrote, because `cache-to` runs at the end of a build. Concurrent jobs write over each other and the last writer wins. The net effect is that an external backend gives you sharing across time on one branch, at the cost of an export and import round trip per build, and gives you close to nothing across concurrent jobs. The tradeoffs between the individual backends are worked through in [buildx cache backends for GitHub Actions](/guides/buildx-cache-backends-github-actions). A fourth cause is worth ruling out while you are here. If jobs on one branch also miss against their own earlier runs, the problem is layer invalidation rather than sharing, and [why a Docker layer cache invalidates on GitHub Actions](/guides/docker-cache-invalidation-github-actions) covers the Dockerfile ordering and lockfile churn behind it. ## [Fix](#fix)Copy Anchor Link Move the build off the job runner and onto a builder profile that both jobs name. A [WarpBuild builder profile](/runners/docker-builders) is one optimized, dedicated Docker builder VM with caching. Multiple jobs can use the same profile in parallel, and those builds run on that same VM in parallel against the same layer cache. The cache belongs to the profile rather than to a repository, a branch, or a workflow, so sharing follows the profile name and nothing else. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface. Here is a three service fan-out where every job shares one cache: Copy Text ``` name: images on: pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-2x strategy: fail-fast: false matrix: service: [api, worker, scheduler] steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: Warpbuilds/build-push-action@v6 with: context: . file: services/${{ matrix.service }}/Dockerfile push: true tags: ghcr.io/acme/${{ matrix.service }}:${{ github.sha }} profile-name: platform-amd64 timeout: 600000 ``` Three points about that file. The three matrix jobs run on three job runners and one builder. Whatever the `api` build pulls and installs lands on the profile disk, and the `worker` and `scheduler` builds reuse it on the next run. There is no `docker/setup-buildx-action` step, because the WarpBuild action configures the remote builder itself. There is no `cache-from` or `cache-to` either. A cached profile keeps its layers on local disk and reuses them for subsequent builds, so the export and import round trip buys nothing and costs wall clock. The job runner is small on purpose. With the build executing on the builder, the job checks out the repository, authenticates to the registry, and waits, so `warp-ubuntu-latest-x64-2x` carries it. Pick a Linux runner here, since macOS runners do not support nested virtualization and cannot run Docker. Sharing extends past one workflow. A nightly job, a release workflow on the default branch, and a developer running `Warpbuilds/build-push-action` from a GitHub-hosted runner with an API key all reach the same cache when they name `platform-amd64`. The job runners driving those builds can be WarpBuild-hosted runners or BYOC runners in your own AWS, GCP, or Azure account, and the profile is addressed the same way from either. ## [Configuration](#configuration)Copy Anchor Link ### [Inputs that decide what gets shared](#inputs-that-decide-what-gets-shared)Copy Anchor Link | Input | Value | Effect on sharing | | ------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | profile-name | Name of the builder profile | The sharing key. Same name means same VM and same cache. Two names never share. | | api-key | ${{ secrets.WARPBUILD\_API\_KEY }} | Required when the job runs on a GitHub-hosted runner or any machine outside WarpBuild. Not required on WarpBuild runners. | | platforms | linux/amd64,linux/arm64 | Each architecture runs on a separate builder instance with its own cache. Enable both on the profile first, otherwise the build fails with exec format error. | | timeout | Milliseconds to wait for the builder | Defaults to 10 minutes. Raise it when a burst of jobs queues behind a busy profile. | If your build has custom steps between setup and `docker build`, use `Warpbuilds/docker-configure@v1` and keep your own build command. Invoke it immediately before the build step, since the builder is billed from the moment it is assigned. ### [Sizing a profile for concurrent jobs](#sizing-a-profile-for-concurrent-jobs)Copy Anchor Link There is no limit on how many builds run concurrently on a profile, and the documented recommendation is roughly 8 vCPU and 16 GB of memory per concurrent build job. That turns profile size into a concurrency budget: | Profile size | Disk | Price per minute | Concurrent build jobs at the recommended minimum | | ---------------- | ----- | ---------------- | ------------------------------------------------ | | 16 vCPU, 32 GB | 100GB | $0.06 | 2 | | 32 vCPU, 64 GB | 200GB | $0.12 | 4 | | 64 vCPU, 128 GB | 200GB | $0.24 | 8 | | 96 vCPU, 192 GB | 600GB | $0.36 | 12 | | 96 vCPU, 192 GB | 2TB | $0.52 | 12 | | 192 vCPU, 384 GB | 600GB | $0.72 | 24 | | 192 vCPU, 384 GB | 2TB | $0.88 | 24 | Rates are from the [pricing page](/pricing) and the [remote Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13\. The final column is arithmetic on the documented 8 vCPU recommendation rather than a measurement, so treat it as a starting point and watch your build durations as concurrency climbs. arm64 and multi-arch profiles cap at 64 vCPU; the 96 vCPU and 192 vCPU sizes are amd64 only. Capacity on the runner side is separate and unconstrained. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. ### [What concurrent builds actually see](#what-concurrent-builds-actually-see)Copy Anchor Link This is the boundary to understand before you fan out fifteen jobs at one profile. The cache is shared between concurrent builds and it is eventually consistent. A layer produced by one in-flight build may not be visible to another build running at the same moment, and it becomes visible to later builds once synchronization occurs. A cold fan-out therefore builds the shared base stage in more than one job, and the run after it reuses a single copy. Three consequences follow. First, a cold profile costs you once, not forever. The first fan-out after a base image bump pays for parallel duplicate work; the next one does not. Second, serializing a warmup build ahead of the fan-out is often the wrong trade under session billing, because a session bills wall clock rather than CPU. Three cold builds running side by side finish in one build's wall clock. A warmup job that runs first and then hands off to the matrix adds its own duration to the total. The warmup wins only when the concurrent jobs would contend for the profile's vCPU, which the sizing table above is there to prevent. Third, cache scope is the profile. One profile per image gives each image a dense cache and more sessions to pay for. One profile across a monorepo's images gives fewer sessions and a cache that has to hold more. Split when images have different base layers, share when they do not. The builder cache has a TTL of 10 days, and a profile that goes unused for longer is reset automatically. You can also reset it through the API after a base image change leaves a large volume of dead layers: Copy Text ``` curl -s -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles?per_page=30&page=1" curl -s -X POST \ -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/builder-profiles/$BUILDER_PROFILE_ID/cache/reset" ``` Dependency caches that live outside the Docker build follow different rules and are covered in the [WarpBuild caching documentation](/docs/ci/features/caching). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Everything below is arithmetic on stated assumptions rather than a measurement. Replace each duration with the numbers printed in your own job logs before you make a decision on it. ### [How a session is billed when jobs overlap](#how-a-session-is-billed-when-jobs-overlap)Copy Anchor Link A builder session is measured from when the builder action starts until the job completes, and concurrent jobs on one profile share a single session billed from the first job's start to the last job's completion. Take two jobs on one 16 vCPU profile at $0.06 per minute: | Event | Time | Job | | ---------------- | -------- | --- | | Builder assigned | 10:00:00 | J1 | | Builder assigned | 10:02:00 | J2 | | Job completes | 10:06:00 | J1 | | Job completes | 10:09:00 | J2 | J1 occupies the builder for 6 minutes and J2 for 7 minutes, which is 13 minutes of build time. The bill is one session from 10:00:00 to 10:09:00, so 9 minutes at $0.06 equals $0.54 rather than $0.78\. Overlap is the lever: the more of your fan-out that runs at the same time, the more build minutes ride inside one session. Two footnotes on that. A multi-arch profile creates one session per architecture, each billed independently, so an amd64 plus arm64 build of the same image is two sessions. And the job runner and the builder are two separate resources, so both appear on the bill. ### [A monthly fan-out model](#a-monthly-fan-out-model)Copy Anchor Link Assumptions: * Three service images built from a shared base stage, 200 pull request runs per month, so 600 image builds. * Matrix jobs start 0.5 minutes apart, because runner assignment is not simultaneous. * A warm build occupies the builder for 2.5 minutes and a cold one for 7.0 minutes. One run in ten is cold: 20 cold runs, 180 warm. * Job runner overhead of 0.5 minutes per job for checkout, registry login, and action setup. * Durations are held constant across both options, so the only variable is how many sessions the work is spread over. With one shared profile, a warm run bills from the first job's start at 0.0 to the last job's completion at 3.5 minutes, and a cold run bills 8.0 minutes: | Line | Minutes | Rate | Cost | | -------------------------------------- | ------- | ------ | ------ | | Builder, 180 warm runs at 3.5 min | 630 | $0.06 | $37.80 | | Builder, 20 cold runs at 8.0 min | 160 | $0.06 | $9.60 | | Job runners, warp-ubuntu-latest-x64-2x | 2,070 | $0.004 | $8.28 | | Total | $55.68 | | | With one profile per service, the same work spreads over three sessions per run, so warm runs bill 3 x 2.5 minutes and cold runs 3 x 7.0 minutes: | Line | Minutes | Rate | Cost | | -------------------------------------- | ------- | ------ | ------ | | Builder, 180 warm runs at 7.5 min | 1,350 | $0.06 | $81.00 | | Builder, 20 cold runs at 21.0 min | 420 | $0.06 | $25.20 | | Job runners, warp-ubuntu-latest-x64-2x | 2,070 | $0.004 | $8.28 | | Total | $114.48 | | | The shared profile saves $58.80 per month on these assumptions, and the saving comes entirely from session overlap rather than from faster builds. Split the profile anyway when the three images share no base layers, since then the shared cache holds three unrelated working sets on one disk and eviction starts costing you hit rate. For a baseline, a GitHub-hosted `ubuntu-latest` job on a private repository is billed at $0.006 per minute from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. That runner gives the fan-out no shared builder at all, so each of the three jobs carries the full cold build every time unless you add an external cache backend and accept its scoping rules. Every cost number here carries its rate, its source, and a checked-on date, and the same rates appear on the [pricing page](/pricing). The one flat item on the platform is SSO, which costs $250 per month whatever the user count. ## [FAQ](#faq)Copy Anchor Link ### [Can two GitHub Actions jobs share the same Docker layer cache?](#can-two-github-actions-jobs-share-the-same-docker-layer-cache)Copy Anchor Link Only if both builds run somewhere that outlives a single job. A buildx instance created inside a job dies with the job, so a second job has nothing to attach to. Give both jobs the same WarpBuild builder profile through `profile-name` and they build against one VM and one layer cache, in the same workflow or in different repositories. ### [Do concurrent builds on one profile see each other's layers?](#do-concurrent-builds-on-one-profile-see-each-others-layers)Copy Anchor Link Not reliably while they are still running. The cache on a builder profile is shared but eventually consistent, so a layer produced by one in-flight build may not be visible to another until it synchronizes. Later builds pick it up, which means a fan-out that starts cold can build the same shared stage more than once. ### [How is a builder profile billed when several jobs use it at once?](#how-is-a-builder-profile-billed-when-several-jobs-use-it-at-once)Copy Anchor Link Per session. Concurrent jobs on one profile are billed as a single session measured from the first job's builder start to the last job's completion, so two overlapping six and seven minute builds bill as one nine minute session rather than thirteen minutes. Multi-arch profiles run one session per architecture, and the job runner and the builder are two separate resources on the bill. ### [How large should a builder profile be for a matrix of jobs?](#how-large-should-a-builder-profile-be-for-a-matrix-of-jobs)Copy Anchor Link Size it against concurrency. The documented recommendation is roughly 8 vCPU and 16 GB of memory per concurrent build job, so a 16 vCPU, 32 GB profile comfortably carries two builds at once and a 64 vCPU, 128 GB profile carries eight. arm64 and multi-arch profiles cap at 64 vCPU. --- # Sizing Runners for Android Builds | WarpBuild URL: https://www.warpbuild.com/guides/sizing-runners-for-android-builds Description: Size an Android runner by subtracting the emulator first: it holds guest cores and RAM all job. Sizing table, warp- labels, the KVM label, and cost math. # Sizing Runners for Android Builds Size an Android runner by subtracting the emulator first: it holds guest cores and RAM all job. Sizing table, warp- labels, the KVM label, and cost math. Last verified: 2026-08-13 Android assemble and unit test jobs belong on 8 vCPU with 32GB of memory, which is `warp-ubuntu-latest-x64-8x` at $0.016 per minute, and an instrumented job that boots one emulator holds at that size only until a second device or a wide module graph arrives, at which point it moves to `warp-ubuntu-latest-x64-16x` at $0.032 per minute. The reason is that an emulator is a virtual machine with a fixed claim on the runner: the cores and RAM you pass to it are gone from Gradle for the whole job, so you size the build first and then add the device on top. This guide gives the sizing table across the Linux x64 ladder, the memory and core arithmetic an emulator adds, and the workflow YAML that pins the label, the worker count, and the KVM permissions step that emulator jobs require. ## [Diagnosis](#diagnosis)Copy Anchor Link An Android pipeline runs three workloads with different resource shapes, and only some of them convert cores into shorter runs. * Assemble: Kotlin and Java compilation, AAPT2 resource processing, and D8 or R8 dexing, parallel across modules and bounded by `org.gradle.workers.max`. * Unit tests: parallel across forked JVMs, bounded by `maxParallelForks` on each test task. * Instrumented tests: one or more emulators running Android system images, with Gradle, adb, and the instrumentation runner working beside them on the host. Two defaults set the trap. Gradle derives `org.gradle.workers.max` from the number of processors it sees, documented in the [Gradle build environment reference](https://docs.gradle.org/current/userguide/build%5Fenvironment.html), so a label change silently changes the worker count. The JVM then sets its default maximum heap to one quarter of physical memory when nothing overrides it, which on a 32GB runner is 8GB per process, and the Kotlin compile daemon is a separate JVM with its own heap set through `kotlin.daemon.jvmargs` in the [Kotlin Gradle plugin documentation](https://kotlinlang.org/docs/gradle-compilation-and-caches.html). The emulator adds a claim neither default accounts for. `emulator -cores 4 -memory 4096` gives the guest 4 vCPU and 4096MB of RAM, both documented on the [Android emulator command line reference](https://developer.android.com/studio/run/emulator-commandline), and both are committed from device boot until the job ends. Add roughly 2GB for the QEMU process, adb, and the page cache holding the system image, and one device costs 4 vCPU and 6GB before Gradle compiles anything. Three symptoms follow, and they read as unrelated until the heaps are added up. The job ends with exit code 137, which is the kernel OOM killer. Instrumented tests fail on install rather than on assertion, because the package manager is swapping while the test APK lands. Wall clock rises while CPU utilization stays flat, which is GC pressure or a device waiting on a contended core. One failure mode no runner size repairs: an emulator running without hardware acceleration. The log signature is `ProbeKVM` reporting no permission on /dev/kvm, followed by a line disabling Linux hardware acceleration and an emulator command line containing `-accel off`. Fix acceleration first, as described on [Android emulator tests on GitHub Actions](/solutions/android-emulator-tests-github-actions), then size the machine. CI observability reports per-job CPU and memory utilization, which is how you separate a starved runner from a misconfigured one. ## [Fix](#fix)Copy Anchor Link Size from a budget. Committed memory equals the Gradle daemon heap, plus the Kotlin daemon heap, plus `maxParallelForks` multiplied by the fork heap, plus 6GB for each emulator running with `-memory 4096`. Keep the total at or under two thirds of the machine's RAM, and subtract the emulator `-cores` value from the vCPU count before choosing a worker count. This applies the rule across the Linux x64 ladder in the [cloud runners documentation](/docs/ci/cloud-runners), with per-minute rates from the [pricing page](/pricing), checked on 2026-08-13\. Every row carries 150GB of SSD storage. | Runner label | vCPU | RAM | Rate per minute | Workers on a build job | Emulators at \-cores 4 -memory 4096 | Workers left with one emulator | Nested virtualization | | -------------------------- | ---- | ----- | --------------- | ---------------------- | ----------------------------------- | ------------------------------ | -------------------------------------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | $0.004 | 2 | 0 | none | Label accepted, device unusable at this size | | warp-ubuntu-latest-x64-4x | 4 | 16GB | $0.008 | 4 | 1 at \-cores 2 -memory 3072 | 2 | Yes, with the label | | warp-ubuntu-latest-x64-8x | 8 | 32GB | $0.016 | 8 | 1 | 4 | Yes, with the label | | warp-ubuntu-latest-x64-16x | 16 | 64GB | $0.032 | 12 | 2 | 12 | Yes, with the label | | warp-ubuntu-latest-x64-32x | 32 | 128GB | $0.064 | 16 | 4 | 16 | Yes, with the label | The 8x row is the working default, and the arithmetic behind it is: a 6g Gradle daemon heap, a 2g Kotlin daemon heap, and two test forks at 3g each is 14GB, and one emulator adds 6GB for a total of 20GB against a 21.3GB two-thirds ceiling on 32GB. The same budget on the 4x row overflows: 4g plus 1g plus one 2g fork is 7GB, and a device at `-memory 3072` adds 5GB for 12GB against a 10.7GB ceiling, which is why an emulator job that fits on paper at 4 vCPU spends its time in GC and adb timeouts. Nested virtualization is where the label differs from every other sizing decision. Emulator jobs run on Linux x86-64 cloud runners with `;nested-virtualization.enabled=true` appended to the `warp-` label, and the [nested virtualization reference](/docs/ci/features/nested-virtualization) carries the full support matrix. ARM64, Windows, and macOS labels ignore the suffix silently, so an emulator job placed there runs in software emulation with a workflow file that looks correct. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the ARM64 ladder remains a good home for assemble and unit test jobs that never boot a device. The catalog view of which sizes carry the feature is on [runners with nested virtualization](/runners/nested-virtualization). The rate lever works without any assumption about the build getting quicker. `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. Both GitHub list prices come from the [per-minute rate reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. ## [Configuration](#configuration)Copy Anchor Link Set the worker count, every heap, and the device shape explicitly, because all three are derived from the machine by default and a label change moves them together. Copy Text ``` org.gradle.parallel=true org.gradle.workers.max=4 org.gradle.caching=true org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g kotlin.daemon.jvmargs=-Xmx2g ``` The worker count is 4 rather than 8 because the emulator holds the other four cores. Build-only jobs that run on the same repository pass `--max-workers=8` on the command line to take them back. The instrumented job pins the label, the KVM suffix, the permissions step, and the device shape: Copy Text ``` name: android-instrumented-tests on: pull_request: push: branches: [main] jobs: instrumented-tests: runs-on: warp-ubuntu-latest-x64-8x;nested-virtualization.enabled=true steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" - uses: gradle/actions/setup-gradle@v4 - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Confirm the device is writable run: test -w /dev/kvm - uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 target: google_apis arch: x86_64 cores: 4 ram-size: 4096M emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-snapshot-save script: ./gradlew connectedDebugAndroidTest --max-workers=4 ``` The permissions step is standard Android emulator setup on all GitHub and GitHub-compatible runners, documented with [reactivecircus/android-emulator-runner](https://github.com/ReactiveCircus/android-emulator-runner), and it is required because the default device permissions of `crw-rw---- root:kvm` stop the `runner` user from opening /dev/kvm. The `test -w /dev/kvm` line turns the most common misconfiguration into a failed step at the top of the log rather than a long test run at the bottom. Assemble and unit test jobs in the same workflow stay on a plain `warp-ubuntu-latest-x64-8x` label with `--max-workers=8`. The surrounding pipeline is covered on [Android builds on GitHub Actions](/solutions/android-github-actions). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Take a repository running 600 pipeline runs per month with one instrumented job per run. The model assumes that job takes 24 minutes on `warp-ubuntu-latest-x64-8x` with one device, and that splitting the suite across two devices on `warp-ubuntu-latest-x64-16x` brings it to 14 minutes, since the shared assemble, install, and boot work does not halve. Substitute your own durations; the rates are fixed. | Option | Arithmetic | Monthly cost | | --------------------------------------------- | --------------------- | ------------ | | warp-ubuntu-latest-x64-8x, one emulator | 600 x 24 min x $0.016 | $230.40 | | warp-ubuntu-latest-x64-16x, two emulators | 600 x 14 min x $0.032 | $268.80 | | GitHub-hosted 8-core, same 24-minute profile | 600 x 24 min x $0.022 | $316.80 | | GitHub-hosted 16-core, same 14-minute profile | 600 x 14 min x $0.042 | $352.80 | Read two decisions off that table. Against the same shape, the label change moves the bill by $86.40 per month at 8 vCPU and $84.00 per month at 16 vCPU, with durations held equal on both platforms. Against the size change, the wider machine returns 10 minutes per run for $38.40 per month, which is 6,000 minutes of engineer waiting removed at $0.0064 per minute returned. The break-even is arithmetic rather than judgment. The 16x rate is exactly double the 8x rate, so the two rows cost the same when the wider machine finishes in 24 x ($0.016 / $0.032) = 12.0 minutes. Below 12 minutes the wider machine wins on both wall clock and invoice, and above it you are buying feedback time. Emulator jobs rarely clear that bar, because AVD boot, APK install, and the serial tail of instrumentation do not shrink with cores, which is the practical argument for holding at 8x until the module graph or a second device forces the move. Once the pipeline is live, replace these assumptions with measured numbers from CI observability, and use [right-sizing GitHub Actions runners from measured usage](/guides/runner-right-sizing-github-actions) for the fleet-wide version of this exercise. Snapshot runners, remote Docker builders, an MCP server, and the Action Debugger cover the rest of the toolchain, and the Action Debugger is the fastest way to inspect /dev/kvm and live adb state on the machine actually running the job. ## [FAQ](#faq)Copy Anchor Link ### [What size runner should an Android build use?](#what-size-runner-should-an-android-build-use)Copy Anchor Link Assemble and unit test jobs start at `warp-ubuntu-latest-x64-8x`, which is 8 vCPU and 32GB at $0.016 per minute. Instrumented jobs that boot an emulator start at the same size and move to `warp-ubuntu-latest-x64-16x` at $0.032 per minute when a second device or a wide module graph shares the machine. ### [How much does the emulator take away from Gradle?](#how-much-does-the-emulator-take-away-from-gradle)Copy Anchor Link Whatever you give it. An emulator launched with `-cores 4 -memory 4096` holds 4 vCPU and 4GB of guest RAM for the life of the job, and a further 2GB budget covers the QEMU process, adb, and the system image page cache. Size the machine for the build, then add 4 vCPU and 6GB per device. ### [Do assemble and unit test jobs need the nested virtualization label?](#do-assemble-and-unit-test-jobs-need-the-nested-virtualization-label)Copy Anchor Link No. Only jobs that boot an emulator need /dev/kvm, so `nested-virtualization.enabled=true` belongs on the instrumented test job alone. Adding it to compile-only jobs constrains scheduling for nothing. --- # Sizing Runners and Builders for Docker Builds | WarpBuild URL: https://www.warpbuild.com/guides/sizing-runners-for-docker-builds Description: Size the job runner for checkout and waiting, then size the remote Docker builder for the layer cache. Two size ladders, a decision rule, and the arithmetic. # Sizing Runners and Builders for Docker Builds Size the job runner for checkout and waiting, then size the remote Docker builder for the layer cache. Two size ladders, a decision rule, and the arithmetic. Last verified: 2026-08-13 For a Docker build on GitHub Actions there are two machines to size, and they answer different questions: the job runner needs enough capacity for checkout, registry login, and whatever else the job does, while the remote Docker builder needs enough capacity for the compile and the layer cache working set. The common shape is a small job runner such as `warp-ubuntu-latest-x64-2x` at $0.004 per minute paired with a 16 vCPU builder profile at $0.06 per minute, from the [WarpBuild pricing page](/pricing). Remote Docker builders sit in the WarpBuild product surface alongside snapshot runners, CI observability, an MCP server, and the Action Debugger. This guide gives you the two size ladders with their rates, the rule for deciding which machine does the building, the workflow configuration for each shape, and the arithmetic for one build at four different shapes. For the wider picture, start at [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link Before picking a size, work out which resource is actually short. Four measurements from your own job log settle it. **Cold cache, not cores.** Read the build log for `CACHED` prefixes. If a commit that touched one source file still reruns the dependency install and the base image pull, the machine is idle-starved on cache rather than on CPU, and moving from a 4 vCPU runner to a 16 vCPU runner buys you a faster rebuild of work that should not have run at all. That is the case a builder profile fixes. **Disk.** WarpBuild Linux runners carry 150GB SSD, and runner storage is ephemeral and deleted when the runner terminates. A multi-stage build of a large image holds the base image, every intermediate stage, and the export at the same time. Builder profiles carry their own disk, from 100GB on the 16 vCPU size to 2TB on the largest amd64 sizes, and that disk holds the persistent cache across runs. **Memory.** Parallel compilers scale memory with jobs. If the build dies with an out-of-memory kill at high `-j` values, the fix is a shape with more RAM rather than more minutes. Every runner size doubles vCPU and RAM together, so RAM per core is constant across the ladder and the only way to buy RAM is to buy cores. **Concurrency on one profile.** WarpBuild documents approximately 8 vCPU and 16 GB memory per build job as the recommended minimum for a builder profile. A 16 vCPU, 32 GB profile therefore fits about two concurrent build jobs at that minimum, a 32 vCPU profile about four, and a 64 vCPU profile about eight. Count the builds that overlap on a busy afternoon and size against that number rather than against a single build. One platform check before you go further: macOS runners do not support nested virtualization and cannot run Docker. Docker jobs run on Linux or Windows runners, and the builder profile is Linux regardless. ## [Fix](#fix)Copy Anchor Link The decision rule is short. Build on the job runner while the layers are small and the cache adds little. Move the build to a builder profile when the cache is the value. Concretely, keep the build in the job when the image is under about 1 GB, the cold build finishes in about two minutes, one architecture is enough, and fewer than a handful of builds run per day. In that world an in-job buildx instance rebuilds cheaply and a persistent cache saves less than the second machine costs. Move to a builder profile when any of these hold: the dependency layer takes longer to rebuild than the rest of the build, the image is multiple gigabytes, you build amd64 and arm64 from the same Dockerfile, or several jobs build the same image at once. In that world the cache is the product and the builder is the thing that owns it, because the cache lives on the builder disk between runs while the job runner is reset every job. ### [The two ladders](#the-two-ladders)Copy Anchor Link Linux x64 job runners, from the [cloud runners documentation](/docs/ci/cloud-runners): | Runner label | vCPU and RAM | Storage | Per minute | Where it fits | | -------------------------- | --------------- | --------- | ---------- | ---------------------------------------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | 150GB SSD | $0.004 | Job calls a builder profile and waits | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | 150GB SSD | $0.008 | Small image built inside the job | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | 150GB SSD | $0.016 | In-job build at the documented 8 vCPU, 16 GB minimum | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | 150GB SSD | $0.032 | In-job build plus tests in the same job | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | 150GB SSD | $0.064 | Rare for one image; use a builder profile instead | Linux ARM64 runs the same ladder at a lower rate, from `warp-ubuntu-latest-arm64-2x` at $0.003 per minute to `warp-ubuntu-latest-arm64-32x` at $0.048 per minute, which makes ARM64 the cheaper choice for the job that only orchestrates a builder. Remote Docker builder profiles, from the [Docker builders documentation](/docs/ci/docker-builders): | Builder size | Disk | Per minute | Architectures | Concurrent build jobs at the documented minimum | | ---------------- | ----- | ---------- | ------------------- | ----------------------------------------------- | | 16 vCPU, 32 GB | 100GB | $0.06 | amd64, arm64, multi | About 2 | | 32 vCPU, 64 GB | 200GB | $0.12 | amd64, arm64, multi | About 4 | | 64 vCPU, 128 GB | 200GB | $0.24 | amd64, arm64, multi | About 8 | | 96 vCPU, 192 GB | 600GB | $0.36 | amd64 | About 12 | | 96 vCPU, 192 GB | 2TB | $0.52 | amd64 | About 12 | | 192 vCPU, 384 GB | 600GB | $0.72 | amd64 | About 24 | | 192 vCPU, 384 GB | 2TB | $0.88 | amd64 | About 24 | Pair them by reading across. A 2 vCPU runner with a 16 vCPU profile covers one service image on a busy repository. A 2 vCPU runner with a 32 vCPU profile covers a monorepo where four pull request builds land together. Disk is the second axis: pick the 200GB sizes when one profile serves several large images, because a full disk evicts layers and an evicted layer is a cold layer. ## [Configuration](#configuration)Copy Anchor Link The orchestrating shape puts a small runner in front of a builder profile: Copy Text ``` name: docker on: pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/api:${{ github.sha }} profile-name: api-amd64 timeout: 600000 ``` The in-job shape keeps buildx inside the job and sizes the runner for the build itself: Copy Text ``` jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/api:${{ github.sha }} ``` Four configuration facts change the sizing arithmetic: * Builder profiles are billed per session, measured from when the builder action starts until the job completes. Jobs that overlap on one profile share a single session, billed from the first job's start to the last job's completion. * Multi-arch builds run each architecture on a separate builder instance, so one multi-arch profile opens two sessions that are billed independently. Enable both architectures on the profile, or the build fails with `exec format error`. * The builder cache has a TTL of 10 days, and a profile unused for longer is reset automatically. A profile that only serves a quiet repository pays the cold path more often than the sizing model suggests. * Invoke `Warpbuilds/docker-configure@v1` immediately before the build step when you drive `docker build` yourself, since billing starts at assignment. Its default wait is 5 minutes, against 10 minutes for `Warpbuilds/build-push-action`. Layer ordering decides how much of the cache you get to use at any size. That work belongs in the Dockerfile and is covered in [Dockerfile optimization for GitHub Actions](/guides/dockerfile-optimization-github-actions). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The durations below are stated assumptions rather than measurements. Replace them with the step times printed in your own job log, then re-run the arithmetic. Assumptions: a service image of 1.4 GB with seven layers; 5.0 minutes of cold build work that scales with cores and 3.0 minutes of fixed work for the base pull, export, and push that does not; 0.5 minutes of job overhead for checkout and registry login; a warm build on a builder profile that rebuilds the application layer only, at 1.2 minutes with 0.5 minutes of overhead on the job runner. Cost of one build at four shapes, using the rates in the ladders above: | Shape | Job minutes | Builder minutes billed | Arithmetic | Cost per build | | ------------------------------------------------------------------------------------ | ----------- | ---------------------- | --------------------------- | -------------- | | A. warp-ubuntu-latest-x64-4x, build in the job, cold cache | 8.5 | 0 | 8.5 x $0.008 | $0.068 | | B. warp-ubuntu-latest-x64-8x, build in the job, cold cache | 6.0 | 0 | 6.0 x $0.016 | $0.096 | | C. warp-ubuntu-latest-x64-2x plus a 16 vCPU profile, warm cache, one job per session | 1.7 | 1.5 | 1.7 x $0.004 + 1.5 x $0.06 | $0.0968 | | D. Same as C with four jobs sharing one 3.0 minute session | 1.7 | 0.75 per build | 1.7 x $0.004 + 0.75 x $0.06 | $0.0518 | Three readings come out of that table. Shape B costs more than shape A even though it finishes sooner, because doubling cores doubles the rate while only the 5.0 minutes of scaling work halves. Shape C costs about the same as shape B and returns the pull request in 1.7 minutes instead of 6.0, so the money buys wall clock. Shape D is where the builder becomes the cheaper option outright, because the session cost divides across the builds inside it. The crossover is arithmetic you can run for your own repository. Builder cost per build is session minutes multiplied by the profile rate, divided by the builds inside the session. At $0.06 per minute, four builds in a 3.0 minute session cost $0.045 each, and eight builds in a 5.0 minute session cost $0.0375 each. Stepping up to the 32 vCPU profile at $0.12 per minute doubles that number, so the larger profile pays for itself only when the extra cores shorten the session or when concurrency exceeds what 16 vCPU covers at the documented per-job minimum. One caution on shared sessions: the cache is shared but eventually consistent, so a layer produced by one in-flight build may not reach another until it syncs. Later builds pick it up. Batching builds onto one profile lowers the per-build session cost without guaranteeing that simultaneous builds reuse each other's fresh layers. Both machines appear on the invoice, which is the point of [paying for the runner and the Docker builder](/answers/do-i-pay-for-both-the-runner-and-the-docker-builder) separately. Every rate on this page comes from the [pricing page](/pricing) and was checked on 2026-08-13. ## [FAQ](#faq)Copy Anchor Link ### [What size runner should I use when the Docker build runs on a remote builder?](#what-size-runner-should-i-use-when-the-docker-build-runs-on-a-remote-builder)Copy Anchor Link A 2 vCPU Linux runner is usually enough. The job checks out the repository, authenticates to the registry, calls the builder, and waits, so `warp-ubuntu-latest-x64-2x` at $0.004 per minute or `warp-ubuntu-latest-arm64-2x` at $0.003 per minute carries the work. Add cores only if the same job also runs tests or packaging steps. ### [Does a bigger job runner make the Docker build faster?](#does-a-bigger-job-runner-make-the-docker-build-faster)Copy Anchor Link Only while the build runs inside the job. Once the build runs on a builder profile, the size of the Docker machine is not limited by or related to the size of the GitHub runner, and the builder profile size is the lever. WarpBuild documents approximately 8 vCPU and 16 GB memory per concurrent build job as the recommended minimum for a builder profile. ### [Can arm64 and multi-arch profiles use the largest builder sizes?](#can-arm64-and-multi-arch-profiles-use-the-largest-builder-sizes)Copy Anchor Link No. arm64 and multi-arch builder profiles top out at 64 vCPU, and the 96 vCPU and 192 vCPU sizes are amd64 only. Each architecture in a multi-arch build runs on a separate builder instance, so a multi-arch profile opens one billed session per architecture. --- # Sizing Runners for Java and Gradle Builds | WarpBuild URL: https://www.warpbuild.com/guides/sizing-runners-for-java-builds Description: Size a Gradle runner from the memory budget first: daemon heap plus forked test heaps must fit under RAM. Sizing table, warp- labels, rates, and cost math. # Sizing Runners for Java and Gradle Builds Size a Gradle runner from the memory budget first: daemon heap plus forked test heaps must fit under RAM. Sizing table, warp- labels, rates, and cost math. Last verified: 2026-08-13 Most Java and Gradle builds belong on 8 vCPU with 32GB of memory, which is `warp-ubuntu-latest-x64-8x` at $0.016 per minute. Memory sets the ceiling before cores do, because a Gradle build commits the daemon heap plus one heap per forked test worker, and that sum has to fit under the machine's RAM. This guide gives the table that maps `org.gradle.workers.max` and JVM heap onto the Linux runner ladder, the memory arithmetic that turns a reasonable looking `--max-workers` into an exit code 137, and the workflow YAML that pins both to the label you selected. ## [Diagnosis](#diagnosis)Copy Anchor Link A Gradle build spends its wall clock in four phases with different resource shapes. * Dependency resolution and wrapper download: network bound, one core busy. * Configuration: largely single threaded until the configuration cache is warm. * Compilation: parallel across modules when `org.gradle.parallel=true`, bounded by `org.gradle.workers.max`. * Tests: parallel across forked JVMs, bounded by `maxParallelForks` on each test task. Only the last two convert extra cores into shorter runs. A single-module service with a 90 second test suite gains almost nothing from 32 vCPU, while a 40-module monorepo with sharded tests keeps every worker busy. The memory arithmetic is where sizing goes wrong. Each Gradle worker and each forked test JVM is a separate process with its own heap, and the JVM sets its default maximum heap to one quarter of the machine's physical memory when nothing overrides it. On a 32GB runner that default is 8GB per process. A test task with `maxParallelForks = 4` and no explicit `-Xmx` therefore permits 32GB of test heap on a 32GB machine, before counting the Gradle daemon's own heap, Metaspace, thread stacks, and the page cache that keeps build cache reads off the disk. Three symptoms follow from that overcommit, and they look unrelated until you add up the heaps: * The job ends with exit code 137, which is the kernel OOM killer. The log above it usually reads "Gradle build daemon disappeared unexpectedly" or reports that a worker process crashed. * Test failures move between classes on consecutive runs of the same commit, because which fork the kernel kills varies. * Wall clock rises while CPU utilization stays low, which is the signature of GC pressure rather than a slow machine. Measure before resizing. CI observability reports maximum CPU utilization, maximum memory utilization, filesystem utilization, and disk throughput per runner instance, grouped from repository down to job and instance type, and it flags any instance holding at or above 80 percent memory utilization as High Memory Usage. The [observability documentation](/docs/ci/features/observability) covers the Recommendations view that carries those thresholds. A Gradle job sitting at 95 percent memory and 30 percent CPU needs a heap change before it needs a bigger machine. ## [Fix](#fix)Copy Anchor Link Size from a budget, then check the cores against it. Committed heap equals the daemon `-Xmx` plus `maxParallelForks` multiplied by the fork `-Xmx`. Keep that total at or under two thirds of the runner's RAM. The remaining third absorbs Metaspace, JIT code cache, thread stacks, annotation processors, Kotlin compile daemons, and the page cache. This table applies the rule across the Linux x64 ladder in the [cloud runners documentation](/docs/ci/cloud-runners), with per-minute rates from the [pricing page](/pricing): | Runner label | vCPU | RAM | Rate per minute | org.gradle.workers.max | Daemon -Xmx | Test forks x fork -Xmx | Committed heap | | -------------------------- | ---- | ----- | --------------- | ---------------------- | ----------- | ---------------------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | $0.004 | 2 | 2g | 1 x 2g | 4GB of 8GB | | warp-ubuntu-latest-x64-4x | 4 | 16GB | $0.008 | 4 | 4g | 2 x 3g | 10GB of 16GB | | warp-ubuntu-latest-x64-8x | 8 | 32GB | $0.016 | 8 | 6g | 4 x 4g | 22GB of 32GB | | warp-ubuntu-latest-x64-16x | 16 | 64GB | $0.032 | 12 | 8g | 8 x 4g | 40GB of 64GB | | warp-ubuntu-latest-x64-32x | 32 | 128GB | $0.064 | 16 | 12g | 12 x 6g | 84GB of 128GB | Two rows deserve an explanation. Worker count tracks vCPU up to the 8x size, and then falls behind it: at 16 and 32 vCPU the heap budget binds first, and forked test JVMs are already consuming cores that the compile workers would otherwise use. The 2x row is a single-module build with one test fork; a multi-module Gradle build on 8GB spends its time in GC. Both levers are available on the same catalog. All Linux sizes carry 150GB of SSD storage with 4GB of memory per vCPU. Pure JVM builds also run on the ARM64 ladder, where the same 8 vCPU and 32GB shape is `warp-ubuntu-latest-arm64-8x` at $0.012 per minute; check any JNI dependencies and Testcontainers images for ARM64 builds before switching architecture. The rate lever works without any assumption about scaling. warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price, checked on 2026-08-13 against [GitHub's per-minute rate reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). At the top of the ladder, warp-ubuntu-latest-x64-32x (32 vCPU, 128 GB) costs $0.064 per minute against $0.082 per minute for the 32-core Linux larger runner (32 vCPU, 128 GB): 22 percent lower list price, same source and date. Two product features change the sizing conversation for Java work. Snapshot runners bring back a populated Gradle User Home so dependency resolution stops competing for the same memory as compilation, and CI observability supplies the utilization numbers this section asks for. Cache configuration is covered on the [Gradle build caches page](/solutions/gradle-github-actions), and configuration phase time on the [Gradle configuration cache guide](/guides/gradle-configuration-cache-github-actions). ## [Configuration](#configuration)Copy Anchor Link Set the worker count and every heap explicitly. Defaults are derived from the machine, so a label change silently changes both, which is how a build that passed on 8 vCPU dies on 32. Start in `gradle.properties`, sized for `warp-ubuntu-latest-x64-8x`: Copy Text ``` org.gradle.parallel=true org.gradle.workers.max=8 org.gradle.caching=true org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g ``` Then bound the test forks in the build script, because `maxParallelForks` defaults to 1 per test task while the fork heap defaults to a quarter of machine RAM: Copy Text ``` tasks.withType(Test).configureEach { maxParallelForks = 4 jvmArgs = ["-Xmx4g", "-XX:MaxMetaspaceSize=512m"] } ``` The workflow pins the label and repeats the worker count on the command line so a job override is visible in the diff: Copy Text ``` name: build on: pull_request: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" - uses: gradle/actions/setup-gradle@v4 - run: ./gradlew build --parallel --max-workers=8 - name: Print peak heap settings if: always() run: ./gradlew -q javaToolchains ``` Maven builds follow the same budget with different flags, and the repository and toolchain setup behind them is covered on the [Java builds page](/solutions/java-github-actions). `mvn -T 1C` runs one module thread per core, `MAVEN_OPTS=-Xmx6g` bounds the Maven JVM, and Surefire's `forkCount` with an `argLine` of `-Xmx4g` bounds the test forks. The committed heap column in the table applies unchanged: `forkCount` multiplied by the Surefire heap plus the Maven heap stays under two thirds of RAM. Verify the change with the observability numbers rather than impressions. Peak memory utilization under 80 percent with CPU pinned across the run means the size fits and the heaps are correct. Peak CPU under 40 percent on an 8 vCPU runner means the build is serial somewhere, and a larger label will bill more for the same duration. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Take a team running 900 Java builds per month at 12 billed minutes each. Rates below are the WarpBuild rates from the [pricing page](/pricing) and the GitHub list prices from the [per-minute rate reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), both checked on 2026-08-13. | Option | Arithmetic | Monthly cost | | -------------------------- | --------------------- | ------------ | | GitHub-hosted Linux 8-core | 900 x 12 min x $0.022 | $237.60 | | warp-ubuntu-latest-x64-8x | 900 x 12 min x $0.016 | $172.80 | The label change moves the bill by $64.80 per month with the same 8 vCPU and 32GB shape and no assumption about the build getting quicker. Now add the sizing lever. Suppose observability shows all 8 workers saturated through compilation and the test task, and a trial on 16 vCPU with `workers.max=12` and 8 test forks brings the build to 7.5 minutes: | Option | Arithmetic | Monthly cost | | ----------------------------------------- | ---------------------- | ------------ | | warp-ubuntu-latest-x64-16x | 900 x 7.5 min x $0.032 | $216.00 | | GitHub-hosted Linux 16-core, same profile | 900 x 7.5 min x $0.042 | $283.50 | At $216.00 the doubled size stays under the $237.60 GitHub-hosted starting point while returning 4.5 minutes per build, which is 4,050 minutes per month across the team's pull requests. The break-even is arithmetic. The 16x rate is exactly double the 8x rate, so cost per run matches when duration falls to 12 x ($0.016 / $0.032) = 6.0 minutes. Between 6.0 and 12 minutes the larger runner buys time and costs more; below 6.0 minutes it wins on both. Gradle builds rarely clear that bar past 16 vCPU, because the configuration phase and the serial tail of a module graph do not shrink, so treat the 32x row as a ceiling for wide monorepos rather than a default. ## [FAQ](#faq)Copy Anchor Link ### [What runner size should a Gradle build start on?](#what-runner-size-should-a-gradle-build-start-on)Copy Anchor Link Start at warp-ubuntu-latest-x64-8x, which is 8 vCPU and 32GB of memory at $0.016 per minute. That shape carries a 6g Gradle daemon heap plus four forked test JVMs at 4g each with room left for Metaspace and the page cache. ### [Why does my Gradle build die with exit code 137?](#why-does-my-gradle-build-die-with-exit-code-137)Copy Anchor Link Exit code 137 is the kernel OOM killer. It usually means the daemon heap plus `maxParallelForks` multiplied by the fork heap exceeded the machine's RAM. The JVM defaults its maximum heap to one quarter of physical memory, so four unbounded forks on a 32GB runner can commit the whole machine. ### [Should org.gradle.workers.max match the runner vCPU count?](#should-orggradleworkersmax-match-the-runner-vcpu-count)Copy Anchor Link Match it up to 8 vCPU. Above that, the memory budget usually binds first, so 12 workers on a 16 vCPU runner and 16 workers on a 32 vCPU runner keep committed heap under two thirds of RAM. --- # Sizing Runners for Node.js Test Suites | WarpBuild URL: https://www.warpbuild.com/guides/sizing-runners-for-node-test-suites Description: Node test workers each hold their own heap, so memory per worker sets the runner size. Measure peak CPU and memory, then pick the warp- Linux label to match. # Sizing Runners for Node.js Test Suites Node test workers each hold their own heap, so memory per worker sets the runner size. Measure peak CPU and memory, then pick the warp- Linux label to match. Last verified: 2026-08-13 A Node.js test suite runs one process per worker, and each process carries its own V8 heap, so the runner size that matters is the one that holds the worker count you want in memory. Measure peak memory and peak CPU for the job first, divide peak memory by the worker count to get the per worker footprint, then pick the `warp-` Linux label where the memory ceiling and the vCPU count agree. This guide covers the measurement that comes before the label change, a worker count table across the Linux ladder at three per worker footprints, the workflow file that sets the worker count from the runner size rather than leaving it at the default, and the arithmetic on one large runner against several sharded small ones. ## [Diagnosis](#diagnosis)Copy Anchor Link Three failures look like a slow test job, and each one has a different metric signature. **Workers are killed and restarted.** Test files fail without a stack trace, the log shows a worker exiting with code 137, or Node prints a heap out of memory error. Peak memory sits near 100 percent while CPU stays in the middle of the range. The suite is memory bound, and adding cores at the same memory ratio will not help. **Cores sit idle.** Peak CPU stays in the low tens on a large label. The worker count is below what the machine can run, either because it was left at the default on a size that was chosen for memory or because the suite has fewer test files than workers. **Everything is pinned.** Peak CPU sits near 100 percent for most of the run and peak memory has headroom. This is the case where more cores return wall clock time, and the next label up is a straight trade of dollars for minutes. WarpBuild collects CPU, memory, filesystem, and network utilization from an agent on every runner it operates, and the [observability documentation](/docs/ci/features/observability) describes what each metric records. Two limits shape how you read them: metrics are collected only for jobs longer than about one minute, and the Recommendations view flags an instance once maximum sustained CPU or maximum memory utilization reaches 80 percent. ### [Converting peak memory into a per worker footprint](#converting-peak-memory-into-a-per-worker-footprint)Copy Anchor Link Memory utilization is reported as a percentage of the runner's RAM, so one division turns it into the number that drives the sizing decision. Take a job on `warp-ubuntu-latest-x64-16x`, which carries 16 vCPU and 64 GB per the [cloud runners documentation](/docs/ci/cloud-runners), running 15 workers, with memory utilization peaking at 78 percent. Copy Text ``` 0.78 x 64 GB = 49.9 GB peak resident 49.9 GB - 2 GB = 47.9 GB held by workers 47.9 GB / 15 workers = 3.2 GB per worker ``` The 2 GB subtraction covers the operating system, the runner agent, and the Jest or Vitest main process. Run the same arithmetic on the size you are on today, and the result transfers to every other label, because the per worker footprint is a property of the suite rather than the machine. For a local cross-check before the job ever runs on a bigger label, `jest --logHeapUsage` prints heap size after each test file, and `/usr/bin/time -v npx jest --ci --maxWorkers=4` reports maximum resident set size for the whole run. Divide that figure the same way. ## [Fix](#fix)Copy Anchor Link Choose the label from the per worker footprint, then set the worker count to match it. | Runner label | vCPU | RAM | Workers at 1 GB each | Workers at 2 GB each | Workers at 4 GB each | | -------------------------- | ---- | ------ | -------------------- | -------------------- | -------------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 2 | 2 | 1 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 4 | 4 | 3 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 8 | 8 | 7 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 16 | 16 | 15 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 32 | 32 | 31 | Each cell is the smaller of the vCPU count and the worker count the memory holds after the 2 GB reserve. Read down the columns and the pattern is the sizing rule: at 1 GB and 2 GB per worker every Linux size is bound by cores, so the worker count equals the vCPU count and the label choice is a pure speed decision. At 4 GB per worker memory binds at every size, and each label loses one or two workers against its core count. A suite that drives headless browsers, spins up a database per worker, or loads a large fixture set lands in that last column. Two consequences follow. A memory bound suite gains nothing from a label that adds cores at the same memory-to-vCPU ratio, since the ratio is fixed at 4 GB per vCPU across the whole Linux ladder. And once the footprint pushes the worker count below the core count, the cheaper move is to cut the footprint: cap each worker heap so V8 collects earlier, or drop per worker fixtures, before buying a wider machine. Cap the heap explicitly. Node sizes its default old space from the memory it sees, so a worker on a 64 GB machine will let its heap grow far past what 15 concurrent workers can afford. Setting `--max-old-space-size` to slightly below the per worker budget makes the ceiling a decision rather than an accident. `warp-` runners are ephemeral VMs, so `nproc` and `os.availableParallelism()` report the vCPU count of the label. That makes the default worker count predictable, and it also means the default is wrong in the same direction every time: Jest opens one worker fewer than the core count, which leaves a core idle, and neither runner knows the memory budget you just computed. ## [Configuration](#configuration)Copy Anchor Link Set the worker count and the heap ceiling from the label in the workflow file. Copy Text ``` name: test on: pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-16x env: TEST_WORKERS: 15 NODE_OPTIONS: --max-old-space-size=3072 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - name: Run tests run: npx jest --ci --maxWorkers=$TEST_WORKERS --logHeapUsage ``` Fifteen workers with a 3 GB heap ceiling each is 45 GB of heap against 64 GB of RAM, which leaves room for the main process, the operating system, and off-heap allocations such as buffers and native modules. Keep `--logHeapUsage` on for the first week so the next measurement pass has data. To decide between two sizes with evidence rather than argument, run the same suite at both for a week. The matrix carries the worker count and the heap ceiling alongside the label, so no entry inherits a default meant for a different machine. Copy Text ``` jobs: size-trial: strategy: fail-fast: false matrix: include: - label: warp-ubuntu-latest-x64-8x workers: 7 heap_mb: 3072 - label: warp-ubuntu-latest-x64-16x workers: 15 heap_mb: 3072 runs-on: ${{ matrix.label }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - name: Run tests run: npx jest --ci --maxWorkers=${{ matrix.workers }} env: NODE_OPTIONS: --max-old-space-size=${{ matrix.heap_mb }} ``` Vitest takes the same two inputs under different names. Its forks pool gives each worker its own process and its own heap, which is the shape the table above models, so `poolOptions.forks.maxForks` takes the worker count. The threads pool puts workers inside one process, so the heap ceiling applies once for all of them and the per worker column stops applying. Install time is a separate lever from test time, and sizing decisions get muddy when the two are measured together. If the install step dominates the job, [why npm install is slow in GitHub Actions](/answers/why-is-npm-install-slow-in-github-actions) covers that half, and [caching Jest runs on GitHub Actions](/guides/jest-cache-github-actions) covers the transform cache that sits between them. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Substitute your own numbers. The assumptions below describe one suite: * 1,400 Jest test files totalling 96 worker-minutes of test execution. * Per worker peak of 4.0 GB, measured as above. * Fixed per job overhead of 1.5 minutes for checkout, Node setup, and `npm ci` from a warm cache. * Parallel efficiency of 85 percent, so 15 workers do the work of 12.75. * 600 runs per month. | Option | Workers | Wall clock per run | Billed minutes per run | Monthly cost | | ----------------------------------------- | ---------------- | ------------------ | ---------------------- | ------------ | | One warp-ubuntu-latest-x64-8x | 7 | 17.6 min | 17.6 | $168.96 | | One warp-ubuntu-latest-x64-16x | 15 | 9.0 min | 9.0 | $172.80 | | Four shards on warp-ubuntu-latest-x64-4x | 3 each, 12 total | 10.9 min | 43.6 | $209.28 | | Eight shards on warp-ubuntu-latest-x64-4x | 3 each, 24 total | 6.2 min | 49.6 | $238.08 | The single 16 vCPU runner returns 8.6 minutes of wall clock over the 8 vCPU runner for $3.84 a month, which is the clearest trade on the list. Sharding buys the next step down in wall clock and pays for it twice: the fixed 1.5 minutes is billed once per shard, and the smaller labels hold fewer workers each at a 4 GB footprint. Eight shards reach 6.2 minutes at $238.08 a month, which is $65.28 more than the single 16 vCPU runner for 2.8 minutes per run. Rates are the per minute Linux x64 rates from the [pricing page](/pricing), billed per minute. For a baseline, `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price, so the 5,400 monthly minutes of the second row cost $226.80 on GitHub-hosted runners against $172.80\. `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price, so the 29,760 monthly minutes of the last row cost $357.12 against $238.08\. GitHub list prices checked on 2026-08-13 in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The same worker arithmetic applies on each platform once you read the vCPU and memory figures for its label. The utilization metrics this guide reads come from CI observability. For the sizing method applied across an estate rather than one suite, see [right sizing GitHub Actions runners](/guides/runner-right-sizing-github-actions), and for the surrounding Node.js pipeline see [Node.js builds on WarpBuild GitHub Actions runners](/solutions/nodejs-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [What runner size does a Node.js test suite need?](#what-runner-size-does-a-nodejs-test-suite-need)Copy Anchor Link The size whose memory holds the worker count you want. Measure peak memory for the job, subtract about 2 GB for the operating system and the test runner main process, and divide by the worker count to get the per worker footprint. A suite at 4 GB per worker fits 3 workers on `warp-ubuntu-latest-x64-4x`, 7 on `warp-ubuntu-latest-x64-8x`, and 15 on `warp-ubuntu-latest-x64-16x`. A suite at 1 GB per worker is bound by cores instead, so the worker count equals the vCPU count of the label. ### [Is one large runner better than several small ones for a Node.js test suite?](#is-one-large-runner-better-than-several-small-ones-for-a-nodejs-test-suite)Copy Anchor Link One large runner is usually cheaper per run because it pays the checkout and install overhead once. Sharding across several jobs is faster in wall clock once the total worker count exceeds what a single label can hold in memory, and it bills that fixed overhead once per shard. Model both against your own run count before choosing. ### [Why did the test suite get slower after moving to a larger runner?](#why-did-the-test-suite-get-slower-after-moving-to-a-larger-runner)Copy Anchor Link The worker count did not follow the label. Jest defaults to one worker fewer than the reported CPU count and Vitest defaults from available parallelism, so a suite whose workers each need 4 GB will open more workers than the machine's memory holds, and the run slows down while the kernel reclaims pages. Set `maxWorkers` explicitly from the runner size and cap each worker heap with `NODE_OPTIONS`. --- # Sizing Runners for Python Test Suites | WarpBuild URL: https://www.warpbuild.com/guides/sizing-runners-for-python-test-suites Description: Two limits decide the runner size for a Python suite: memory per pytest worker and the shared service ceiling. Size both against the Linux x64 ladder. # Sizing Runners for Python Test Suites Two limits decide the runner size for a Python suite: memory per pytest worker and the shared service ceiling. Size both against the Linux x64 ladder. Last verified: 2026-08-13 The right runner size for a Python test suite is the smallest label whose vCPU count matches the number of pytest workers the suite can actually keep busy, and that number comes from two limits rather than from the core count. The limits are memory per worker and the capacity of any service the workers share, such as one Postgres or Redis container inside the job. This guide covers how to read both limits from a job, a worker-count table for process-parallel runs against the Linux x64 ladder, a workflow that derives the worker count from the label and pins the service container size next to it, and a cost model that prices four sizes for the same suite. For the fan-out question of one big machine against several small ones see the [guide to sharding pytest suites](/guides/pytest-parallel-test-sharding), and for the health-check mechanics around services see the [guide to service containers](/guides/service-containers-github-actions). ## [Diagnosis](#diagnosis)Copy Anchor Link Worker processes are what turn cores into shorter wall clock. `pytest -n 8` starts eight separate processes, so the interpreter lock does not cap them, and the machine size only matters through what it lets those eight processes do. Each worker imports the test modules on startup, holds its own fixtures, and opens its own connections to whatever the suite talks to. That gives two ceilings, and the lower one decides the size. **Memory per worker.** Every Linux x64 size carries 4 GB of RAM per vCPU, from 2 vCPU with 8 GB to 32 vCPU with 128 GB, all on 150GB SSD storage ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). A suite that holds 2 GB per worker fills every core at every size. A suite that holds 6 GB per worker, which happens with large fixtures, in-memory dataframes, or a machine-learning model loaded per worker, leaves half the cores idle on any label and gets workers killed if you ask for one per core. **Shared service capacity.** When the workers all talk to one Postgres container, one Redis container, or one external staging API, the suite scales with worker count only until that service becomes the queue. The service runs inside the same job on the same machine, so it also takes cores and memory away from the workers. Measure both before picking a label. Run the suite once at a low worker count and once at a higher one, then read the numbers. The Observability recommendations view aggregates metrics by repository, workflow, job, and instance type, and reports Maximum CPU Utilization, Maximum Memory Utilization, Maximum Filesystem Utilization, and Maximum Disk I/O per runner instance. It flags an instance as under-provisioned once sustained CPU, memory, or filesystem utilization reaches 80 percent, or once disk I/O reaches 80 percent of supported throughput ([observability documentation](/docs/ci/features/observability)). Memory per worker is the peak memory minus the idle baseline, divided by the worker count that produced it. | Signal | Reading | What it means | | ------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------- | | Memory Utilization at or above 80 percent | Memory is the ceiling | Fewer workers per machine than cores, or a larger label for headroom | | Job exits 137 | The kernel killed a worker | Memory per worker exceeded what the label holds | | CPU Utilization at or above 80 percent with wall clock still falling per added worker | Cores are the ceiling | The next size up converts into speed | | Wall clock flat as workers rise, memory and CPU both low | The shared service is the ceiling | Service capacity, connection pool, or fixture isolation | | CPU Utilization low across the whole job | The label is oversized | Move down a size and keep the same worker count | The last row is worth a pass on its own. A job sized for a peak that no longer exists bills the larger rate on every run. ## [Fix](#fix)Copy Anchor Link Work the two limits in order, then pick the label. **Measure memory per worker at a low count.** Run the suite at `-n 4` on `warp-ubuntu-latest-x64-4x` and read Memory Utilization from the recommendations view. Peak minus baseline, divided by 4, is the per-worker figure that everything below depends on. **Find the service ceiling.** Raise the worker count in steps and record wall clock. The point where wall clock stops falling while CPU and memory stay under 80 percent is the service ceiling, expressed as a worker count. A default Postgres container answers a fixed `max_connections`, so a suite opening two connections per worker hits that wall earlier than the core count suggests. **Give each worker its own state before adding any.** xdist sets `PYTEST_XDIST_WORKER` to `gw0`, `gw1`, and so on. Derive the database name, the temp directory, and any bound port from it in a session fixture. Workers that share one database schema serialize on locks, which looks exactly like a service ceiling and is cheaper to fix. **Reserve capacity for the service, then read the table.** Budget the service container first, hold back about 0.5 GB for the runner agent and the Docker daemon, and divide what is left by the per-worker memory. The worker count is the lower of that result and the vCPU count minus the cores the service needs. Worker counts by label and per-worker memory, with 0.5 GB held back for the runner agent and no service container in the job. Rates and shapes come from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | runs-on label | vCPU | RAM | USD per minute | Workers at 1 GB each | at 2 GB each | at 4 GB each | at 6 GB each | | -------------------------- | ---- | ------ | -------------- | -------------------- | ------------ | ------------ | ------------ | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | 2 | 2 | 1 | 1 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | 4 | 4 | 3 | 2 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | 8 | 8 | 7 | 5 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 | 16 | 16 | 15 | 10 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | $0.064 | 32 | 32 | 31 | 21 | Read a row as the lower of two numbers: the vCPU count, and `(RAM - 0.5) / memory per worker` rounded down. At 1 GB and 2 GB per worker the core count binds, so the label alone sets the worker count. At 4 GB the two numbers nearly meet, which is what the 4 GB per vCPU ratio means. At 6 GB memory binds everywhere, and a third of the cores on the largest label stay idle. Subtract the service from the same budget. A Postgres container pinned to 1 core and 4 GB removes one from the vCPU column and 4 from the RAM column before the division, which is how the configuration below arrives at 6 workers on the 8 vCPU label. **Pick the smallest label that reaches your worker ceiling.** Above that point the rate rises and the worker count does not, so the run costs more and finishes at the same time. The same arithmetic transfers to the other platforms with their own shapes. ## [Configuration](#configuration)Copy Anchor Link This workflow carries the sizing decision in one place. The matrix rows pair a label with the worker count and the service container size that fit it, so a size experiment is a row edit and the production config is the row you keep. Copy Text ``` name: pytest on: pull_request: workflow_dispatch: jobs: test: name: pytest ${{ matrix.label }} n=${{ matrix.workers }} runs-on: ${{ matrix.label }} strategy: fail-fast: false matrix: include: - label: warp-ubuntu-latest-x64-4x workers: 2 db_cpus: "1" db_memory: 4g - label: warp-ubuntu-latest-x64-8x workers: 6 db_cpus: "1" db_memory: 4g - label: warp-ubuntu-latest-x64-16x workers: 13 db_cpus: "2" db_memory: 8g services: postgres: image: postgres:17 env: POSTGRES_PASSWORD: app POSTGRES_DB: app_test ports: - 5432:5432 options: >- --cpus ${{ matrix.db_cpus }} --memory ${{ matrix.db_memory }} --health-cmd "pg_isready -h 127.0.0.1 -U postgres -d app_test" --health-interval 5s --health-timeout 5s --health-retries 12 --health-start-period 20s steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.13" cache: pip - run: pip install -r requirements.txt -r requirements-dev.txt - name: Run suite env: DATABASE_URL: postgresql://postgres:app@127.0.0.1:5432/app_test PYTEST_WORKERS: ${{ matrix.workers }} run: pytest -n "$PYTEST_WORKERS" --dist loadfile -q ``` What each piece does: * `matrix.workers` is the number from the table above rather than `auto`, so the worker count stays tied to the label and to the measured per-worker memory. `auto` reads the CPU count and would ask for 16 workers on the last row, which the 4 GB per worker budget does not hold once Postgres takes 8 GB. * `--cpus` and `--memory` in `options` reach `docker create`, so the service gets a fixed slice instead of competing with the workers for the whole machine. Pinning it also makes the worker arithmetic stable: what the container cannot take, the workers can have. * The health check gates the first step on a TCP connection to the real server. `--health-start-period` covers the initialization window, which is where a suite otherwise fails with a connection refused on an apparently healthy service. * `--dist loadfile` keeps each module on one worker, so module scoped fixtures set up once per worker rather than once per test group. Fewer setups means less memory per worker and a higher worker count at the same label. * `fail-fast: false` keeps every row running while you are measuring, so one bad size does not cancel the comparison. Run the matrix on a `workflow_dispatch` for a day, read wall clock and Memory Utilization per row, then delete the rows you are not keeping. Runner storage is ephemeral and is deleted when the runner terminates, so nothing carries between rows and each is a clean measurement. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Take a suite with 50 minutes of parallelizable test execution, 3 minutes of setup for checkout, `setup-python` against a warm pip cache, and the service health wait. Per-worker memory measures at 4 GB. One Postgres container takes 1 core and 4 GB, the runner agent takes about 0.5 GB, and the database saturates at 8 concurrent workers. Each job bills to the next whole minute. | Label | Workers the budget holds | Effective workers | Test time | Wall clock | Billed minutes | Rate | Cost per run | | -------------------------- | ------------------------ | ----------------- | --------- | ---------- | -------------- | ------ | ------------ | | warp-ubuntu-latest-x64-4x | 2 | 2 | 25.0 min | 28.0 min | 28 | $0.008 | $0.224 | | warp-ubuntu-latest-x64-8x | 6 | 6 | 8.3 min | 11.3 min | 12 | $0.016 | $0.192 | | warp-ubuntu-latest-x64-16x | 14 | 8 | 6.3 min | 9.3 min | 10 | $0.032 | $0.320 | | warp-ubuntu-latest-x64-32x | 30 | 8 | 6.3 min | 9.3 min | 10 | $0.064 | $0.640 | Three readings come out of that table. The 8 vCPU size is the cheapest per run and finishes 16.7 minutes sooner than the 4 vCPU size, because the worker count rises three times while the rate doubles. The 16 vCPU size buys 2 more minutes for 67 percent more per run, since the database ceiling caps the useful workers at 8 whatever the label holds. The 32 vCPU size buys nothing at all and doubles the bill, which is the shape of every size decision made without measuring the shared service first. Raising the database ceiling changes the answer rather than the label. Give Postgres 2 cores and 8 GB, tune the connection pool, and the 16 vCPU row runs the 13 workers its memory budget holds at 3.8 minutes of test time, so wall clock lands near 6.8 minutes and 7 billed minutes at $0.224\. That is the same cost as the 4 vCPU row for a quarter of the wall clock, and it only becomes available once the service stops being the ceiling. Scale the winning row to a working month at 40 runs a day over 22 days, which is 880 runs. The 8 vCPU row costs $168.96 a month. The 16 vCPU row costs $281.60 for 1,760 fewer minutes of waiting, about 29 hours spread across the team's pull requests. Both numbers are the per-minute rate times billed minutes times run count, so rerun them with your own suite time. The same shapes on GitHub-hosted larger runners cost more per minute. `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list prices checked on 2026-08-13 against the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Every rate above is on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link **What size runner should a Python test suite use?** Take the smallest label whose vCPU count matches the worker count you can actually feed. Two numbers set that count: memory per worker and the concurrency the shared service tolerates. A suite at 2 GB per worker with no database fills every core up to 32 on the Linux x64 ladder, while a suite at 4 GB per worker sharing one Postgres container that saturates at 8 connections stops paying for cores above the 8 vCPU size. **Should I run pytest with `-n auto` on a larger runner?** Use `-n auto` only when memory per worker is at or below 4 GB and nothing shared throttles the run, because auto reads the CPU count of the machine and asks for one worker per vCPU. Every Linux x64 size carries 4 GB per vCPU, so a suite needing more than that per worker gets workers killed at the top of the ladder. Set an explicit `-n` number derived from the sizing table instead. **How do I tell whether memory or the database is capping my suite?** Read the two signals separately. Memory Utilization at or above 80 percent on the Observability recommendations view, or a job exiting 137, means memory per worker is the limit. Wall clock that stops falling as the worker count rises while memory stays low means the shared service is the limit, and the fix is service capacity rather than a bigger runner. --- # Sizing Runners for Rust Builds | WarpBuild URL: https://www.warpbuild.com/guides/sizing-runners-for-rust-builds Description: Size Rust jobs by how wide the cargo dependency graph stays. 4 vCPU for check jobs, 8 to 16 vCPU for tests, and the point where extra cores stop paying. # Sizing Runners for Rust Builds Size Rust jobs by how wide the cargo dependency graph stays. 4 vCPU for check jobs, 8 to 16 vCPU for tests, and the point where extra cores stop paying. Last verified: 2026-08-13 Most Rust jobs land on 8 to 16 vCPU: `warp-ubuntu-latest-x64-8x` at $0.016 per minute for a workspace whose compile set stays under about 150 crates, and `warp-ubuntu-latest-x64-16x` at $0.032 once it passes that or compiles dependencies cold. The number that picks the size is how wide the cargo dependency graph stays rather than how many crates the workspace holds, because cores only pay while independent crates are ready to compile at the same time. This guide covers the two properties that set Rust build parallelism, a table mapping compile-set size and codegen units to a runner label, the point where extra cores stop removing wall clock, and a workflow that matches the job count and the cache paths to the label you picked. ## [Diagnosis](#diagnosis)Copy Anchor Link Cargo compiles a directed acyclic graph. A crate becomes ready when every crate it depends on has finished, so the parallelism available at any instant is the size of the ready set, and the ready set changes shape across the run. Two properties decide how wide it gets. **Graph width.** A workspace with 700 registry dependencies has a wide middle: hundreds of leaf crates with no dependents compile at once, and any core you add during that phase does real work. The ends are narrow. At the start only the leaves are ready, and at the end the final workspace crates plus their test binary links depend on nearly everything before them. **Codegen units.** Within one crate, rustc splits code generation into parallel units. The dev profile defaults to 256 codegen units and the release profile to 16, per the [Cargo profile reference](https://doc.rust-lang.org/cargo/reference/profiles.html#codegen-units). Setting `codegen-units = 1` or enabling fat LTO collapses that to a single serial step, which is why a release job tuned for runtime speed scales worse than a debug job on the same workspace. Measure both with `cargo build --timings`, which writes an HTML report including a concurrency graph of units active, waiting on a dependency, and waiting on a core ([Cargo timings reference](https://doc.rust-lang.org/cargo/reference/timings.html)). Two readings settle the size question. If units waiting on a core stay high, the runner is undersized. If active units sit below your vCPU count for most of the run, you are paying for idle cores. Memory is the second constraint. Every size on the Linux x64 ladder carries 4 GB per vCPU and a 150 GB SSD, listed in the [cloud runners documentation](/docs/ci/cloud-runners), so parallel rustc processes on a crate with a large generic surface can exhaust RAM before they exhaust CPU. An `aarch64-unknown-linux-gnu` target compiles natively on `warp-ubuntu-latest-arm64-8x` at $0.012 per minute. ## [Fix](#fix)Copy Anchor Link Pick the label from the compile set and the codegen units in play, then confirm against a timings report. | Job shape | Crates in the compile set | Codegen units in play | Label | Price per minute | | ----------------------------------------------------------------- | ------------------------- | --------------------------------- | -------------------------- | ---------------- | | cargo fmt, cargo clippy, cargo check with a warm dependency cache | up to 60 rebuilt | none; check skips code generation | warp-ubuntu-latest-x64-4x | $0.008 | | cargo test, dev profile, warm dependency cache | 100 to 150 | 256 per crate | warp-ubuntu-latest-x64-8x | $0.016 | | cargo test on a wide workspace, or any cold dependency compile | 150 to 600 | 256 per crate | warp-ubuntu-latest-x64-16x | $0.032 | | Release build with thin LTO across a large graph | 600 or more | 16 per crate | warp-ubuntu-latest-x64-32x | $0.064 | | Release build with fat LTO or codegen-units = 1 | any | 1, in one final serial step | warp-ubuntu-latest-x64-8x | $0.016 | Rates come from the [pricing page](/pricing). The last row is the one teams get wrong: a fat-LTO release job serializes the expensive half of the work, so the 32 vCPU rate buys idle cores. Size release and test jobs separately when the release profile turns LTO on. ### [Where extra cores stop helping](#where-extra-cores-stop-helping)Copy Anchor Link Split the run into a parallel phase and a serial tail. The parallel phase divides by core count; the tail does not move. Take a workspace of 44 first-party crates and 720 registry dependencies with 96 CPU-minutes of compile work, of which 88 CPU-minutes sit in the parallel phase and 3.20 minutes of wall clock form the tail of final crates and test binary links. | Label | vCPU | Parallel phase | Serial tail | Wall clock | Cost per run | Cost per minute of wall clock removed | | -------------------------- | ---- | -------------- | ----------- | ---------- | ------------ | ------------------------------------- | | warp-ubuntu-latest-x64-4x | 4 | 22.00 min | 3.20 min | 25.20 min | $0.2016 | baseline | | warp-ubuntu-latest-x64-8x | 8 | 11.00 min | 3.20 min | 14.20 min | $0.2272 | $0.0023 | | warp-ubuntu-latest-x64-16x | 16 | 5.50 min | 3.20 min | 8.70 min | $0.2784 | $0.0093 | | warp-ubuntu-latest-x64-32x | 32 | 2.75 min | 3.20 min | 5.95 min | $0.3808 | $0.0372 | The model assumes the ready set stays wide enough to fill every core during the parallel phase, which is the optimistic bound; a real graph narrows earlier. Even so, the tail is 23 percent of the 8 vCPU run and 54 percent of the 32 vCPU run, and the last doubling costs $0.0372 per minute of wall clock removed against $0.0023 for the first. Substitute your own two numbers from a timings report before moving up a size. The [Observability page](/docs/ci/features/observability) closes the loop after the change. Its recommendations view groups runs by repository, workflow, job, and instance type, and reports maximum CPU, memory, filesystem, disk I/O, and network utilization per instance, flagging jobs that are under-provisioned or over-provisioned. CI observability sits alongside snapshot runners, remote Docker builders, an MCP server, and the Action Debugger in the product surface. ## [Configuration](#configuration)Copy Anchor Link Set the job count to the label, keep the cache scoped to what a job of that size actually reuses, and leave incremental state out of it. Copy Text ``` name: rust on: pull_request: env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: "0" CARGO_BUILD_JOBS: "16" CARGO_PROFILE_TEST_CODEGEN_UNITS: "256" jobs: check: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - run: cargo clippy --workspace --all-targets -- -D warnings test: runs-on: warp-ubuntu-latest-x64-16x steps: - uses: actions/checkout@v5 - name: Restore cargo home and compiled dependencies uses: WarpBuilds/cache@v1 with: path: | ~/.cargo/registry/index ~/.cargo/registry/cache ~/.cargo/git/db target/debug/deps target/debug/build target/debug/.fingerprint key: rust-16x-${{ hashFiles('**/Cargo.lock') }} restore-keys: rust-16x- - name: Test run: cargo nextest run --workspace --test-threads 16 ``` `CARGO_BUILD_JOBS` matches the 16 vCPU label. Cargo already defaults to the logical CPU count ([Cargo configuration reference](https://doc.rust-lang.org/cargo/reference/config.html#buildjobs)), so the value is written down for two reasons: a matrix that runs the same job on more than one label makes the mismatch visible, and a workspace whose linker peaks past 4 GB per unit gets capped here instead of by moving up a size. Four cache paths carry the reuse and one is deliberately absent. `~/.cargo/registry/index`, `~/.cargo/registry/cache`, and `~/.cargo/git/db` remove the download. `target/debug/deps`, `target/debug/build`, and `target/debug/.fingerprint` hold the compiled dependency artifacts, which is the wide part of the graph the 16 vCPU label exists to compile. `target/debug/incremental` stays out, because it is large, it is billed as transfer minutes in both directions, and cargo discards it once checkout rewrites the workspace mtimes. The [guide to sccache on GitHub Actions](/guides/sccache-github-actions) covers the object-cache route for the same reuse, and the [answer on why cargo rebuilds everything](/answers/why-does-cargo-rebuild-everything-in-github-actions) works through the fingerprint check. The cache key carries the label. A restore written by a 16 vCPU job is valid on any size, but keying by label keeps a size experiment from mixing artifacts with your steady state while you compare timings. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Take the wall clock above at 900 pull request runs per month and price each rung, with rates from the [pricing page](/pricing): | Label | Wall clock per run | Monthly minutes | Monthly cost | | -------------------------- | ------------------ | --------------- | ------------ | | warp-ubuntu-latest-x64-8x | 14.20 min | 12,780 | $204.48 | | warp-ubuntu-latest-x64-16x | 8.70 min | 7,830 | $250.56 | | warp-ubuntu-latest-x64-32x | 5.95 min | 5,355 | $342.72 | Moving from 8x to 16x costs $46.08 per month and removes 5.50 minutes from every run. Moving from 16x to 32x costs another $92.16 and removes 2.75 minutes. The second trade is worth making only when a check is blocking a merge queue. Against GitHub-hosted list prices for the same shapes, from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13: * `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. The 12,780 monthly minutes above come to $281.16 on the GitHub-hosted runner against $204.48. * `warp-ubuntu-latest-x64-32x` (32 vCPU, 128 GB) costs $0.064 per minute against $0.082 per minute for the 32-core Linux larger runner (32 vCPU, 128 GB): 22 percent lower list price. The 5,355 monthly minutes come to $439.11 against $342.72. The model holds step timings identical across every column so that only the core count and the list price move. Every cost and performance number on this page carries its source and a checked-on date; where no citable number exists, no speed claim is attached. ## [FAQ](#faq)Copy Anchor Link ### [What size runner should a Rust workspace start on?](#what-size-runner-should-a-rust-workspace-start-on)Copy Anchor Link `warp-ubuntu-latest-x64-8x` at $0.016 per minute for `cargo test` on a workspace whose compile set is under about 150 crates, and `warp-ubuntu-latest-x64-16x` at $0.032 once the set passes 150 crates or the job compiles dependencies cold. Lint and check jobs with a warm dependency cache stay on `warp-ubuntu-latest-x64-4x` at $0.008, because `cargo check` skips code generation and rarely holds four cores busy. ### [Does setting CARGO\_BUILD\_JOBS above the vCPU count help?](#does-setting-cargo%5Fbuild%5Fjobs-above-the-vcpu-count-help)Copy Anchor Link No. Cargo already defaults the job count to the number of logical CPUs, and oversubscribing adds rustc processes that compete for the same cores and the same memory. The reason to set the value explicitly is the opposite case: capping it below the vCPU count when a large crate or a linker peaks past the 4 GB per vCPU the Linux x64 ladder provides. ### [Why did doubling the runner size not halve my Rust build?](#why-did-doubling-the-runner-size-not-halve-my-rust-build)Copy Anchor Link Because only the wide part of the graph scales. Every cargo build ends with a serial tail where the last workspace crates and the test binary links depend on almost everything before them, and that tail takes the same wall clock at 32 vCPU as at 8\. Run `cargo build --timings` and read the concurrency graph: if active units sit well below your vCPU count for most of the run, the extra cores are idle and billed. --- # How to Speed Up GitHub Actions Builds | WarpBuild URL: https://www.warpbuild.com/guides/speed-up-github-actions Description: Faster GitHub Actions builds come from more vCPUs, warm caches, and shorter queues. Swap ubuntu-latest for a WarpBuild warp- label and see the cost math. # How to Speed Up GitHub Actions Builds Faster GitHub Actions builds come from more vCPUs, warm caches, and shorter queues. Swap ubuntu-latest for a WarpBuild warp- label and see the cost math. Last verified: 2026-08-13 Most GitHub Actions builds get faster through three changes: give each job enough vCPUs, keep dependency and Docker layer caches warm between runs, and remove the queue wait before the job starts. WarpBuild covers all three with a one-line edit: a `warp-ubuntu-latest-x64-8x` label in `runs-on` replaces `ubuntu-latest`, and `WarpBuilds/cache@v1` replaces `actions/cache@v4` wherever a workflow caches dependencies. This guide diagnoses the five failure modes behind most slow workflows, maps each one to its fix, shows the exact YAML, and works a monthly cost model from GitHub list prices and WarpBuild per-minute rates. ## [Diagnosis](#diagnosis)Copy Anchor Link Slow is a symptom with several distinct causes, and the fixes differ. Before changing anything, open one representative workflow run and collect two numbers per job: the gap between the queued timestamp and the started timestamp, and the duration of each step. Five patterns account for most of the lost wall clock. GitHub's run page gives coarse timings. For per-step resource detail on WarpBuild runners, CI observability correlates system metrics from the runner agent with the GitHub Actions job log, which makes CPU saturation and cache download stalls visible without adding any instrumentation to the workflow. ### [Queue wait before the job starts](#queue-wait-before-the-job-starts)Copy Anchor Link The job shows minutes of queue time before a single step executes. Machine speed never enters the picture during this window; the run is waiting for a runner to become available. The usual causes are account-level ceilings on parallel jobs, `runs-on` labels that no online runner carries, and self-hosted pools sized for average load while the actual demand arrives in bursts. Measuring queue wait per runner label and fixing each cause has its own guide: [GitHub Actions queue times and how to fix them](/guides/github-actions-queue-times). ### [Cold dependency cache on every run](#cold-dependency-cache-on-every-run)Copy Anchor Link `npm ci`, `pip install`, `cargo fetch`, and `go mod download` each rebuild everything when the cache restore misses. Look at the cache restore step in the log: a reported miss followed by a multi-minute install step means the job pays the full download and compile cost on every run. GitHub evicts cache entries once a repository passes its storage ceiling, so busy repositories cycle through misses even when the workflow is configured correctly. The eviction mechanics and the fixes are covered in [the GitHub Actions cache size limit, explained](/guides/github-actions-cache-size-limit). ### [Undersized runner for a parallel test suite](#undersized-runner-for-a-parallel-test-suite)Copy Anchor Link A standard `ubuntu-latest` job runs on 2 vCPUs. Test runners such as `jest --maxWorkers`, `pytest -n auto`, and `go test -p` split work across cores, and on 2 vCPUs that split buys little. The signature is a job whose CPU sits saturated for its whole duration while the suite itself splits cleanly into independent shards. The [WarpBuild runner catalog](/docs/ci/cloud-runners) lists Linux x64 sizes from 2 to 32 vCPUs, so the runner can match the parallelism the suite already has. ### [Docker layer cache rebuilt each run](#docker-layer-cache-rebuilt-each-run)Copy Anchor Link GitHub-hosted runners start with an empty Docker layer cache because each job receives a fresh machine. Every `docker build` replays every layer, including base image pulls and dependency install layers whose inputs have identical hashes run after run. If the build log shows those layers rebuilding anyway, the layer cache is being discarded between jobs. Persistent layer caching options are covered in [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). ### [Single-job serialization of an otherwise parallel matrix](#single-job-serialization-of-an-otherwise-parallel-matrix)Copy Anchor Link A 12-entry matrix should finish in roughly the time of its slowest entry. When entries run one after another instead, look for three culprits: a workflow-level `concurrency` group that queues siblings behind each other, a `max-parallel` setting left over from an older constraint, and an account-level ceiling on concurrent jobs. Per-label wait data confirms which one applies; the measurement walkthrough is in the [queue times guide](/guides/github-actions-queue-times). ## [Fix](#fix)Copy Anchor Link Each failure mode above has a direct fix, and four of the five are label or action swaps inside existing YAML. **Queue wait.** Move the job to WarpBuild runners. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Capacity adjusts dynamically, so a 30-entry matrix claims 30 machines instead of queueing behind a fixed pool. **Cold dependency caches.** Replace `actions/cache@v4` with `WarpBuilds/cache@v1`. The action is a drop-in replacement: same `path`, `key`, and `restore-keys` inputs, same `cache-hit` output. WarpBuild also maintains cache-enabled forks of the popular setup actions, including `WarpBuilds/setup-node`, `WarpBuilds/setup-python`, and `WarpBuilds/setup-go`, which route toolchain and dependency caching through the same store with zero other workflow edits. **Undersized runners.** Pick a label whose vCPU count matches the parallelism of the job. On Linux x64 the sizes run from 2 vCPUs with 8GB of memory up to 32 vCPUs with 128GB, all on a 150GB SSD. A suite already sharded eight ways belongs on `warp-ubuntu-latest-x64-8x` or larger. **Docker layers.** For workflows that build images, WarpBuild's remote Docker builders keep a persistent layer cache on dedicated build machines, so unchanged layers stop rebuilding. Setup and tradeoffs are on the [Docker builds solution page](/solutions/docker-builds-github-actions). **Serialized matrices.** Delete stale `max-parallel` settings, scope `concurrency` groups to the git ref instead of the whole workflow, and let the matrix fan out. With no hard concurrency caps in the runner pool, the matrix completes in roughly the wall clock of its slowest entry. None of these changes touch build logic. The workflow files change by a handful of lines. The per-size rates behind that comparison are on the [pricing page](/pricing) and in the cost model below. ## [Configuration](#configuration)Copy Anchor Link The minimal change is the `runs-on` label plus the cache action. Applied to a Node.js test job, the diff is: Copy Text ``` jobs: test: - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - - uses: actions/cache@v4 + - uses: WarpBuilds/cache@v1 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - run: npm ci - - run: npm test + - run: npm test -- --maxWorkers=8 ``` The complete workflow after the change: Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - uses: WarpBuilds/cache@v1 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - run: npm ci - run: npm test -- --maxWorkers=8 ``` Four details worth knowing before rolling this out across a repository: `warp-ubuntu-latest-x64-8x` resolves to Ubuntu 24.04 with 8 vCPUs, 32GB of memory, and a 150GB SSD. The full label list, including the Ubuntu 26.04 and Ubuntu 22.04 variants and the ARM64 labels, is in the [runner catalog](/docs/ci/cloud-runners). To pin an OS version explicitly, use the alias form: `warp-ubuntu-2404-x64-8x` names the same machine without following the `latest` pointer. `WarpBuilds/cache@v1` keeps the semantics of `actions/cache@v4`: an exact `key` match sets `cache-hit` to `true`, a `restore-keys` match restores a stale entry and reports `false`, and cache entries are scoped to key, version, and branch. Advanced patterns, including split restore and save steps and Docker layer caching through the cache proxy, are in the [cache documentation](/docs/ci/features/caching). The cache is available on Linux runners and is enabled by default. WarpBuild caches are unsupported on Windows-based runners, so Windows jobs keep `actions/cache` while still gaining the larger machine sizes. The `--maxWorkers=8` flag matters as much as the label. A bigger runner only pays off when the job uses the extra cores, so raise the worker count of the test runner, the bundler, or the compiler to match the vCPU count of the label you chose. Roll the change out one workflow at a time. Start with the longest pull request gate, since that is the wait engineers feel most, and leave release workflows for last. Because the label is the routing mechanism, a rollback is the same one-line change in the other direction, and both versions of the workflow keep running against the same repository while you compare them. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link GitHub's per-minute list prices below come from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the [GitHub-hosted runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13\. They apply to private repositories, where minutes are billed. WarpBuild rates come from the [WarpBuild pricing page](/pricing). | vCPU | GitHub-hosted Linux, per minute | WarpBuild Linux x64, per minute | WarpBuild label | | ---- | ------------------------------- | ------------------------------- | -------------------------- | | 2 | $0.006 (ubuntu-latest) | $0.004 | warp-ubuntu-latest-x64-2x | | 4 | $0.012 (larger runner) | $0.008 | warp-ubuntu-latest-x64-4x | | 8 | $0.022 (larger runner) | $0.016 | warp-ubuntu-latest-x64-8x | | 16 | $0.042 (larger runner) | $0.032 | warp-ubuntu-latest-x64-16x | | 32 | $0.082 (larger runner) | $0.064 | warp-ubuntu-latest-x64-32x | The table pairs each GitHub-hosted size with the WarpBuild label at the same vCPU count. Against those GitHub list prices the WarpBuild rate is 33 percent lower at 2 and 4 vCPU, 27 percent lower at 8, 24 percent lower at 16, and 22 percent lower at 32, all checked on 2026-08-13\. The worked example below applies both rates to a realistic monthly volume so the difference shows up in dollars. Assumptions, stated so the arithmetic can be rerun with your own numbers: * 6,000 jobs per month, about 300 jobs per working day * 12 minutes of billed wall clock per job today on `ubuntu-latest` (2 vCPUs) * a private repository, so every minute is billed * GitHub list prices as checked on 2026-08-13 **Baseline.** 6,000 jobs x 12 minutes = 72,000 minutes per month. At $0.006 per minute on `ubuntu-latest`, the monthly bill is $432. **Same size, new runner.** On `warp-ubuntu-latest-x64-2x` at $0.004 per minute, the same 72,000 minutes cost $288 per month. Wall clock is assumed unchanged in this row; the only change is the per-minute rate. **Right-sized runner.** Suppose the job is a parallel test suite and moves to `warp-ubuntu-latest-x64-8x` at $0.016 per minute. Assume wall clock drops from 12 minutes to 5 as the suite spreads across 8 vCPUs. Treat that as a planning assumption and verify it against your own suite. The month becomes 6,000 x 5 = 30,000 minutes, costing $480\. Beyond the bill, each run returns 7 minutes to whoever is waiting on it: 42,000 engineer-minutes per month, or 700 hours across the team. **The same speed on GitHub's larger runner.** The identical 30,000 minutes on GitHub's 8-core larger runner at $0.022 per minute would cost $660 per month. If the bill is the primary concern and speed is secondary, [cutting GitHub Actions costs without rewrites](/guides/reduce-github-actions-costs) covers right-sizing with per-job billed time reports and runner label breakdowns. ## [FAQ](#faq)Copy Anchor Link ### [Do I need to rewrite my workflows to use WarpBuild runners?](#do-i-need-to-rewrite-my-workflows-to-use-warpbuild-runners)Copy Anchor Link No. Change the `runs-on` label to a `warp-` label such as `warp-ubuntu-latest-x64-8x`. Steps, actions, and secrets keep working because the runner images carry the same tooling as GitHub-hosted runners. ### [Does WarpBuild limit how many jobs can run at once?](#does-warpbuild-limit-how-many-jobs-can-run-at-once)Copy Anchor Link Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Capacity adjusts dynamically with your workload. ### [What do WarpBuild Linux runners cost per minute?](#what-do-warpbuild-linux-runners-cost-per-minute)Copy Anchor Link Linux x64 runners range from $0.004 per minute for 2 vCPUs to $0.064 per minute for 32 vCPUs. ### [Does the WarpBuild cache work on Windows runners?](#does-the-warpbuild-cache-work-on-windows-runners)Copy Anchor Link No. WarpBuild caches are unsupported on Windows-based runners. The cache is available on Linux runners and is enabled by default, and Windows jobs can keep using `actions/cache`. --- # Spot Instances for GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/guides/spot-instances-github-actions-runners Description: Spot instances for GitHub Actions runners come from WarpBuild BYOC in your own AWS, GCP, or Azure account, at a flat $0.002 per runner minute fee. # Spot Instances for GitHub Actions Runners Spot instances for GitHub Actions runners come from WarpBuild BYOC in your own AWS, GCP, or Azure account, at a flat $0.002 per runner minute fee. Last verified: 2026-08-13 Spot instances give GitHub Actions runners interruptible cloud capacity at a discount, and on WarpBuild they come from BYOC runners that launch inside your own cloud account. BYOC runners cost $0.002 per minute in WarpBuild fees and the compute is billed by your own cloud account, where AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices ([AWS EC2 Spot Instances](https://aws.amazon.com/ec2/spot/), checked on 2026-08-13). One availability fact decides how this page applies to you. BYOC runs on AWS, GCP, and Azure, and spot capacity lives there. Cloud spot runners were removed from WarpBuild Cloud on June 8, 2026, so every `*-spot` label on the hosted fleet is gone; the [cloud runners catalog](/docs/ci/cloud-runners) records the removal, and the [BYOC documentation](/docs/ci/byoc) is the path forward. ## [Diagnosis](#diagnosis)Copy Anchor Link Spot capacity is cloud capacity your provider can take back. When it does, the instance stops, the GitHub Actions runner agent stops answering, and the job that was running on it fails. Nothing resumes: the job restarts from its first step on a fresh instance, and any minutes already burned are still billed by your cloud account. That single behavior decides which jobs belong on spot and which do not. Start by sizing the eligible slice rather than flipping a switch on every runner set. The [Reports page](/docs/ci/features/reports) breaks WarpBuild billing down to the individual job with repository, runner label, execution time, billed time, and cost, and filters by repository, runner label, stack, and job name. Sort by cost, then walk the top rows against the table below. Teams usually find that a small number of job names carry most of the minutes, and that those jobs split cleanly into retry-safe and side-effecting. | Job class | Fits spot | Why | | ----------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------- | | Unit tests and sharded test matrices | Yes | Short, idempotent, and cheap to re-run. A lost shard costs one shard, not the pipeline. | | Lint, type checks, static analysis | Yes | Pure functions of the checkout. Re-running produces the same result. | | Container image builds pushing by digest | Yes | A lost build wastes minutes and leaves no partial state behind a mutable tag. | | Integration tests against ephemeral services | Usually | Safe when the fixture is created and torn down inside the job. Risky when it holds a shared database. | | Release, publish, and tag jobs | No | A half-finished publish leaves artifacts in a registry that the retry then has to reconcile. | | Deploy jobs holding a lock or a migration | No | Interruption mid-migration is an operational incident rather than a failed run. | | Long single-step jobs over 30 minutes | No | The longer the step, the higher the chance of losing all of it, and the retry pays full price again. | | Jobs feeding a required status check on a busy branch | Careful | Correctness is fine, but the retry adds wall clock to every merge that gets unlucky. | Two constraints narrow the list further. Spot instances are not supported for standby disks, so a runner set cannot have both a warm pool and spot pricing; the [standby disks documentation](/docs/ci/byoc/standby-disks) states the reason plainly, which is that a reclaimed instance cannot guarantee a pre-initialized VM is waiting. And spot capacity applies to the platforms BYOC covers: Linux runner sets on AWS, GCP, and Azure, and Windows runner sets on AWS and Azure. The macOS fleet is hosted, so macOS jobs stay on hosted labels and are priced from the [pricing page](/pricing). The last diagnostic is queue behavior. Spot availability varies by instance type, region, and availability zone, and a runner set pinned to one instance type will park jobs in the queue when that type runs dry. Availability, rather than price, is what usually goes wrong first. ## [Fix](#fix)Copy Anchor Link Work the change in five steps, and keep the spot and on-demand paths as separate runner sets so a rollback is a label edit rather than a reconfiguration. **1\. Connect the cloud account and create a stack.** BYOC setup is three moves: connect the cloud account so WarpBuild gets an IAM role or service account, create a stack that pins a region, VPC, and object storage bucket, then create custom runners against that stack. The [BYOC documentation](/docs/ci/byoc) covers all three, and the [AWS configuration guide](/docs/ci/byoc/aws/config) covers the AWS specifics, including the spot permissions the IAM role carries: `ec2:RequestSpotInstances`, `ec2:CancelSpotInstanceRequests`, `ec2:DescribeSpotInstanceRequests`, `ec2:DescribeSpotPriceHistory`, and the service-linked role for EC2 Spot. Architecture and IAM detail for the AWS path live on the [AWS BYOC page](/byoc/aws). **2\. Create two runner sets, not one.** Build a spot runner set for the retry-safe classes in the table and an on-demand runner set at the same instance size for everything else. The workflow chooses between them with the `runs-on` label, so moving a job back to on-demand after a bad week is a one-line diff. **3\. Give the spot set a long fallback instance type list.** A runner set accepts several instance types in priority order, and WarpBuild picks an available one at launch time based on availability and price. This is the single most useful spot setting, because a queued job that cannot find capacity costs more than a job that pays on-demand rates. Choose types close in price and performance so job duration stays predictable, for example `m7a.xlarge` alongside `m7i.xlarge`. **4\. Spread subnets across availability zones.** The AWS configuration guide recommends three public and three private subnets in different availability zones, with at least 250 IPs per subnet. Zonal spread widens the pool your fallback list can draw from, which is what turns a longer instance type list into real capacity. **5\. Make retries cheap.** Shard long suites so an interrupted job loses one shard, and set `fail-fast: false` on matrix jobs so a reclaimed instance does not cancel the siblings that were about to pass. Watch the success rate and duration percentiles in the Jobs section of the Reports page after the switch; if the interrupted share is high enough to stretch merge times, shorten the jobs before widening the spot footprint. Standby disks stay on the on-demand set. Snapshot runners, remote Docker builders, CI observability, the MCP server, and the Action Debugger are the wider WarpBuild surface, and every one of them is reachable through the API, so the runner set changes above can be scripted rather than clicked. Scale is the last thing to check before widening. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. On BYOC the ceiling that actually bites is your own cloud quota for vCPUs, IPs, and volumes in the stack region, and a spot fleet reaches it sooner than an on-demand one because retries add launches. ## [Configuration](#configuration)Copy Anchor Link A BYOC runner is addressed by its Runner ID, which is the runner name with the `warp-custom-` prefix. The two-set pattern reads like this: .github/workflows/ci.yml Copy Text ``` name: ci on: pull_request: push: branches: [main] concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: unit-tests: runs-on: warp-custom-linux-8x-spot strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - run: make test SHARD=${{ matrix.shard }} publish: needs: unit-tests if: github.ref == 'refs/heads/main' runs-on: warp-custom-linux-8x-ondemand steps: - uses: actions/checkout@v4 - run: make publish ``` The sharded test job runs on spot with `fail-fast: false`, so a reclaimed instance costs one shard and a re-run of that shard. The publish job runs on the on-demand set because a partial publish is expensive to clean up. Both labels point at runner sets in the same stack, so they share the region, VPC, and cache bucket. The runner set settings that matter on spot, from the [AWS configuration guide](/docs/ci/byoc/aws/config): | Setting | Spot runner set | On-demand runner set | | ----------------------- | ------------------------------------------------- | ------------------------------------ | | Instance types | Four or more similar types in priority order | Two is usually enough | | Spot | Enabled | Disabled | | Standby disk pool | 0, since spot is unsupported for standby disks | Sized to expected concurrency | | Disk minimum | 100 GB, 125 MBps, 3000 IOPS | 100 GB, 125 MBps, 3000 IOPS | | Disk recommendation | 150 GB, 400 MBps, 4000 IOPS | 150 GB, 400 MBps, 4000 IOPS | | Windows Server 2022 x64 | 8 vCPU or larger, m7a series, 6000 IOPS, 500 MBps | Same | | Instance profile | Scoped to what the spot jobs read | Scoped to what the deploy jobs write | Two more configuration notes. Static IPs place runners in private subnets and add data transfer charges to your cloud bill, so enable them only for the runner sets that need a fixed egress address, and keep the spot set without them where your network policy allows. Terraform support exists for BYOC on AWS, so the two runner sets, their instance type lists, and their disk settings can live in the same module as the rest of your AWS infrastructure and be reviewed in a pull request. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The WarpBuild half of a BYOC bill is a flat per-minute fee that does not change with the instance purchase option. The compute half is your own cloud bill, and that is the half spot moves. Rates below come from the [pricing page](/pricing), checked on 2026-08-13. | Line | Rate | Billed by | | ------------------------------------------------ | ----------------- | ------------------ | | BYOC Linux runners on AWS, GCP, or Azure | $0.002 per minute | WarpBuild | | BYOC Windows runners on AWS or Azure | $0.002 per minute | WarpBuild | | EC2 or VM compute, on-demand or spot | Your cloud rate | Your cloud account | | EBS or disk, data transfer | Your cloud rate | Your cloud account | | Hosted warp-ubuntu-latest-x64-8x, for comparison | $0.016 per minute | WarpBuild, all in | Take a fleet of 200,000 Linux runner minutes per month on an 8 vCPU shape. The WarpBuild fee is 200,000 x $0.002 = $400 per month, and it stays $400 whether every instance is on-demand or every instance is spot. On top of that sits your cloud bill for the instances, volumes, and data transfer those jobs consumed. AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices ([AWS EC2 Spot Instances](https://aws.amazon.com/ec2/spot/), checked on 2026-08-13), and that published ceiling is what the compute half moves against. The realized figure depends on your instance mix, your region, your zonal spread, and any committed-use discounts you already hold, so model it against a real month of workflow minutes before migrating. Interruptions push the other way, and the arithmetic is worth doing before the switch. Suppose the spot set carries 120,000 of those 200,000 minutes, average job length is 6 minutes, and reclaimed jobs are lost halfway through on average. An interrupted job bills the 3 minutes it burned plus a full 6-minute retry, so it costs 9 minutes where a clean run costs 6: half a job extra each time. At a 5 percent interruption rate the spot slice adds 0.05 x 120,000 x 0.5 = 3,000 minutes, which is $6 in WarpBuild fees plus the matching compute in your cloud account. At 15 percent it adds 9,000 minutes. The break-even point is the interruption rate at which those added minutes cost more than the spot discount returns, and a long fallback instance type list plus zonal spread is what keeps you below it. Compare that against staying on the hosted fleet. The same 200,000 minutes on `warp-ubuntu-latest-x64-8x` bill 200,000 x $0.016 = $3,200 per month with no cloud bill attached, no capacity planning, and no interruption handling. BYOC with spot trades that simplicity for a lower compute rate you manage yourself. Teams with an existing cloud footprint, committed-use discounts, and an operations team usually come out ahead; teams without one usually do not. Treat a provider's advertised spot discount as an instance-price ceiling, not a prediction of total CI savings. Model interruptions, job duration, cloud rates, and the WarpBuild fees on the [pricing page](/pricing) together. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, and signup includes $10 free credits, which is enough to run a spot runner set against a real workflow before committing to the migration. The choice between the two routes is worked through in more depth in [hosted runners compared with BYOC runners](/guides/hosted-vs-byoc-runners), and the capacity side, including the cloud quotas a spot fleet hits first, is covered in [BYOC capacity planning and cloud quotas](/guides/byoc-capacity-and-quotas). ## [FAQ](#faq)Copy Anchor Link ### [Can I still use the WarpBuild Cloud spot runner labels?](#can-i-still-use-the-warpbuild-cloud-spot-runner-labels)Copy Anchor Link No. All `*-spot` runner labels on WarpBuild Cloud were removed on June 8, 2026\. Spot capacity now comes from BYOC runners on AWS, GCP, or Azure, where the instances launch in your own cloud account. ### [What happens to a GitHub Actions job when the cloud reclaims a spot instance?](#what-happens-to-a-github-actions-job-when-the-cloud-reclaims-a-spot-instance)Copy Anchor Link The runner disappears mid-job and GitHub Actions marks that job failed, so the work has to run again. WarpBuild launches a replacement instance from your runner set's ordered instance type list, which is why a longer fallback list matters on spot. ### [Can spot runners use standby disks?](#can-spot-runners-use-standby-disks)Copy Anchor Link No. Spot instances are not supported for standby disks, because a reclaimed instance breaks the guarantee that a pre-initialized VM is waiting. Keep standby disk pools on an on-demand runner set and give the spot set a pool size of zero. ### [What does WarpBuild charge for spot BYOC runners?](#what-does-warpbuild-charge-for-spot-byoc-runners)Copy Anchor Link BYOC runners cost $0.002 per runner minute in WarpBuild fees on both Linux and Windows, and your cloud account is billed for the compute at whatever spot rate it pays. Pricing is purely usage based, with no base subscription fee, no platform fee, and no seat fee. --- # Static IPs for GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/guides/static-ip-github-actions-runners Description: Static egress IPs for GitHub Actions runners are a BYOC capability on AWS, GCP, and Azure. Here is the setup, the address math, and the per-GB cost. # Static IPs for GitHub Actions Runners Static egress IPs for GitHub Actions runners are a BYOC capability on AWS, GCP, and Azure. Here is the setup, the address math, and the per-GB cost. Last verified: 2026-08-13 A stable outbound IP address for GitHub Actions jobs comes from running the runner inside your own cloud account, because GitHub-hosted runners egress from a large shared pool of public addresses. On WarpBuild, static IPs are a BYOC capability on AWS, GCP, and Azure, switched on per custom runner, and they are unavailable on WarpBuild Cloud runners. This guide covers how to tell a real allowlist requirement from a workaround, how to turn static IPs on, how many addresses your partner ends up trusting, and what the private subnet path costs per gigabyte on your cloud bill. ## [Diagnosis](#diagnosis)Copy Anchor Link Three requests produce this ticket. Only one of them genuinely needs a fixed egress address, so separate them before you build anything. ### [Partner and vendor allowlists](#partner-and-vendor-allowlists)Copy Anchor Link A payment processor, an SFTP drop, or an enterprise customer's API gateway accepts connections from an approved list of source addresses. The security review that produced that list will reject a range that rotates. The symptom is an integration test that passes from a laptop on the office network and fails from GitHub Actions with a 403 or a refused TCP handshake. ### [Database and warehouse firewalls](#database-and-warehouse-firewalls)Copy Anchor Link Managed database security rules and warehouse network policies are address based. A migration job, a seed job, or a nightly export that runs on every merge has to arrive from an address the rule already trusts. Teams usually discover this when the staging rule was opened wide during a hackathon and the production rule was written properly. ### [Internal services behind an address rule](#internal-services-behind-an-address-rule)Copy Anchor Link Legacy internal endpoints often sit behind an address rule rather than an identity check. Deploy jobs, smoke tests, and cache warmers that call them fail in the same way, and the failure is intermittent enough that it gets retried for weeks before anyone reads the firewall logs. ### [Why GitHub-hosted runners cannot satisfy the requirement](#why-github-hosted-runners-cannot-satisfy-the-requirement)Copy Anchor Link GitHub publishes the address ranges its hosted runners use through the meta endpoint of its REST API. Checked on 2026-08-13, the `actions` key of [the GitHub meta endpoint](https://api.github.com/meta) listed 7,280 CIDR ranges. GitHub republishes that document as ranges change, so an allowlist built from it needs a sync job of its own and covers a very large public surface in the meantime. Most security reviews decline both properties, which is how the ticket reaches the runner team. ### [The availability fact](#the-availability-fact)Copy Anchor Link Static egress IPs are a BYOC capability. BYOC runs on AWS, GCP, and Azure, and the addresses come from your own account. WarpBuild Cloud runners have no static egress address and no label that grants one. A workflow that needs a fixed source address moves to a custom runner in your cloud; every other job in the repository can stay where it is. ### [Check the requirement before you build for it](#check-the-requirement-before-you-build-for-it)Copy Anchor Link Ask the endpoint owner whether an identity-based control clears the same review: OIDC federation with short-lived credentials, mTLS client certificates, or a signed request header. Address allowlists carry operational cost on both sides, and the party maintaining the list is usually happier without one. When the answer is that the address rule is contractual or comes from a compliance control, static IPs are the fix, and the rest of this guide is about applying them with a small blast radius. ## [Fix](#fix)Copy Anchor Link ### [1\. Connect the cloud account](#1-connect-the-cloud-account)Copy Anchor Link Connecting an account creates the IAM role or service account that WarpBuild uses to create and manage runners in your infrastructure. The flow starts in the BYOC section of the dashboard and is documented in the [BYOC setup documentation](/docs/ci/byoc). ### [2\. Create a stack with public and private subnets](#2-create-a-stack-with-public-and-private-subnets)Copy Anchor Link A stack is the region, VPC, subnets, and object storage that runners use. The VPC needs at least one public subnet and one private subnet, both with internet connectivity. Private subnets route outbound traffic through NAT gateways, and the addresses attached to those gateways become the external addresses your partner sees. Two configuration recommendations from the docs matter here. Use three public and three private subnets across three availability zones, which keeps instance type availability high. Size each subnet for the peak number of concurrent runners, with 250 addresses per subnet as the recommended floor. ### [3\. Create a custom runner with static IPs enabled](#3-create-a-custom-runner-with-static-ips-enabled)Copy Anchor Link Static IPs are a property of the custom runner, alongside instance types, disk, and fallback instances. Enabling the option places that runner's instances in the private subnets. The runner registers under a Runner ID prefixed with `warp-custom-`, and that ID is what `runs-on` references. ### [4\. Collect the addresses and hand them over](#4-collect-the-addresses-and-hand-them-over)Copy Anchor Link On AWS the addresses are the elastic IPs attached to the NAT gateways, one per availability zone, so a three-zone stack yields three addresses. GCP and Azure follow the same shape with their regional NAT addresses. The list stays stable across job counts, so the partner allowlist holds three entries whether you run 10 jobs a day or 10,000. ### [5\. Keep static IPs on one runner](#5-keep-static-ips-on-one-runner)Copy Anchor Link Create a second custom runner in the same stack with static IPs disabled and point everything else at it. Those instances launch in public subnets, skip the per-GB data processing charge, and behave identically otherwise. The cost model below shows what this split is worth per month. ### [6\. Put the definition in code](#6-put-the-definition-in-code)Copy Anchor Link Terraform support exists for BYOC on AWS, so stacks and runners live in the same module as the rest of your infrastructure. Setup details per cloud are in the [AWS BYOC documentation](/docs/ci/byoc/aws) and the [guide to running GitHub Actions runners in your own AWS account](/byoc/aws). ## [Configuration](#configuration)Copy Anchor Link ### [Split the workflow by which job needs the address](#split-the-workflow-by-which-job-needs-the-address)Copy Anchor Link The build job below runs on a WarpBuild Cloud Linux runner. Only the job that talks to the allowlisted partner runs on the BYOC runner with static IPs, so the expensive network path carries the smallest possible number of gigabytes. Copy Text ``` name: integration on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: make build - uses: actions/upload-artifact@v4 with: name: dist path: dist/ partner-sync: needs: build runs-on: warp-custom-partner-use1 steps: - uses: actions/download-artifact@v4 with: name: dist path: dist/ - name: Confirm the egress address run: curl -sS --fail https://api.ipify.org - name: Push the build to the partner endpoint env: PARTNER_TOKEN: ${{ secrets.PARTNER_TOKEN }} run: | curl -sS --fail \ -H "Authorization: Bearer ${PARTNER_TOKEN}" \ -F "bundle=@dist/bundle.tar.gz" \ https://partner.example.com/v1/ingest ``` The `Confirm the egress address` step is worth keeping permanently. When a partner reports a rejected request, that one line tells you within a single job run whether the address changed or the allowlist did. ### [Cloud quotas the static IP path consumes](#cloud-quotas-the-static-ip-path-consumes)Copy Anchor Link Quotas apply per stack in the stack's region, and they assume a peak concurrent job count. Generally available Linux and Windows runners do not have plan-level concurrency caps; on BYOC, your cloud quota is the relevant infrastructure ceiling. At 1,000 concurrent jobs the address quota below asks for 1,003 addresses. | Cloud | Resource | Quota per stack | What drives it | | ----- | --------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------- | | AWS | Elastic IPs | 3 plus one per concurrently running job | 3 for the NAT gateways across 3 availability zones, 1 attached to each running job | | AWS | NAT gateways | 3 | One per availability zone | | AWS | VPCs | 1 | One VPC per stack | | GCP | In-use regional external IPv4 addresses | 3 plus one per concurrently running job | Cloud NAT addresses plus one per running job | | GCP | Cloud NAT | 3 | One per stack | | Azure | In-use regional external IPv4 addresses | 3 plus one per concurrently running job | NAT addresses plus one per running job | | Azure | NAT | 3 | One per stack | Quota values transcribed from the WarpBuild BYOC documentation on 2026-08-13\. Compute and disk quotas scale with vCPU per job and disk per job and are listed on the same pages. Raise them before the first large run, because a quota rejection surfaces as a queued job rather than an error. ### [Runner labels and rates used above](#runner-labels-and-rates-used-above)Copy Anchor Link The Linux x64 labels referenced in the workflow, with per-minute rates from the [pricing page](/pricing): | Runner label | OS | vCPU | RAM | Storage | Price per minute | | -------------------------- | ------------ | ---- | ----- | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | BYOC Linux and Windows runners are billed at $0.002 per minute in WarpBuild fees, with the compute billed by your own cloud account at your own rates. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [Assumptions](#assumptions)Copy Anchor Link Every input is stated so you can substitute your own. Cloud rates are AWS list prices for US East (N. Virginia). | Input | Value | Source | | ---------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------- | | Jobs per month that touch the partner endpoint | 66 | Your workflow run history | | Bytes processed per partner job | 0.4 GB | Your job logs | | All other jobs per month | 1,800 | Your workflow run history | | Bytes processed per other job | 2.0 GB | Package installs, image pulls, cache restores | | Outbound bytes to the internet per job | 0.15 GB | Your job logs | | Average job duration | 8 minutes | Your workflow run history | | NAT data processing | $0.045 per GB, both directions | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13 | | NAT hourly charge | $0.045 per gateway-hour | [Amazon VPC pricing](https://aws.amazon.com/vpc/pricing/), checked on 2026-08-13 | | Data transfer out to the internet | $0.09 per GB after 100 GB free per month | [Amazon EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/), checked on 2026-08-13 | | WarpBuild BYOC fee | $0.002 per minute | [Pricing page](/pricing) | ### [The arithmetic](#the-arithmetic)Copy Anchor Link Fixed gateway charge: 3 gateways times 730 hours times $0.045 equals $98.55 per month. This line exists as soon as the stack has private subnets with gateways, whether one job uses them or every job does. Data processed by the partner jobs: 66 times 0.4 equals 26.4 GB. At $0.045 per GB that is $1.19. Data processed by every other job, if you point them at the static IP runner too: 1,800 times 2.0 equals 3,600 GB, plus the 26.4 GB above equals 3,626.4 GB. At $0.045 per GB that is $163.19. Data transfer out to the internet: 1,866 jobs times 0.15 GB equals 279.9 GB, less the 100 GB monthly allowance, leaves 179.9 GB billable at $0.09 equals $16.19\. This charge applies in public subnets as well, so static IPs do not create it. WarpBuild fee: 1,866 jobs times 8 minutes equals 14,928 minutes, at $0.002 per minute equals $29.86. | Monthly line | Public subnets, no static IPs | Static IPs scoped to partner jobs | Static IPs for the whole fleet | | ----------------------------- | ----------------------------- | --------------------------------- | ------------------------------ | | NAT hourly charge, 3 gateways | $0.00 | $98.55 | $98.55 | | NAT data processing | $0.00 | $1.19 | $163.19 | | Data transfer out | $16.19 | $16.19 | $16.19 | | WarpBuild BYOC fee | $29.86 | $29.86 | $29.86 | | Total | $46.05 | $145.79 | $307.79 | EC2 and EBS charges for the runner instances are billed by your cloud account and sit outside this table, because instance choice varies by workload. ### [What the model says](#what-the-model-says)Copy Anchor Link Scoping static IPs to the jobs that need them saves $162.00 a month in this shape, all of it data processing on gigabytes that never needed a fixed address. The saving scales with fleet size and image size, since the per-GB line is the only one that grows. The fixed $98.55 is the price of admission. Three gateways cost the same whether they carry 26 GB or 3,600 GB, so the decision is binary at the stack level and granular at the runner level. Two details from the [BYOC documentation](/docs/ci/byoc) belong in the same calculation. Data processing is charged in both directions, so a job that pulls 2 GB of packages and uploads 20 MB pays on the full 2 GB. Transfer between private subnets and gateways in different regions adds its own charge, so keep the subnets and the gateways inside the stack's region. The documentation states the conclusion plainly: static IPs should be used minimally. ### [Where WarpBuild fits](#where-warpbuild-fits)Copy Anchor Link Every cost figure on this page carries a source link and a checked-on date, and cloud list prices change. Re-check the two AWS pricing links before quoting these totals internally, and re-run the arithmetic against your own job counts and image sizes. On the enterprise tier, egress costs from your cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores during deployments, on BYOC and on WarpBuild-hosted runners alike; the scope is on the [zero egress page](/enterprise/zero-egress). The [BYOC capacity and quotas guide](/guides/byoc-capacity-and-quotas) covers the fleet-wide version of the numbers above, and [hosted runners compared with BYOC runners](/guides/hosted-vs-byoc-runners) covers the placement decision itself. For the network design around the stack, see [GitHub Actions runners inside your own VPC](/guides/github-actions-runners-in-your-vpc). ## [FAQ](#faq)Copy Anchor Link **Can WarpBuild Cloud runners have a static egress IP?** No. Static egress IPs are a BYOC capability, available when runners run in your own AWS, GCP, or Azure account. WarpBuild Cloud runners egress from shared addresses, and no runner label or configuration flag changes that. **How many addresses does a partner have to allowlist?** Three per stack. The addresses belong to the NAT gateways, one per availability zone, so the allowlist holds three entries no matter how many jobs run. The elastic IP quota is separate: 3 plus one address per concurrently running job. **What do static IPs cost?** They move runners into private subnets, which adds charges on your own cloud bill. In US East (N. Virginia) AWS charges $0.045 per hour per gateway plus $0.045 per GB of data processed in both directions, so three gateways cost $98.55 a month before the first gigabyte moves. Amazon VPC pricing, checked on 2026-08-13. **Should every runner use static IPs?** No. Keep one custom runner with static IPs for the jobs that hit an allowlisted endpoint and a second runner without them for everything else. The second runner lands in public subnets and skips the per-GB data processing charge entirely. --- # Running Only the Tests a Change Affects | WarpBuild URL: https://www.warpbuild.com/guides/test-impact-analysis-github-actions Description: Pick the tests a change actually affects with path filters, dependency graph selection, or coverage maps. Detection job YAML, matrix fan-out, and cost math. # Running Only the Tests a Change Affects Pick the tests a change actually affects with path filters, dependency graph selection, or coverage maps. Detection job YAML, matrix fan-out, and cost math. Last verified: 2026-08-13 Running only the tests a change affects means computing the affected set from the diff in a detection job, then fanning out test jobs over that set instead of over the whole suite. The selection has to be conservative in one direction only: a test that should have run and did not is a defect that reaches the default branch, so every scheme needs a named safety net that runs everything on a fixed trigger. This guide covers what breaks when selection is added without one, the three selection strategies ranked by how much they can be trusted, a workflow whose detection job output drives a matrix of affected projects, and the arithmetic that prices selected runs against full runs at a stated change profile. For the vocabulary see the [task graph entry](/glossary/task-graph), for the repository shape that makes selection worth doing see the [monorepo solution page](/solutions/monorepo-github-actions), and for the rest of the spend see the [hub on reducing GitHub Actions costs](/guides/reduce-github-actions-costs). ## [Diagnosis](#diagnosis)Copy Anchor Link Two distinct problems live under the same heading, and they pull in opposite directions. ### [The suite grew and the diff did not](#the-suite-grew-and-the-diff-did-not)Copy Anchor Link A repository accumulates projects faster than any single pull request touches them. The suite runs all of them anyway, so billed minutes track the size of the repository while the work under review tracks the size of the diff. The gap widens every quarter and nothing in a green run reports it. Measure it before changing anything. The Jobs report aggregates per unique repository, workflow, and job name combination and gives duration P75 and P90 for each, with CSV export ([reports documentation](/docs/ci/features/reports)). Export a month, group the rows by project, and multiply run count by P75 duration: that product is what each project costs to keep in the default set. On GitHub-hosted runners, pull the same shape from the workflow jobs API and aggregate it yourself. ### [Selection was added and nothing catches the misses](#selection-was-added-and-nothing-catches-the-misses)Copy Anchor Link The second problem appears after the first is fixed. A selection rule that skips a test which the change actually broke produces a green pull request and a broken default branch, and the failure surfaces later with a wider blast radius. The symptoms are specific enough to name. | Symptom | What it means | Where to look | | ---------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------- | | Billed minutes scale with repository size rather than diff size | No selection layer at all | Jobs report, run count times duration P75 per job name | | A change passes and the default branch breaks on the same commit | The selection rule missed an edge | Diff against the affected set the detection job printed | | A dependency bump touches one file and tests one project | Lockfile and shared config paths are inside a narrow glob | Path filter globs against the changed file list | | Generated code changes and nothing runs | The generator output is untracked or outside every filter | Generated paths against the filter set | | The run fails before any test job starts | The affected set was empty and the matrix expanded to zero entries | Detection job output and the guard on the test job | | Selection lands and wall clock does not move | The full fan-out already ran in parallel | Job start times across the matrix | | Nobody trusts the pull request result | No full run exists to compare against | Schedule and push triggers on the workflow | ## [Fix](#fix)Copy Anchor Link Pick the strategy whose blind spots you can live with, then bound those blind spots with a safety net. The three options rank cleanly by how much of the real dependency structure each one can see. | Strategy | What it reads | Misses it produces | Trust | | -------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------ | | Path filters | Changed file paths matched against globs | Anything coupled across a glob boundary: shared config, generated code, a lockfile bump, a base image change | Lowest | | Dependency graph selection | The project graph declared in build files, walked from the changed projects to their dependents | Edges the build files never declare: fixtures loaded by path, runtime service lookups, environment config | Middle | | Coverage-based selection | A recorded map from each test to the source lines that test executed | Code paths no recorded run reached, plus drift between map refreshes | Highest while the map is fresh | Path filters also have a first-class form in the workflow file itself, covered in the [answer on running a job only when files change](/answers/how-do-i-run-a-job-only-when-files-change). The detection job below computes the same idea once and publishes the result, which is what lets a single decision drive a matrix instead of a per-job condition. Three rules make any of them safe to gate on. **Resolve the base explicitly.** Compute the merge base against the target branch rather than diffing against the previous commit, so a pull request that sat open for a week still sees every file it changed. That requires full history on the checkout, since the default shallow fetch has no merge base to find. **Widen on the paths that mean everything changed.** A lockfile, a shared build config, a base image tag, or the detection script itself should expand the affected set to all projects. Encode that as an explicit list of trigger paths inside the detection step rather than leaving it to the graph. **Name the safety net.** The full suite runs on a nightly schedule and on every push to the default branch, whatever the selection said about the pull request. A miss then costs one night of exposure, and a scheduled full run that fails after a green pull request is the signal that a rule has a hole. Keep the escape hatch manual as well: a label on the pull request that forces the full set. ## [Configuration](#configuration)Copy Anchor Link The detection job resolves the affected projects and publishes them as a JSON array. The test job expands that array into its matrix. A third job with a fixed name owns the branch protection check. Copy Text ``` name: test on: pull_request: push: branches: [main] schedule: - cron: "0 3 * * *" jobs: detect: name: detect affected projects runs-on: warp-ubuntu-latest-x64-2x outputs: projects: ${{ steps.affected.outputs.projects }} count: ${{ steps.affected.outputs.count }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - id: base run: | ref="${{ github.event.pull_request.base.ref || 'main' }}" echo "sha=$(git merge-base "origin/$ref" HEAD)" >> "$GITHUB_OUTPUT" - id: affected env: FORCE_FULL: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-tests') }} run: | if [ "$FORCE_FULL" = "true" ]; then projects=$(node scripts/list-projects.mjs --all) else projects=$(node scripts/list-projects.mjs --affected-since "${{ steps.base.outputs.sha }}") fi echo "projects=$projects" >> "$GITHUB_OUTPUT" echo "count=$(echo "$projects" | jq 'length')" >> "$GITHUB_OUTPUT" test: name: test ${{ matrix.project }} needs: detect if: needs.detect.outputs.count != '0' runs-on: warp-ubuntu-latest-x64-8x strategy: fail-fast: false matrix: project: ${{ fromJSON(needs.detect.outputs.projects) }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - run: npm run test --workspace ${{ matrix.project }} gate: name: tests needs: [detect, test] if: always() runs-on: warp-ubuntu-latest-x64-2x steps: - name: Fail when detection failed if: needs.detect.result != 'success' run: exit 1 - name: Fail when an affected project failed if: needs.test.result == 'failure' || needs.test.result == 'cancelled' run: exit 1 ``` What each piece does: * `fetch-depth: 0` gives the checkout full history so `git merge-base` has something to resolve; the default fetch is shallow and the merge base lookup fails on it ([actions/checkout](https://github.com/actions/checkout), checked on 2026-08-13). * The `FORCE_FULL` expression turns the schedule trigger, pushes to `main`, and a `full-tests` label into the same code path, so the safety net and the manual escape hatch share one branch of the script. * `fromJSON` converts the job output string into the matrix vector, since job outputs are strings and `strategy.matrix` needs a list ([GitHub Actions contexts](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts), checked on 2026-08-13). * The `if` guard on the test job earns its place for a subtler reason than it looks: an empty matrix vector fails the run rather than skipping the job, so a diff that touches only documentation would turn red without the count check. * `gate` carries a fixed name while the matrix job names carry the project, so branch protection has one check to require that survives every change in the affected set. It fails on `cancelled` as well as `failure`, which is what stops a cancelled matrix from reporting a pass. * A matrix expands to at most 256 jobs per run ([GitHub Actions usage limits](https://docs.github.com/en/actions/reference/limits), checked on 2026-08-13), so a repository past that count needs the full-set branch to chunk projects into groups rather than emit one job each. The same detection job drives matrices on any of them by changing one `runs-on` label. The two labels above resolve to these machines, from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. | runs-on label | vCPU | RAM | Storage | USD per minute | | ------------------------- | ---- | ----- | --------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | Where a project's suite is itself the long pole, the two techniques compose: select the affected projects here, then shard each project's suite across a second matrix dimension as described in the [guide to matrix sharding](/guides/matrix-sharding-github-actions). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Selection pays in billed minutes, so the model needs a change profile rather than a single number. Stated profile: 40 projects, each with a 6 minute test job on `warp-ubuntu-latest-x64-8x`, a 1 minute detection job on `warp-ubuntu-latest-x64-2x`, 40 runs a day over 22 working days, and a distribution measured from one month of merged pull requests. | Change shape | Share of runs | Projects tested | Billed minutes | Cost per run | | --------------------------------- | ------------- | --------------- | -------------- | ------------ | | One project touched | 70 percent | 1 | 7 | $0.10 | | Three projects touched | 20 percent | 3 | 19 | $0.29 | | Eight projects touched | 8 percent | 8 | 49 | $0.77 | | Lockfile or shared config touched | 2 percent | 40 | 241 | $3.84 | | Weighted average of the four rows | 100 percent | 2.74 | 17.4 | $0.27 | | Full suite on every run | 100 percent | 40 | 240 | $3.84 | The weighted average is the number to plan against: `0.70 * 1 + 0.20 * 3 + 0.08 * 8 + 0.02 * 40` is 2.74 projects, or 16.4 test minutes plus the 1 minute detection job. The lockfile row is what keeps the average honest, because widening on shared paths is the rule that makes the other three rows safe. Add the safety net back. The nightly full run costs $3.84 once a day, or $84.48 over 22 days, and pushes to the default branch add one full run per merge on top of that. Even counting the nightly, the month lands at roughly $319 against $3,379 for testing everything on every run. Those billed minutes carry a list price. GitHub rates below are from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Plan | 8 vCPU minutes per month | On warp-ubuntu-latest-x64-8x at $0.016 | On the GitHub-hosted 8-core larger runner at $0.022 | | --------------------------------- | ------------------------ | -------------------------------------- | --------------------------------------------------- | | Full suite on every run | 211,200 | $3,379.20 | $4,646.40 | | Selection plus a nightly full run | 19,747 | $315.96 | $434.44 | The 880 detection jobs add 880 minutes on `warp-ubuntu-latest-x64-2x`, or $3.52 a month, which is the entire overhead the selection layer introduces. `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13. Read the wall clock separately from the invoice. A matrix of 40 projects and a matrix of 3 both finish in roughly the duration of the slowest single project when every job starts at once, so wall clock moves only where the full fan-out exceeded the parallelism the run actually received, or where projects ran in sequence. The billed minutes move in every case. Full rates by runner type are on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link **How do I run only the tests a pull request affects in GitHub Actions?** Add a detection job that checks out full history, computes the merge base against the target branch, resolves the affected projects from the diff, and writes a JSON array to a job output. A dependent test job expands that array with `fromJSON` into `strategy.matrix`, so the fan-out is the affected set rather than the whole suite. Guard the test job with a count check, because an empty matrix list fails the run with a matrix error rather than skipping the job. **What happens when the selection misses a test that should have run?** That is the failure mode selection has to be designed around, so every scheme carries a safety net that runs the full suite on a fixed trigger: a nightly schedule and every push to the default branch. A miss then costs one night of exposure instead of a defect that sits in the branch indefinitely, and a full run that fails while the pull request passed is the signal that the selection rule has a hole in it. **Does test selection cut wall clock or billed minutes?** Billed minutes move first and by the larger factor, because the jobs that never start are the ones that stop being charged. Wall clock moves only when the full fan-out was wider than the parallelism the run actually got, or when the suite ran serially, since a matrix of 40 projects and a matrix of 3 both finish in roughly the duration of the slowest single project when every job starts at once. --- # TestFlight Uploads from GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/testflight-uploads-github-actions Description: Archive and export on a macOS runner, write an App Store Connect API key into the runner temp directory, then upload the IPA with xcrun altool on a tag. # TestFlight Uploads from GitHub Actions Archive and export on a macOS runner, write an App Store Connect API key into the runner temp directory, then upload the IPA with xcrun altool on a tag. Last verified: 2026-08-13 Uploading a build to TestFlight from GitHub Actions is one job on a macOS runner running four stages in order: `xcodebuild archive` produces an `.xcarchive`, `xcodebuild -exportArchive` turns it into a signed `.ipa`, an App Store Connect API key gets written where Apple's tooling looks for it, and `xcrun altool --upload-app` hands the IPA to App Store Connect. The stage that fails is rarely the upload command; it is usually the export method, the build number, or the location of the `.p8` key file. ## [Diagnosis](#diagnosis)Copy Anchor Link Read the exact string App Store Connect returns before changing anything, because five different problems all surface as a red upload step. | Message in the log | Cause | Where the fix lives | | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------ | | Unable to authenticate or HTTP 401 from altool | Key file missing, misnamed, or outside the search path | Key write step | | No suitable application records were found | Bundle identifier has no App Store Connect record, or \-t names the wrong platform | App Store Connect record | | The bundle version must be higher than the previously uploaded version | Build number repeated across uploads | Build number step | | Invalid Provisioning Profile on an otherwise signed IPA | Archive exported with a development method rather than an App Store method | ExportOptions.plist | | Missing or invalid signature | Archive signed with a development certificate | Signing step | **Key file resolution.** `altool` locates the private key by filename rather than by a path passed on the command line. It expects `AuthKey_.p8` inside the directory named by `API_PRIVATE_KEYS_DIR`, falling back to `./private_keys`, `~/private_keys`, `~/.private_keys`, and `~/.appstoreconnect/private_keys`. A job that decodes the secret to `$RUNNER_TEMP/key.p8` and passes `--apiKey` gets a 401 that reads like a revoked key. **Export method.** The export method in `ExportOptions.plist` decides which profile the exporter selects and which entitlements land in the IPA. An archive exported for development signs cleanly on the runner and is rejected on upload, which wastes the entire archive step. **Build number collisions.** App Store Connect rejects a build whose `CFBundleVersion` matches one already uploaded for that marketing version. A workflow that re-runs after a transient network error re-uploads the same build number and fails on the second attempt. **Secret exposure.** Two habits leak the API key: decoding it inside `$GITHUB_WORKSPACE` where a wildcard artifact upload will collect it, and uploading the whole export directory instead of the IPA. GitHub masks the raw secret value in logs, and it does not mask a file you wrote and then published as an artifact. ## [Fix](#fix)Copy Anchor Link Keep all four stages in one job. The IPA exists on one machine, and each WarpBuild job runs in its own virtual machine created on demand and destroyed after the build, so a second job starts with an empty disk. The runner isolation model is described in the [runner security documentation](/docs/ci/security). **Gate the job on a tag.** Put the upload behind `on: push` with a `tags` filter plus `workflow_dispatch`. A pull request run has no reason to consume an App Store Connect slot, and fork pull requests receive no secrets at all, so an ungated upload job fails on every external contribution. **Write the key where altool looks.** Set `API_PRIVATE_KEYS_DIR` to a directory under `$RUNNER_TEMP`, decode the base64 secret into `AuthKey_$ASC_KEY_ID.p8` inside it, and tighten permissions before the upload runs. **Set the build number from the run number.** `agvtool new-version -all "$GITHUB_RUN_NUMBER"` gives every run a monotonic `CFBundleVersion` without a commit back to the repository. **Validate before you upload.** `xcrun altool --validate-app` runs the same asset checks as the upload without consuming a build slot. It turns a rejected upload into a failure that costs seconds rather than a full re-archive. **Restrict the artifact path.** Give `actions/upload-artifact` a glob that matches `*.ipa` and nothing else, and drop the key directory in an `if: always()` teardown step. **Move the runner label.** WarpBuild runners register with GitHub as self-hosted runners carrying `warp-` labels, so changing `runs-on` is the whole migration. The macOS catalog carries several sizes, so the release job and the pull request test job can sit on different labels in one workflow file. ## [Configuration](#configuration)Copy Anchor Link Copy Text ``` name: testflight on: push: tags: ["v*"] workflow_dispatch: permissions: contents: read jobs: upload: runs-on: warp-macos-26-arm64-12x environment: release timeout-minutes: 45 env: API_PRIVATE_KEYS_DIR: ${{ runner.temp }}/asc_keys steps: - uses: actions/checkout@v4 - name: Select the Xcode that ships the release SDK run: sudo xcode-select -s "$(ls -d /Applications/Xcode_27*.app | tail -1)" - name: Install signing assets env: SIGNING_CERT_P12: ${{ secrets.SIGNING_CERT_P12 }} SIGNING_CERT_PASSWORD: ${{ secrets.SIGNING_CERT_PASSWORD }} PROVISIONING_PROFILE: ${{ secrets.PROVISIONING_PROFILE }} run: | KEYCHAIN="$RUNNER_TEMP/build.keychain-db" KEYCHAIN_PASSWORD="$(openssl rand -base64 24)" echo "KEYCHAIN=$KEYCHAIN" >> "$GITHUB_ENV" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security list-keychains -d user -s "$KEYCHAIN" login.keychain-db echo "$SIGNING_CERT_P12" | base64 --decode > "$RUNNER_TEMP/cert.p12" security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \ -P "$SIGNING_CERT_PASSWORD" \ -T /usr/bin/codesign -T /usr/bin/security rm -f "$RUNNER_TEMP/cert.p12" security set-key-partition-list \ -S apple-tool:,apple:,codesign: \ -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" > /dev/null PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" mkdir -p "$PROFILES" echo "$PROVISIONING_PROFILE" | base64 --decode \ > "$PROFILES/build.mobileprovision" - name: Stamp the build number run: agvtool new-version -all "$GITHUB_RUN_NUMBER" - name: Archive run: | xcodebuild archive \ -scheme App \ -configuration Release \ -destination "generic/platform=iOS" \ -archivePath "$RUNNER_TEMP/App.xcarchive" \ -derivedDataPath "$RUNNER_TEMP/DerivedData" \ CODE_SIGN_STYLE=Manual \ DEVELOPMENT_TEAM=${{ vars.DEVELOPMENT_TEAM }} \ PROVISIONING_PROFILE_SPECIFIER="${{ vars.PROFILE_NAME }}" \ OTHER_CODE_SIGN_FLAGS="--keychain $KEYCHAIN" - name: Export the IPA run: | xcodebuild -exportArchive \ -archivePath "$RUNNER_TEMP/App.xcarchive" \ -exportOptionsPlist ci/ExportOptions.plist \ -exportPath "$RUNNER_TEMP/export" - name: Write the App Store Connect API key env: ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} run: | mkdir -p "$API_PRIVATE_KEYS_DIR" chmod 700 "$API_PRIVATE_KEYS_DIR" echo "$ASC_KEY_P8" | base64 --decode \ > "$API_PRIVATE_KEYS_DIR/AuthKey_$ASC_KEY_ID.p8" chmod 600 "$API_PRIVATE_KEYS_DIR/AuthKey_$ASC_KEY_ID.p8" - name: Validate then upload env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} run: | IPA="$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)" xcrun altool --validate-app -f "$IPA" -t ios \ --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" xcrun altool --upload-app -f "$IPA" -t ios \ --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" - uses: actions/upload-artifact@v4 with: name: ipa path: ${{ runner.temp }}/export/*.ipa retention-days: 14 - name: Tear down if: always() run: | rm -rf "$API_PRIVATE_KEYS_DIR" security delete-keychain "$KEYCHAIN" || true ``` ### [Export options that decide whether the upload is accepted](#export-options-that-decide-whether-the-upload-is-accepted)Copy Anchor Link `ci/ExportOptions.plist` is the file that separates an IPA App Store Connect accepts from one it rejects. | Key | Value for a TestFlight build | What breaks without it | | -------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- | | method | An App Store export method for your Xcode version | Upload rejected as an invalid provisioning profile | | teamID | The ten character team identifier | Export picks the wrong team when the keychain holds two | | signingStyle | manual | Automatic signing asks for an App Store Connect session the job cannot supply | | provisioningProfiles | Bundle identifier mapped to the installed profile name | Export fails to find a profile for the target | | uploadSymbols | true | TestFlight crash reports arrive unsymbolicated | | stripSwiftSymbols | true | Larger IPA, and larger IPAs cost upload minutes | The `provisioningProfiles` dictionary takes the profile's own name, which differs from its filename on disk. See [provisioning profile](/glossary/provisioning-profile) for how the profile, the certificate, and the bundle identifier have to agree, and the [code signing guide](/guides/ios-code-signing-github-actions) for the keychain half of the job. `warp-macos-26-arm64-12x` ships Xcode 27.0 with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes. Xcode 27.0 is a WarpBuild addition on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 image is in beta, and a dedicated macOS 27 image follows once that image is released. The full label list lives in the [cloud runners documentation](/docs/ci/cloud-runners). When the upload step behaves differently on the runner than on a laptop, the Action Debugger pauses the workflow and opens an SSH session on the runner machine, which is the shortest route to inspecting the key directory in place. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link macOS labels and rates, from the [WarpBuild pricing page](/pricing) and the cloud runners documentation, verified on 2026-08-13: | Label | macOS | vCPU | Memory | Storage | Rate per minute | Alias | | ----------------------- | -------- | ---- | ------ | --------- | --------------- | --------------------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | | ### [Where a release job spends its minutes](#where-a-release-job-spends-its-minutes)Copy Anchor Link Substitute your own stage timings; these are placeholders for a medium iOS app so the arithmetic is visible. | Stage | Assumed minutes | At $0.08 per minute | At $0.16 per minute | | --------------------------------------- | --------------- | ------------------- | ------------------- | | Checkout and package resolution | 2.0 | $0.16 | $0.32 | | Keychain, certificate, profile | 0.5 | $0.04 | $0.08 | | xcodebuild archive | 14.0 | $1.12 | $2.24 | | xcodebuild -exportArchive | 2.0 | $0.16 | $0.32 | | altool --validate-app and \--upload-app | 3.0 | $0.24 | $0.48 | | Total | 21.5 | $1.72 | $3.44 | Only the archive row responds to a wider label. Export is bounded by disk, and upload is bounded by IPA size and network throughput, so a 12 vCPU label has to cut archive minutes by more than half before it pays for itself against the 6 vCPU label. Twenty TestFlight builds in a month is 430 minutes, or $34.40 at $0.08 per minute. `warp-macos-latest-arm64-6x` (6 vCPU, 14GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14GB), which is 22 percent lower list price, with one more vCPU and the same memory on the WarpBuild side. GitHub list price read from the [GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. The same 430 minutes at the GitHub-hosted rate is $43.86, a difference of $9.46 per month at this volume. Two cost mistakes are worth naming. The first is polling App Store Connect for processing status on the macOS runner: `altool` returns once the upload finishes, and waiting for the build to appear in TestFlight burns macOS minutes at $0.08 or $0.16 while the work happens on Apple's side. Move that poll to a separate job on `warp-ubuntu-latest-x64-2x` at $0.004 per minute, which reads the App Store Connect API over HTTP and needs no Xcode. The second is skipping `--validate-app`: a rejected upload after a 14 minute archive costs the whole archive again, while the validation step costs part of a minute. CI observability keeps the per-stage minute counts in the table honest over time, so the sizing decision stays measured rather than assumed. For the label catalog and image details, start with [WarpBuild macOS runners for GitHub Actions](/runners/macos). For the signing half of this job, see [code signing iOS builds on GitHub Actions](/guides/ios-code-signing-github-actions), and for the same upload expressed as a lane, see [fastlane lanes on GitHub Actions](/solutions/fastlane-github-actions). ## [FAQ](#faq)Copy Anchor Link **Why does `xcrun altool` fail with a 401 when the key works locally?** `altool` resolves the private key by filename rather than by a path passed on the command line. The file has to be named `AuthKey_.p8` and sit in the directory named by `API_PRIVATE_KEYS_DIR`, or in `./private_keys`, `~/private_keys`, `~/.private_keys`, or `~/.appstoreconnect/private_keys`. A key decoded to any other path produces an authentication failure that looks like a bad key. **How do I keep the App Store Connect API key out of logs and artifacts?** Decode the `.p8` into a directory under `$RUNNER_TEMP` rather than the workspace, `chmod` the directory to 700 and the key to 600, and give `actions/upload-artifact` a path glob that matches the IPA only. GitHub masks secret values in logs automatically, and the WarpBuild runner VM and its encrypted volume are destroyed after the build. **Which macOS label should a TestFlight release job use?** The archive step is the only part that scales with vCPU, so start on `warp-macos-15-arm64-6x` at $0.08 per minute and move to `warp-macos-26-arm64-12x` at $0.16 per minute only if archive minutes fall by more than half. Export and upload are dominated by disk and network, and a wider label does not shorten them. --- # Caching vcpkg and Conan Packages on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/vcpkg-and-conan-caching-github-actions Description: vcpkg and Conan rebuild from source on GitHub Actions until the binary cache outlives the job. Restore the archives, key them safely, and ban source builds. # Caching vcpkg and Conan Packages on GitHub Actions vcpkg and Conan rebuild from source on GitHub Actions until the binary cache outlives the job. Restore the archives, key them safely, and ban source builds. Last verified: 2026-08-13 vcpkg and Conan both answer a dependency request by looking for a prebuilt binary in a local directory first, and on GitHub Actions that directory is created with the job and destroyed with it, so every run compiles the whole tree from source. The fix has three parts: put each tool's binary store on a path a cache action can restore and save, key that path on the inputs the tool already hashes, and fail the job when a package would be compiled from source anyway. This guide covers where each tool keeps its binaries, which key inputs make a hit safe, a workflow that restores the archives and refuses to fall back to a source build, and a time model that prices source builds against binary cache hits for a stated dependency count. It sits under the [persistent caches for GitHub Actions runs](/guides/persistent-caches-github-actions) hub. ## [Diagnosis](#diagnosis)Copy Anchor Link ### [Where the binaries live and what the key has to cover](#where-the-binaries-live-and-what-the-key-has-to-cover)Copy Anchor Link Both tools already compute a content hash for every package. The cache key only has to cover the coarse inputs that decide which hashes are in play at all, so the restored archive belongs to the same compiler, the same platform, and the same dependency set. | Package manager | Binary store | Path on a runner | Key inputs that make a hit safe | | --------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | vcpkg | Binary cache archives, one per port ABI hash | $HOME/.cache/vcpkg/archives on Linux, %LOCALAPPDATA%\\vcpkg\\archives on Windows, or the path given to the files provider in VCPKG\_BINARY\_SOURCES | Hash of vcpkg.json and vcpkg-configuration.json, the builtin-baseline commit, the triplet, the runner image label | | Conan 2 | Package folders under the cache home | \~/.conan2 by default, or $CONAN\_HOME | Hash of conan.lock and the profile files, plus the settings in the host profile: os, arch, compiler, compiler.version, compiler.libcxx, build\_type | The per package identity underneath is where correctness actually lives. vcpkg computes an ABI hash for each port from the port version and selected features, the portfile contents, the triplet settings, the compiler it detects, and the ABI hashes of that port's dependencies; the [vcpkg binary caching documentation](https://learn.microsoft.com/en-us/vcpkg/consume/binary-caching) describes the providers and the default `files` behavior. Conan computes a `package_id` from the recipe reference and revision, the settings above, the options, and the resolved requirements. A stale archive cannot be served to a different compiler by accident, because the hash changes first. That leaves the key doing one job: keeping the restored directory small and relevant. See [what a cache key is](/glossary/cache-key) for the general shape. ### [Confirm the run is compiling rather than downloading](#confirm-the-run-is-compiling-rather-than-downloading)Copy Anchor Link Read the job log before editing anything. vcpkg prints a line per port telling you whether it restored from a binary cache or built, and the summary at the end reports how many packages were installed from cache. Force the detail out with: Copy Text ``` vcpkg install --debug 2>&1 | tee vcpkg-install.log grep -c "Restored from" vcpkg-install.log grep -c "Building" vcpkg-install.log ``` On the Conan side, `conan install . --build=missing` reports every package it decided to build. Two commands give you the denominator and the actual decision: Copy Text ``` conan graph info . --format=json | jq '[.graph.nodes[] | select(.ref != null)] | length' conan install . --build=missing --format=json | jq -r '.graph.nodes[] | "\(.ref) \(.binary)"' ``` The `binary` field on each node reads `Cache`, `Download`, or `Build`. Count the `Build` entries. That count, and the wall clock of the install step, are the two inputs to the model at the end of this page. ### [Decide whether the transfer pays](#decide-whether-the-transfer-pays)Copy Anchor Link The restore and the save are runner minutes, and a C++ dependency tree compresses poorly. Time both steps on a real run. WarpBuild CI observability correlates runner system metrics with GitHub Actions job logs, which separates an install step saturating every core on compilation from one sitting in a network transfer, and the Action Debugger opens a session on the live runner when a port keeps missing and the logs will not say why. Snapshot runners, remote Docker builders, and an MCP server sit on the same surface, and the snapshot path matters here once the archives plus the build trees outgrow what a transfer can carry. ## [Fix](#fix)Copy Anchor Link **1\. Give vcpkg an explicit binary source inside the workspace.** Set `VCPKG_BINARY_SOURCES` to `clear;files,${{ github.workspace }}/.vcpkg-cache,readwrite`. The `clear` term drops the default source so exactly one directory is in play, and `readwrite` lets the same run consume and populate it. Other providers exist, including a NuGet feed provider, and the choice does not change anything else on this page. **2\. Pin the vcpkg baseline in the manifest.** `builtin-baseline` in `vcpkg.json` fixes the registry commit that resolves port versions, so the ABI hashes stop moving between runs for reasons unrelated to your code. Without it, a port set can change under you and every archive in the cache becomes unreachable. **3\. Point Conan at a lock file and a committed profile.** Generate `conan.lock` with `conan lock create .`, commit it along with the profiles under `.conan-profiles/`, and pass both on every install. The lock file fixes the recipe revisions, and the profile fixes the settings that feed `package_id`. **4\. Restore before the install step and save after it.** `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` and is enabled by default on WarpBuild Linux runners; the inputs, the key matching, and the scoping rules are in the [caching documentation](/docs/ci/features/caching). Use the split `restore` and `save` actions so the save can be gated on a successful build. **5\. Ban the accidental source build.** Add `--only-binarycaching` to `vcpkg install` and `--build=never` to `conan install`. Each one turns a silent 30 minute compile into a failed step with the name of the package that missed. Run that strict form on the default branch and on release workflows, and let pull requests use a permissive form so a contributor adding one dependency is not blocked. **6\. Move to machine state when the archives outgrow the transfer.** Once the binary cache plus the build trees runs to tens of gigabytes, restoring it costs more than it returns. [Snapshot runners](/docs/ci/features/snapshot-runners) boot a later job from a captured VM image with the packages already on disk, which takes the transfer off the critical path. The compiler side of the same problem is covered in [using ccache for C and C++ builds on GitHub Actions](/guides/ccache-github-actions), and a GUI application that pulls its own dependencies through either tool alongside Qt is covered in [Qt application builds on GitHub Actions](/solutions/qt-github-actions). ## [Configuration](#configuration)Copy Anchor Link ### [The workflow](#the-workflow)Copy Anchor Link Copy Text ``` name: deps on: pull_request: push: branches: [main] jobs: vcpkg: runs-on: warp-ubuntu-latest-x64-16x timeout-minutes: 60 env: VCPKG_BINARY_SOURCES: clear;files,${{ github.workspace }}/.vcpkg-cache,readwrite VCPKG_DEFAULT_TRIPLET: x64-linux steps: - uses: actions/checkout@v4 - name: Restore the vcpkg binary cache id: vcpkg-cache uses: WarpBuilds/cache/restore@v1 with: path: ${{ github.workspace }}/.vcpkg-cache key: vcpkg-x64-linux-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }}-${{ github.sha }} restore-keys: | vcpkg-x64-linux-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }}- vcpkg-x64-linux- - name: Install dependencies from binaries only run: > vcpkg install --triplet x64-linux --only-binarycaching --x-manifest-root=. - name: Configure and build run: | cmake -S . -B build -G Ninja \ -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ -DVCPKG_INSTALL_OPTIONS=--only-binarycaching cmake --build build --parallel 16 - name: Save the vcpkg binary cache if: success() && github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: ${{ github.workspace }}/.vcpkg-cache key: ${{ steps.vcpkg-cache.outputs.cache-primary-key }} conan: runs-on: warp-ubuntu-latest-x64-16x timeout-minutes: 60 steps: - uses: actions/checkout@v4 - name: Restore the Conan package archive id: conan-cache uses: WarpBuilds/cache/restore@v1 with: path: conan-cache.tgz key: conan-x64-linux-${{ hashFiles('conan.lock', '.conan-profiles/**') }} fail-on-cache-miss: ${{ github.ref == 'refs/heads/main' }} - name: Load the archive into the Conan cache if: steps.conan-cache.outputs.cache-hit == 'true' run: conan cache restore conan-cache.tgz - name: Install dependencies without building run: > conan install . --lockfile=conan.lock --profile:host=.conan-profiles/linux-x64 --profile:build=.conan-profiles/linux-x64 --build=never --output-folder=build - name: Build run: | cmake -S . -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake cmake --build build --parallel 16 - name: Repack and save the Conan cache if: success() && github.ref == 'refs/heads/main' run: conan cache save "*:*" --file=conan-cache.tgz - if: success() && github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: conan-cache.tgz key: ${{ steps.conan-cache.outputs.cache-primary-key }} ``` Three inputs carry the guard. `--only-binarycaching` makes vcpkg exit with an error listing the ports that had no archive. `--build=never` makes Conan exit with an error naming the missing `package_id`. `fail-on-cache-miss` on the restore step stops the default branch from running at all when the entry is gone, which surfaces a broken key immediately rather than as a 30 minute job. ### [Key design](#key-design)Copy Anchor Link Put the manifest hash in the middle of the key and the commit SHA at the end. The commit term guarantees the primary key never matches, so `restore-keys` always resolves to the newest entry on the prefix and the save always writes a fresh entry. The manifest term gives the fallback two levels: same dependency set first, then any recent entry for the triplet. Lead the key with the triplet or the profile name, never with `runner.os` alone. A matrix that spans them needs one key prefix per target: archives built for `x64-linux` cannot serve a job on `warp-ubuntu-latest-arm64-16x`, and a Windows job on `warp-windows-latest-x64-8x` needs its own prefix again. Both tools would refuse the mismatched package anyway, so a shared prefix costs transfer time and returns nothing. Save on pushes to the default branch and restore everywhere. A pull request that adds one port then compiles that port and throws the archive away, which is the correct trade: the alternative writes an entry per pull request and pays storage for entries read once. ### [Retention and fees](#retention-and-fees)Copy Anchor Link A cache entry expires 7 days after its last use, and it can be deleted at any time from the action or from the console. Cache storage bills at $0.20 per GB-month and every write or restore bills at $0.0001 per operation on hosted runners; on BYOC runners, cache storage and operations are included. Entries are scoped to the key, the version, and the branch, so a feature branch reads the default branch entry through the fallback prefix and cannot overwrite it. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Runner rates come from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners). GitHub list prices come from the [Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. Replace the build inputs with the counts you collected in the diagnosis section. ### [Assumptions](#assumptions)Copy Anchor Link * A vcpkg manifest whose closure resolves 62 ports, producing 1.2 GB of archives. * A Conan graph of 48 packages, producing a 0.9 GB archive from `conan cache save`. * Both jobs on `warp-ubuntu-latest-x64-16x` at $0.032 per minute. * Cold source builds: 34.0 minutes for the 62 vcpkg ports, 21.0 minutes for the 48 Conan packages. * Full binary hit: 2.0 minutes for vcpkg including restore, 1.4 minutes for Conan including restore and `conan cache restore`. * A baseline or lock file bump on one run in twenty rebuilds 6 of the 62 ports and 5 of the 48 packages, which adds 3.3 and 2.2 minutes on those runs. * 400 pull request builds per month. Average dependency minutes per run work out to 2.17 for vcpkg (0.95 times 2.0 plus 0.05 times 5.3) and 1.51 for Conan. ### [Minutes and monthly cost](#minutes-and-monthly-cost)Copy Anchor Link | Path | Dependency minutes per run | Monthly minutes | Monthly cost | | ----------------------------- | -------------------------- | --------------- | ------------ | | vcpkg, source build every run | 34.00 | 13,600 | $435.20 | | vcpkg, binary cache | 2.17 | 868 | $27.78 | | Conan, source build every run | 21.00 | 8,400 | $268.80 | | Conan, binary cache | 1.51 | 604 | $19.33 | Cache fees on top of the vcpkg path: 1.2 GB of storage at $0.20 per GB-month is $0.24, and 420 operations at $0.0001 each is $0.04\. The cached vcpkg path lands at $28.06 against $435.20, and it returns 31.8 minutes of wall clock on every pull request, which is the number a reviewer actually feels. ### [The same minutes at GitHub-hosted list prices](#the-same-minutes-at-github-hosted-list-prices)Copy Anchor Link `warp-ubuntu-latest-x64-16x` (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. Holding the cached minute counts fixed, the 868 vcpkg minutes list at $27.78 against $36.46 on that GitHub-hosted shape, and the 604 Conan minutes list at $19.33 against $25.37. Two levers stack, and they are worth separating when you build the case internally. Binary caching removes minutes, and the per minute list price sets what the remaining minutes cost. The first lever is the larger one here and you control it entirely from the workflow file. Sizing the compile phase that follows the install step, along with link time and unity builds, is covered on [C++ builds on GitHub Actions](/solutions/cpp-github-actions). ## [FAQ](#faq)Copy Anchor Link ### [How do I cache vcpkg packages on GitHub Actions?](#how-do-i-cache-vcpkg-packages-on-github-actions)Copy Anchor Link Point `VCPKG_BINARY_SOURCES` at a directory inside the workspace with the `files` provider in `readwrite` mode, restore and save that directory with a cache action, and key it on the `vcpkg.json` manifest, the `vcpkg-configuration.json` baseline, the triplet, and the runner image. Then add `--only-binarycaching` so the job fails instead of quietly compiling a port from source. ### [Why does vcpkg rebuild a port even though the binary cache restored?](#why-does-vcpkg-rebuild-a-port-even-though-the-binary-cache-restored)Copy Anchor Link The ABI hash for that port changed. vcpkg hashes the port version and features, the portfile contents, the triplet, the detected compiler, and the ABI hashes of every dependency, so a baseline bump, a triplet edit, or a runner image that ships a different compiler build produces a new hash and a miss. Run `vcpkg install` with `--debug` and read the ABI info file written under `buildtrees` to see which input moved. ### [Should I cache the whole \~/.conan2 directory?](#should-i-cache-the-whole-conan2-directory)Copy Anchor Link Prefer `conan cache save` and `conan cache restore`. The save command writes only the recipes and package binaries matching the pattern you give it into one archive, while the cache home also holds downloaded sources and tool state that make the entry larger and the key harder to reason about. Pair it with `--build=never` so a missing binary fails the job rather than starting a source build. --- # Visual Studio Build Tools on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/visual-studio-build-tools-github-actions Description: Windows runner images already ship Visual Studio Enterprise. Initialize the developer environment with vswhere and VsDevCmd, and pick images by component ID. # Visual Studio Build Tools on GitHub Actions Windows runner images already ship Visual Studio Enterprise. Initialize the developer environment with vswhere and VsDevCmd, and pick images by component ID. Last verified: 2026-08-13 Visual Studio Build Tools rarely need installing inside a GitHub Actions job, because every Windows runner image already carries a full Visual Studio Enterprise installation with the C++ workloads, MSBuild, the MSVC toolsets, and a Windows SDK on disk ([preinstalled software documentation](/docs/ci/preinstalled-software)). Almost every failure that sends people to the `vs_buildtools` bootstrapper has one of two other causes: the developer command environment was never initialized in the job shell, or one specific component the project needs is missing from the image the `runs-on` label selected. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Windows fleet is x86-64 and splits into three image families, `warp-windows-latest-x64-` on Windows Server 2022, `warp-windows-2025-x64-` on Windows Server 2025, and `warp-windows-2025-vs2026-x64-` on Windows Server 2025 with Visual Studio 2026 ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). This guide separates the three failure shapes, gives the component map for those images, and shows the workflow that initializes the toolset once for the whole job. ## [Diagnosis](#diagnosis)Copy Anchor Link ### [The developer environment was never initialized](#the-developer-environment-was-never-initialized)Copy Anchor Link `microsoft/setup-msbuild` puts `MSBuild.exe` on PATH and stops there. MSBuild reads the toolset out of the `.vcxproj` file, so a solution build succeeds while everything that expects a developer command prompt fails in the same job: * CMake reports `No CMAKE_C_COMPILER could be found` during configure. * A direct `cl` or `nmake` invocation reports that the command is not recognized. * vcpkg fails while probing the compiler, before any port is built. The compiler sits on disk the whole time. What is missing is `INCLUDE`, `LIB`, `LIBPATH`, `WindowsSdkDir`, and a PATH entry for the MSVC binaries, which `VsDevCmd.bat` sets and which no `setup-` action sets for you. ### [The component is absent from the image the label chose](#the-component-is-absent-from-the-image-the-label-chose)Copy Anchor Link All three Windows images list the same 17 `Microsoft.VisualStudio.Workload.*` entries, including `NativeDesktop`, `NativeCrossPlat`, `NativeGame`, `ManagedDesktop`, and `Universal`, so workload-level differences explain nothing. The differences that break builds are at component level, and they are narrow. | Component ID | Windows Server 2022 | Windows Server 2025 | Windows Server 2025 with VS 2026 | | ----------------------------------------------------------------------- | ------------------- | ------------------- | -------------------------------- | | Microsoft.VisualStudio.Component.VC.Tools.x86.x64 | yes | yes | yes | | Microsoft.VisualStudio.Component.VC.Tools.ARM64 | yes | yes | yes | | Microsoft.VisualStudio.Component.VC.Tools.ARM | yes | yes | absent | | Microsoft.VisualStudio.Component.VC.ATL.ARM and VC.MFC.ARM | yes | yes | absent | | Microsoft.VisualStudio.ComponentGroup.VC.Tools.142.x86.x64 | yes | yes | yes | | Microsoft.VisualStudio.Component.VC.14.44.17.14.x86.x64 | absent | absent | yes | | Microsoft.VisualStudio.Component.Windows11SDK.26100 | yes | yes | yes | | Microsoft.VisualStudio.Component.Windows11SDK.22621 | yes | absent | absent | | Microsoft.VisualStudio.Component.Windows10SDK.19041 | yes | absent | absent | | Microsoft.VisualStudio.Component.VC.Llvm.Clang and VC.Llvm.ClangToolset | yes | yes | yes | | Microsoft.VisualStudio.Component.VC.CMake.Project | yes | yes | yes | | Microsoft.VisualStudio.Component.VC.ASAN | yes | yes | yes | | Microsoft.VisualStudio.Component.Azure.Waverton.BuildTools | yes | absent | absent | Rows are transcribed on 2026-08-13 from the upstream image inventories that WarpBuild tracks: [Windows2022-Readme.md](https://github.com/actions/runner-images/blob/main/images/windows/Windows2022-Readme.md), [Windows2025-Readme.md](https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-Readme.md), and [Windows2025-VS2026-Readme.md](https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md). Component lists move with each image release, so treat the readmes as the per-build inventory and re-check them when a green lane turns red on the same label. Three of those rows cause most of the traffic. A project pinned to `WindowsTargetPlatformVersion` 10.0.19041.0 or 10.0.22621.0 configures on Windows Server 2022 and fails on either Windows Server 2025 image. A 32-bit ARM target needs `VC.Tools.ARM`, which the Visual Studio 2026 image does not list. A solution pinned to the 17.14 MSVC toolset builds on the Visual Studio 2026 image because that image adds `VC.14.44.17.14.x86.x64` side by side with its default. ### [A hardcoded installation path](#a-hardcoded-installation-path)Copy Anchor Link Visual Studio 2022 installs under `C:\Program Files\Microsoft Visual Studio\2022\Enterprise` and Visual Studio 2026 installs under `C:\Program Files\Microsoft Visual Studio\18\Enterprise`. Any step carrying a literal `\2022\Enterprise` path to `vcvarsall.bat` or `MSBuild.exe` stops resolving the moment the job moves to a `vs2026` label, and the error text usually says the file was not found rather than naming the real problem. The per-image detail lives on [Visual Studio images on WarpBuild Windows runners](/runners/windows/visual-studio-images). ## [Fix](#fix)Copy Anchor Link **1\. Drop the per-job installer.** The bootstrapper pulls its payload over the network on every run and returns a toolset the image already had. Delete the step first, then find out what actually failed. **2\. Assert the components you depend on, in the first step.** `vswhere.exe` ships at `C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe` on every image. `-requires -property installationPath` prints the installation directory when the component is present and prints nothing when it is absent, so a job can fail in seconds with a precise message rather than failing several minutes later inside MSBuild. **3\. Initialize the developer environment once and export it.** Run `VsDevCmd.bat` in one step and write the resulting variables to `$GITHUB_ENV`, so every later step in the job inherits the compiler environment. Doing it inside each step wastes the setup and leaves the steps disagreeing about which toolset is active. **4\. Resolve paths at run time.** Take the installation directory from `vswhere` instead of writing a year directory into the workflow. The same workflow then runs unchanged on all three image families. **5\. Choose the image by component rather than installing what is missing.** Keeping 32-bit ARM targets and older Windows SDK pins on `warp-windows-latest-x64-` costs nothing and removes the install step entirely. The full label and alias matrix is on [WarpBuild Windows runners for GitHub Actions](/runners/windows), and the readme link for each image is collected on [preinstalled software on WarpBuild runners](/runners/preinstalled-software). **6\. When a component genuinely has to be installed, install it off the pull request path.** Run it in a scheduled or merge-only job and measure it before it becomes permanent. The cost of that decision is worked out below. ## [Configuration](#configuration)Copy Anchor Link This workflow asserts two components, initializes the developer environment for the whole job, and then runs a CMake and Ninja build that needs `cl.exe` on PATH. Copy Text ``` name: windows-native on: pull_request: push: branches: [main] jobs: build: runs-on: warp-windows-latest-x64-8x timeout-minutes: 30 env: REQUIRED_COMPONENTS: >- Microsoft.VisualStudio.Component.VC.Tools.x86.x64 Microsoft.VisualStudio.Component.Windows11SDK.26100 steps: - uses: actions/checkout@v4 with: fetch-depth: 1 - name: Assert the toolset components exist on this image shell: pwsh run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" foreach ($component in $env:REQUIRED_COMPONENTS.Split()) { if (-not $component) { continue } $found = & $vswhere -latest -products * -requires $component -property installationPath if (-not $found) { throw "Component $component is absent from this runner image" } } $install = & $vswhere -latest -products * -property installationPath $version = & $vswhere -latest -products * -property catalog_productDisplayVersion Write-Host "Visual Studio $version at $install" "VSINSTALLDIR=$install" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - name: Initialize the developer environment for the whole job shell: pwsh run: | $script = Join-Path $env:VSINSTALLDIR "Common7\Tools\VsDevCmd.bat" $lines = cmd /c "`"$script`" -arch=amd64 -host_arch=amd64 -no_logo && set" foreach ($line in $lines) { if ($line -match '^(?[A-Za-z_][A-Za-z0-9_()]*)=(?.*)$') { "$($Matches.name)=$($Matches.value)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 } } - name: Configure run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release - name: Build run: cmake --build build --parallel 8 - name: Test run: ctest --test-dir build --output-on-failure --parallel 8 ``` Three details carry the workflow. The regular expression filters the environment dump to names that start with a letter or underscore, which drops the internal `=C:` style entries `set` also prints. `VSINSTALLDIR` comes from `vswhere`, so the same file works on a `vs2026` label where the install lives under `\18\Enterprise`. And `-arch` is explicit: use `-arch=amd64_arm64` to cross-compile to ARM64 from the same image, which every Windows image supports through `VC.Tools.ARM64`. For an inventory rather than an assertion, run `vswhere -latest -products * -format json -utf8` and upload the output as an artifact. That gives every archived run a record of the exact toolset that produced its binaries. Toolchain-level detail for native projects is covered in [building C and C++ projects on GitHub Actions](/solutions/cpp-github-actions), and the answer page [is Visual Studio preinstalled on GitHub Actions Windows runners](/answers/does-visual-studio-come-preinstalled-on-github-actions-runners) lists the edition and install path per image. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The choice between installing a missing component per job and selecting an image that already lists it is arithmetic. Take 600 Windows jobs per month on `warp-windows-latest-x64-8x` at $0.032 per minute, a 14-minute baseline build, and a component install that adds 5 minutes of wall clock. Substitute your own run counts and durations. | Approach | Added minutes per job | Added minutes per month | Added cost per month | | ------------------------------------------ | --------------------- | ----------------------- | -------------------- | | Install the component in every job | 5 | 3,000 | $96.00 | | Install it in a merge-only job, 200 merges | 5 on 200 runs | 1,000 | $32.00 | | Assert components with vswhere first | about 0.1 | 60 | $1.92 | | Select the image that already lists it | 0 | 0 | $0.00 | The runner minutes are the smaller half of that. Five minutes on 600 pull request checks is 3,000 minutes, about 50 hours per month of engineers waiting on a check that a one-line label change removes. The `vswhere` assertion row is worth its $1.92 for a different reason: it converts a late MSBuild error into a failure in the first step, which is the difference between reading a 14-minute log and reading three lines. The baseline itself prices out at 600 jobs times 14 minutes, or 8,400 minutes: $268.80 on `warp-windows-latest-x64-8x`, against $352.80 for the same minutes on the 8-core GitHub-hosted Windows larger runner. warp-windows-latest-x64-8x (8 vCPU, 32 GB) costs $0.032 per minute against $0.042 per minute for the 8-core Windows larger runner (8 vCPU, 32 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13 against the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), with shapes from the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). WarpBuild rates for every size are on the [pricing page](/pricing). When an installer step is unavoidable and its cost is unclear, CI observability collects runner system metrics and correlates them with the GitHub Actions job logs, which separates a network-bound download from a CPU-bound compile without guessing. It sits in the same product surface as snapshot runners, remote Docker builders, an MCP server, and the Action Debugger. ## [FAQ](#faq)Copy Anchor Link ### [Do I need to install Visual Studio Build Tools in a GitHub Actions job?](#do-i-need-to-install-visual-studio-build-tools-in-a-github-actions-job)Copy Anchor Link No. Every Windows runner image carries a full Visual Studio Enterprise install with the C++ workloads, MSBuild, the MSVC toolsets, and a Windows SDK already on disk, so a `vs_buildtools` bootstrapper step downloads a payload the machine already has. Install a component only when `vswhere` proves it is absent from the image your label selected. ### [Why does CMake report that no compiler was found when MSBuild works in the same job?](#why-does-cmake-report-that-no-compiler-was-found-when-msbuild-works-in-the-same-job)Copy Anchor Link `microsoft/setup-msbuild` puts `MSBuild.exe` on PATH and stops there. MSBuild reads the toolset out of the `.vcxproj` file, so an MSBuild build succeeds while CMake, Ninja, nmake, and vcpkg fail because `INCLUDE`, `LIB`, `LIBPATH`, `WindowsSdkDir`, and the path to `cl.exe` are unset. Run `VsDevCmd.bat` once and export its environment to `$GITHUB_ENV` before the configure step. ### [Which Windows label do I use for the 32-bit ARM MSVC toolset or the Windows 10 SDK 19041?](#which-windows-label-do-i-use-for-the-32-bit-arm-msvc-toolset-or-the-windows-10-sdk-19041)Copy Anchor Link Both live on the Windows Server 2022 images behind `warp-windows-latest-x64-4x` through `warp-windows-latest-x64-32x`. `Microsoft.VisualStudio.Component.VC.Tools.ARM` is listed on both Visual Studio 2022 images and is absent from the Visual Studio 2026 image readme, and `Windows10SDK.19041` and `Windows11SDK.22621` are listed on Windows Server 2022 only. --- # A Weekly CI Health Report for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/weekly-ci-health-report-github-actions Description: Assemble billed minutes by runner label, p95 duration, queue wait and cache hit rate into one scheduled GitHub Actions job that writes a job summary. # A Weekly CI Health Report for GitHub Actions Assemble billed minutes by runner label, p95 duration, queue wait and cache hit rate into one scheduled GitHub Actions job that writes a job summary. A weekly GitHub Actions health report is a scheduled workflow that pulls four numbers from the reports API and writes them into its own run page: billed minutes by runner label, duration percentiles per workflow and job, queue wait by runner label, and cache hit rate. It runs on a cron, needs one API key on the `ci` scope, and costs under a cent per run in runner minutes. This guide covers why the four numbers resist hand assembly, which report answers each one, how to compute a p95 the dashboard does not show, and a complete workflow that writes the whole thing into a job summary. It sits under [GitHub Actions observability with WarpBuild](/solutions/github-actions-observability), which is the wider view of what the platform reports across an estate. ## [Diagnosis](#diagnosis)Copy Anchor Link Most teams do build a weekly report. It gets assembled twice, once by whoever proposed it and once by whoever was asked to keep it going, and then it stops. Three things break it. **The four numbers are four different groupings of the same week.** The reports surface splits into Billing, with CI, Docker Builder and Cache tabs, plus Jobs and Queue Timings ([reports documentation](/docs/ci/features/reports)). CI billing returns one row per job execution. Jobs returns one row per unique repository, workflow and job name. Queue Timings returns one row per runner label and stack pair. Cache billing returns one row per cache billing entry. Reading spend and reading latency for the same seven days means reading four aggregations, and reconciling them by eye across four browser tabs is the step that nobody repeats on week three. **The percentile you want is usually not the percentile on the screen.** The Jobs report exposes duration P75 and P90 and queue time P75 and P90\. P90 is the right number for a weekly trend line, and it is the wrong number for an alerting threshold, because the run that makes an engineer give up and go get coffee sits further out. A p95 has to be computed from the per-execution rows, which the CI billing report already returns with an `execution_time` on every row. **The report lands where nobody reads it.** A document in a wiki gets stale silently. A message in a channel scrolls away. The run page of the job that produced the numbers is the one place that keeps them attached to a timestamp, a commit, and a rerun button, which is what a job summary is for ([GitHub workflow commands reference](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions), checked on 2026-08-13). One more constraint shapes the design. Every operation in the WarpBuild API is annotated alpha and every response carries an `X-WarpBuild-API-Stability` header, so pin only to documented behavior and log that header from the weekly job. ## [Fix](#fix)Copy Anchor Link Pull four requests, compute two derived figures locally, and render the result as markdown. | Number in the report | Request | Fields used | What one row is | | --------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------- | | Billed minutes and cost by runner label | GET /reports/billing/ci | runner\_label, billed\_time, runner\_cost, snapshot\_cost, total\_cost | one job execution | | p95 duration per repository and job | GET /reports/billing/ci | repo, job\_name, execution\_time | one job execution, sorted locally | | Duration P75 and P90 per workflow | GET /reports/jobs | workflow\_name, job\_name, run\_count, success\_rate, duration\_p75, duration\_p90 | one repository, workflow and job name | | Queue wait by runner label | GET /reports/queue-timings | runner\_label, stack, stack\_kind, run\_count, queue\_time\_p75, queue\_time\_p90 | one runner label and stack pair | | Cache hit rate | GET /reports/billing/cache | type | one cache billing entry | **Group on billed minutes.** Each CI billing row carries both `execution_time` and `billed_time`. Group by `runner_label` and sum `billed_time`, because billed time is the figure that reconciles against an invoice. Keep `execution_time` for the latency half of the report, where billing rounding has no place. **A p95 comes from sorting rows.** Group the per-execution rows by repository and job name, sort each group by `execution_time`, and read the value at the 95th percentile index. This is the number that answers "how long does a bad run take", and the gap between it and the P90 in the Jobs report is the width of the tail. **Workflow names need a join.** CI billing rows key on repository and job name and carry no workflow name, while the Jobs report carries `workflow_name` with P75 and P90\. When the weekly report has to show a p95 keyed by workflow, each billing row also carries `run_id`, which is the GitHub Actions workflow run id, so a lookup against [the GitHub workflow runs API](https://docs.github.com/en/rest/actions/workflow-runs) returns the workflow name for that run. Cache the mapping; a week of runs resolves to far fewer distinct run ids than job rows. **Queue wait is the number with no invoice line.** Queue time is not billed, so it never appears in a cost review and it is often the largest single block of wall clock time in the week. The Queue Timings report aggregates it per runner label and stack, and its daily chart splits total queue time into GitHub time and WarpBuild time, which separates a plan concurrency ceiling from runner startup. **Cache hit rate from entry types.** The Cache tab filters entries by `storage`, `operation-hit`, and `operation-commit`. Count the rows of the two operation types in the export and divide hits by hits plus commits. Two caveats belong in the report footnote: a `restore-keys` prefix match still restores data and still bills as an operation, so this ratio measures how often a job found something rather than how often the exact key matched, and WarpBuild caching is not supported on Windows runners ([caching documentation](/docs/ci/features/caching)), so Windows jobs contribute no rows either way. ## [Configuration](#configuration)Copy Anchor Link Create the key on the [API keys page](https://app.warpbuild.com/settings/api-keys) in dashboard settings and grant it the `ci` scope. The value starts with `wkey-` and is shown once, so write it into the repository or organization secret in the same step that creates it. Automation patterns for the rest of the API are in the [automation documentation](/docs/ci/api-keys/automation). Every request below sends `Authorization: Bearer wkey-xxxx` and takes `start_date` and `end_date` as RFC3339 timestamps. Copy Text ``` name: weekly-actions-health-report on: schedule: - cron: "23 7 * * 1" workflow_dispatch: permissions: contents: read jobs: report: runs-on: warp-ubuntu-latest-x64-2x timeout-minutes: 10 env: API: https://api.warpbuild.com/api/v1 KEY: ${{ secrets.WARPBUILD_API_KEY }} steps: - name: Compute the window id: window run: | echo "start=$(date -u -d '7 days ago' +%Y-%m-%dT00:00:00Z)" >> "$GITHUB_OUTPUT" echo "end=$(date -u +%Y-%m-%dT00:00:00Z)" >> "$GITHUB_OUTPUT" - name: Pull the four reports env: START: ${{ steps.window.outputs.start }} END: ${{ steps.window.outputs.end }} run: | set -euo pipefail pull() { curl -sS -D headers-"$1".txt -G "$API/$2" \ -H "Authorization: Bearer $KEY" \ --data-urlencode "start_date=$START" \ --data-urlencode "end_date=$END" \ "${@:3}" -o "$1" } pull ci.json reports/billing/ci --data-urlencode 'per_page=200' pull jobs.json reports/jobs --data-urlencode 'per_page=50' \ --data-urlencode 'sort_by=duration_p90' \ --data-urlencode 'sort_order=desc' pull queue.json reports/queue-timings --data-urlencode 'per_page=50' pull cache.csv reports/billing/cache --data-urlencode 'format=csv' grep -i '^x-warpbuild-api-stability' headers-*.txt || true - name: Write the job summary run: | set -euo pipefail { echo "## GitHub Actions health, week ending ${{ steps.window.outputs.end }}" echo echo "### Billed minutes by runner label" echo "| Runner label | Jobs | Billed minutes | Cost |" echo "| --- | --- | --- | --- |" jq -r '.jobs.items | group_by(.runner_label)[] | [ .[0].runner_label, length, ((map(.billed_time) | add) / 60 | round), ((map(.total_cost) | add) * 100 | round / 100) ] | "| \(.[0]) | \(.[1]) | \(.[2]) | $\(.[3]) |"' ci.json echo echo "### Slowest jobs by p95 execution time (minutes)" echo "| Repository | Job | Runs | p95 |" echo "| --- | --- | --- | --- |" jq -r '.jobs.items | group_by(.repo + "|" + .job_name)[] | (map(.execution_time) | sort) as $d | [ .[0].repo, .[0].job_name, ($d | length), (($d[ ((($d | length) - 1) * 0.95) | floor ]) / 60 * 10 | round / 10) ] | select(.[2] >= 20) | "| \(.[0]) | \(.[1]) | \(.[2]) | \(.[3]) |"' ci.json \ | sort -t'|' -k5 -rn | head -10 echo echo "### Duration P90 by workflow" echo "| Repository | Workflow | Job | Runs | Success | P90 |" echo "| --- | --- | --- | --- | --- | --- |" jq -r '.table.items[] | "| \(.repo) | \(.workflow_name) | \(.job_name) | \(.run_count) | \(.success_rate) | \(.duration_p90) |"' \ jobs.json | head -10 echo echo "### Queue wait by runner label" echo "| Runner label | Stack | Jobs | Queue P90 |" echo "| --- | --- | --- | --- |" jq -r '.table.items[] | "| \(.runner_label) | \(.stack_kind) | \(.run_count) | \(.queue_time_p90) |"' queue.json echo awk -F, ' NR == 1 { for (i = 1; i <= NF; i++) col[$i] = i; next } { count[$col["type"]]++ } END { h = count["operation-hit"] + 0 w = count["operation-commit"] + 0 if (h + w > 0) printf "### Cache hit rate: %.1f percent (%d hits, %d writes)\n", 100 * h / (h + w), h, w }' cache.csv } >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@v4 with: name: actions-health-report path: | ci.json jobs.json queue.json cache.csv ``` Five details carry the design. * The cron minute is `23` rather than `0`, because GitHub names the start of every hour as a high load window for the `schedule` event and the run can start late ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), checked on 2026-08-13). * The window boundaries are UTC midnight on both ends, so two consecutive Mondays neither overlap nor leave a gap. * `per_page` is set to the maximum each report allows, 200 on CI billing and 50 on the other two. A busy organization exceeds 200 job rows in a week, so walk `page` until `jobs.next` stops advancing before this goes into production. * Cache billing is pulled as CSV rather than JSON, because the export contains every row matching the filters instead of one page, and counting entry types needs all of them. * Each step writing to `GITHUB_STEP_SUMMARY` is limited to 1MiB, and a step that exceeds it fails its upload with an error annotation while the job conclusion stays unchanged. The `head -10` on the two long tables keeps the panel inside that budget, and the raw files go to an artifact for anyone who wants the full set. The [job summary reference](/glossary/job-summary) covers the rest of the rules. To pull the same window into a spreadsheet or a warehouse instead of a run page, add `format=csv` to any of the four requests; the [CSV export answer](/answers/how-do-i-export-github-actions-job-data-to-csv) covers what each export is keyed on. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link Rates below come from the WarpBuild [pricing page](/pricing), checked on 2026-08-13, and billing is per minute. | Runner label | vCPU | RAM | USD per minute | | --------------------------- | ---- | ----- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | | warp-macos-15-arm64-6x | 6 | 14 GB | $0.08 | | warp-windows-latest-x64-4x | 4 | 16 GB | $0.016 | **Assumptions.** One organization, one week, 4,200 job executions, with snapshot restores on 320 of them, cache operations counted from the export, and billed minutes grouped by runner label. Substitute your own pull; the arithmetic is what matters. | Runner label | Jobs | Billed minutes | Rate | Cost | | --------------------------- | ----- | -------------- | ------ | ------ | | warp-ubuntu-latest-x64-4x | 2,140 | 9,400 | $0.008 | $75.20 | | warp-ubuntu-latest-x64-8x | 620 | 3,150 | $0.016 | $50.40 | | warp-ubuntu-latest-arm64-4x | 980 | 2,600 | $0.006 | $15.60 | | warp-macos-15-arm64-6x | 180 | 540 | $0.08 | $43.20 | | warp-windows-latest-x64-4x | 280 | 800 | $0.016 | $12.80 | Runner cost for the week is $75.20 + $50.40 + $15.60 + $43.20 + $12.80 = $197.20\. The 320 snapshot restores add 320 x $0.04 = $12.80\. The cache export counted 21,400 rows typed `operation-hit` and 3,900 typed `operation-commit`, which is 25,300 operations at $0.0001 each, or $2.53, and a hit rate of 21,400 / 25,300 = 84.6 percent. The weekly total is $212.53\. Cache storage bills separately at $0.20 per GB-month, so 40 GB held across the month is $8.00 and belongs in the monthly view rather than the weekly one. Two derived figures make that table useful to someone outside the platform team. Against 84 pull requests merged in the same week, the estate costs $212.53 / 84 = $2.53 per merged pull request. Held flat, the week annualizes to $212.53 x 52 = $11,051.56. Now price the report itself. One job on `warp-ubuntu-latest-x64-2x` finishing in three minutes is 3 x $0.004 = $0.012 per run, or $0.62 for a year of Mondays. That is the whole cost line for the workflow above. The number the report surfaces that no invoice contains is queue wait. At a queue time P90 of 30 seconds across 4,200 jobs, the week holds 4,200 x 30 = 126,000 seconds, or 35 hours, of wall clock time that costs nothing and delays every one of those jobs. Tracking that figure week over week is what turns the report into a target, which is the subject of [CI SLOs and build time budgets](/guides/ci-slo-and-build-time-budgets). ## [FAQ](#faq)Copy Anchor Link ### [What belongs in a weekly GitHub Actions health report?](#what-belongs-in-a-weekly-github-actions-health-report)Copy Anchor Link Four numbers cover the ground: billed minutes and cost grouped by runner label, duration percentiles per repository, workflow and job, queue wait per runner label and stack, and cache hit rate. Each one comes from a different report because each one aggregates differently, which is why the weekly job pulls four requests rather than one. The mapping from number to endpoint and field is in the table above. ### [Where does a p95 come from when the Jobs report shows P75 and P90?](#where-does-a-p95-come-from-when-the-jobs-report-shows-p75-and-p90)Copy Anchor Link Compute it from the per-execution rows. The Jobs report aggregates to one row per repository, workflow and job name with duration P75 and P90, while the CI billing report returns one row per job execution with an `execution_time` on each row. Sort those rows inside a group and take the value at the 95th percentile index, which is what the `jq` block in the workflow above does. For the same figure keyed by workflow, join on `run_id` against [the GitHub workflow runs API](https://docs.github.com/en/rest/actions/workflow-runs). ### [How is cache hit rate measured from the reports API?](#how-is-cache-hit-rate-measured-from-the-reports-api)Copy Anchor Link The Cache tab of the Billing report types every entry as `storage`, `operation-hit`, or `operation-commit`. Export the window as CSV, count rows of the two operation types, and divide hits by hits plus commits. Two things shape the reading: a `restore-keys` prefix match restores data and bills as an operation, so the ratio counts restores that found something rather than exact key matches, and Windows jobs contribute no rows because WarpBuild caching is not supported on Windows runners. --- # Code Signing Windows Binaries in GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/windows-code-signing-github-actions Description: signtool needs a certificate, a password, and a timestamp authority on a runner that starts with none of them. Here is the tag-only signing job and its cost. # Code Signing Windows Binaries in GitHub Actions signtool needs a certificate, a password, and a timestamp authority on a runner that starts with none of them. Here is the tag-only signing job and its cost. Signing a Windows binary in GitHub Actions means getting three things onto a runner that starts without any of them: a code signing certificate with its private key, the secret that unlocks it, and a reachable timestamp authority. The workflow below does that in a job gated on tag pushes, signs with `signtool` from the Windows SDK already on the image, verifies the signature and its countersignature before anything is uploaded, and leaves the certificate on a machine that is destroyed when the job ends. This guide covers the three stages of the signing flow and the failure each one throws, the workflow YAML for a tag-only signing job, the rule that keeps the certificate out of artifacts and logs, and the arithmetic for what signing adds to a monthly Windows bill. The runner labels and rates come from the [Windows runner catalog](/runners/windows). ## [Diagnosis](#diagnosis)Copy Anchor Link A Windows signing job has three stages, and a failure in any one of them prints a message that names the stage badly. Read the stage first, because the fixes have nothing in common. | Stage | What the job does | Failure it throws | | -------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Certificate delivery | Decodes a base64 secret into a .pfx under $env:RUNNER\_TEMP | The input is not a valid Base-64 string, or a zero-byte file when the secret resolved empty | | Certificate load | signtool sign /f /p , or /n and /sha1 against a certificate store | SignTool Error: The specified PFX password is not correct, SignTool Error: No certificates were found that met all the given criteria | | Signing | Hashes the file and encrypts the digest with the private key | SignerSign() failed (-2147024891/0x80070005) when the key sits in a store the job user cannot read | | Timestamping | /tr /td SHA256 fetches a countersignature | The specified timestamp server either could not be reached or returned an invalid response | | Verification | signtool verify /pa /v | A certificate chain processed, but terminated in a root certificate which is not trusted by the trust provider for an internal CA | **Delivery failures look like signing failures.** GitHub resolves a missing secret to an empty string rather than failing the step, so `FromBase64String` writes a zero-byte file and the first visible error arrives one step later from `signtool`. Three scoping rules produce an empty secret: an environment secret reaches only jobs that declare that `environment:`, an organization secret reaches only repositories on its access list, and a workflow triggered by a `pull_request` from a fork receives no secrets at all. GitHub documents the boundary in [using secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions). **The password crosses the process command line.** `signtool sign /p ` puts the value in the argument list of a process on the machine. On an ephemeral single-tenant runner the exposure window is the job itself, and each WarpBuild runner runs in its own virtual machine that is created on demand and destroyed after the build, as described in the [runner security documentation](/docs/ci/security). Certificate stores and hardware CSPs avoid the argument entirely through `/csp` and `/kc`, which matters more on any machine that outlives one job. **A missing timestamp is a failure that shows up months later.** A signature with no countersignature stops verifying the day the certificate expires, and the binaries affected are the ones users already downloaded. The job that produced them is long green by then, which is why the verification step below asserts on the timestamp rather than on the exit code alone. **Key custody decides which delivery method is available.** Publicly trusted code signing certificates issued since June 1, 2023 must have their private keys generated and stored in a hardware crypto module under the [CA/Browser Forum code signing requirements](https://cabforum.org/working-groups/code-signing/requirements/), checked on 2026-08-13\. A `.pfx` in a repository secret therefore applies to internal CAs and to certificates issued before that date. For a publicly trusted certificate the job calls a network signing service or a hardware token through `signtool` with `/csp` and `/kc`, and the secret it carries is the service credential rather than the key. The rest of the workflow shape is identical. ## [Fix](#fix)Copy Anchor Link Five rules turn a signing job that works on a laptop into one that works on a runner. **Gate signing on the tag.** Build on every push and pull request, sign in a separate job with `if: startsWith(github.ref, 'refs/tags/v')`. The certificate then reaches the small number of machines that produce releases, and fork pull requests never enter a job that expects a secret. **Deliver the certificate outside the workspace.** Decode it into `$env:RUNNER_TEMP`. An upload glob rooted at the checkout directory cannot match a path outside it, so the certificate cannot ride along in an artifact by accident. **Resolve `signtool` explicitly.** The Windows SDK installs it under `C:\Program Files (x86)\Windows Kits\10\bin\\x64\signtool.exe`. Pick the newest version present at runtime instead of pinning a path that changes with the image, and read the option list in the [SignTool reference](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool). **Timestamp with a retry.** Timestamp authorities rate limit and go down. Two retries with a short backoff turn a transient HTTP failure into a slower job rather than a failed release. **Verify before the upload step.** `signtool verify /pa /v` runs the default Authenticode policy against the signed file, and `Get-AuthenticodeSignature` exposes the countersignature so the job can fail when the timestamp is absent. Both finish in seconds, ahead of a publish step that is expensive to undo. Moving the job onto a WarpBuild runner is a label change. WarpBuild runners register with GitHub as self-hosted runners carrying `warp-` labels, so editing `runs-on` is the whole migration. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the Windows images carry the same Windows SDK tooling as the GitHub-hosted equivalents, so the `signtool` invocation stays as it is. When a signing step behaves differently on the runner than on a developer machine, the Action Debugger pauses the workflow and opens a session on the runner, which is the shortest route to inspecting a certificate store in place. The SOC 2 Type 2 report covering Security, Availability, and Confidentiality is available through the [WarpBuild trust center](https://trust.warpbuild.com/). ## [Configuration](#configuration)Copy Anchor Link The workflow builds on every pull request and signs only when a tag is pushed. Copy Text ``` name: windows-release on: pull_request: push: tags: ["v*"] jobs: build: runs-on: warp-windows-2025-x64-8x steps: - uses: actions/checkout@v4 - name: Publish run: dotnet publish src/App/App.csproj -c Release -r win-x64 -o dist - uses: actions/upload-artifact@v4 with: name: unsigned-win-x64 path: dist/** sign: needs: build if: startsWith(github.ref, 'refs/tags/v') runs-on: warp-windows-2025-x64-4x environment: release steps: - uses: actions/download-artifact@v4 with: name: unsigned-win-x64 path: dist - name: Resolve signtool shell: pwsh run: | $tool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe | Where-Object { $_.FullName -like "*\x64\*" } | Sort-Object FullName -Descending | Select-Object -First 1 Add-Content -Path $env:GITHUB_ENV -Value "SIGNTOOL=$($tool.FullName)" - name: Write the certificate to RUNNER_TEMP shell: pwsh env: CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }} CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} run: | $path = Join-Path $env:RUNNER_TEMP "signing.pfx" [IO.File]::WriteAllBytes($path, [Convert]::FromBase64String($env:CERT_PFX_BASE64)) $count = (Get-PfxData -FilePath $path -Password ( ConvertTo-SecureString -String $env:CERT_PASSWORD -AsPlainText -Force )).EndEntityCertificates.Count if ($count -lt 1) { throw "no end entity certificate in the decoded pfx" } Add-Content -Path $env:GITHUB_ENV -Value "CERT_PATH=$path" - name: Sign with a timestamp shell: pwsh env: CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} TIMESTAMP_URL: ${{ vars.TIMESTAMP_URL }} run: | $files = (Get-ChildItem dist -Recurse -Include *.exe,*.dll).FullName foreach ($attempt in 1..3) { & $env:SIGNTOOL sign /fd SHA256 /td SHA256 ` /tr $env:TIMESTAMP_URL ` /f $env:CERT_PATH /p $env:CERT_PASSWORD ` @files if ($LASTEXITCODE -eq 0) { break } Start-Sleep -Seconds (15 * $attempt) } if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Verify the signature and the countersignature shell: pwsh run: | foreach ($file in (Get-ChildItem dist -Recurse -Include *.exe,*.dll)) { & $env:SIGNTOOL verify /pa /v $file.FullName if ($LASTEXITCODE -ne 0) { throw "$($file.Name): signtool verify failed" } $sig = Get-AuthenticodeSignature $file.FullName if ($sig.Status -ne "Valid") { throw "$($file.Name): $($sig.Status)" } if ($null -eq $sig.TimeStamperCertificate) { throw "$($file.Name): no timestamp" } } - uses: actions/upload-artifact@v4 with: name: signed-win-x64 path: dist/** - name: Remove the certificate if: always() shell: pwsh run: Remove-Item $env:CERT_PATH -Force -ErrorAction SilentlyContinue ``` Three details in that file carry the secret handling rule. 1. **The secret expressions appear in `env:` blocks.** A `${{ secrets.X }}` expression written inside the script body is substituted into the script file on disk before the shell runs it, so the value lands in a file the job can read back. Mapping it to an environment variable keeps it in the process environment. 2. **The certificate never enters the workspace.** `dist/**` is the only upload path, and `$env:RUNNER_TEMP` is outside the checkout, so the two cannot intersect. 3. **The sign job declares `environment: release`.** Drop that line and environment secrets resolve to empty strings, which produces the zero-byte `.pfx` from the diagnosis table. Every artifact the signing job touches, where it lives, and how long it survives: | Artifact | Where it lives | How long it survives | | -------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------- | | Base64 .pfx with the private key | Environment secret in GitHub | Until rotation or certificate expiry | | Certificate password | Environment secret in GitHub | Until rotation | | Decoded signing.pfx | $env:RUNNER\_TEMP on the runner's encrypted volume | Deleted by the teardown step, and the volume is destroyed after the build | | Timestamp authority URL | Repository variable | Until edited | | Unsigned build output | Workflow artifact | Repository artifact retention, 90 days by default | | Signed .exe and .dll | Workflow artifact and the release | Retention for the artifact, indefinitely for the release asset | Each runner has its own encrypted storage volume, created on demand and destroyed after each build, and WarpBuild does not access or store build secrets: they stay in your source code repository and reach only the runner environment. Repository-level custody of the two secrets above is covered in [managing GitHub Actions secrets across repositories](/guides/github-actions-secrets-management). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link The signing commands are cheap. Hashing a handful of PE files and fetching one countersignature per file finishes in seconds, and the money sits in the job around them. Windows labels and rates, from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners) and the [pricing page](/pricing), checked on 2026-08-13: | Label | Image | vCPU | Memory | Storage | Rate per minute | | ------------------------------- | ------------------------------------------- | ---- | ------ | --------- | --------------- | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16GB | 256GB SSD | $0.016 | | warp-windows-2025-x64-4x | Windows Server 2025 | 4 | 16GB | 256GB SSD | $0.016 | | warp-windows-2025-x64-8x | Windows Server 2025 | 8 | 32GB | 256GB SSD | $0.032 | | warp-windows-2025-x64-16x | Windows Server 2025 | 16 | 64GB | 256GB SSD | $0.064 | | warp-windows-2025-vs2026-x64-8x | Windows Server 2025 with Visual Studio 2026 | 8 | 32GB | 256GB SSD | $0.032 | Assumptions for the model, all stated so you can substitute your own: one desktop application repository, 8 tagged releases per month, a 14 minute build job on `warp-windows-2025-x64-8x`, a sign job on `warp-windows-2025-x64-4x`, 6 signed files per release, and per-minute billing of job time. | Step | Label | Time per release | Releases | Minutes | Cost | | --------------------------------------- | ------------------------ | ---------------- | -------- | ------- | ----- | | Build and publish | warp-windows-2025-x64-8x | 14:00 | 8 | 112.0 | $3.58 | | Download artifact and resolve signtool | warp-windows-2025-x64-4x | 0:20 | 8 | 2.7 | $0.04 | | Decode and check the certificate | warp-windows-2025-x64-4x | 0:04 | 8 | 0.5 | $0.01 | | Sign 6 files with timestamps | warp-windows-2025-x64-4x | 0:24 | 8 | 3.2 | $0.05 | | Verify signatures and countersignatures | warp-windows-2025-x64-4x | 0:08 | 8 | 1.1 | $0.02 | | Total | 119.5 | $3.70 | | | | Signing accounts for $0.12 of that $3.70\. Now price the two alternatives. Signing on every push instead of on tags, at 300 pushes per month, adds 300 x 0:56 of sign job time, which is 280 minutes and $4.48 at $0.016 per minute. It also puts the certificate on 300 machines rather than 8, and the machine count is the number that matters in a key custody review. Shipping a release without a timestamp costs the re-sign. Artifacts expire at the 90 day default, so the fix is a rebuild and a re-sign of every affected release: 8 releases at 14:00 of build plus 0:56 of signing is 119.5 minutes and $3.70 in runner time, plus a support thread with every user whose installer stopped verifying. The `TimeStamperCertificate` assertion in the verify step is what keeps that arithmetic hypothetical. For the full label list and image details, see the [Windows runner catalog](/runners/windows). For the workflow that wraps this job, see [release build workflows on GitHub Actions](/guides/release-builds-github-actions), and for what a signature proves once it is attached, see [code signing](/glossary/code-signing). ## [FAQ](#faq)Copy Anchor Link ### [Why does signtool report that no certificates were found that met all the given criteria?](#why-does-signtool-report-that-no-certificates-were-found-that-met-all-the-given-criteria)Copy Anchor Link That message comes from certificate selection rather than from signing. With `/f` the file was written but holds no code signing certificate, usually because the secret decoded to zero bytes or the `.pfx` was exported without its private key. With store-based selection through `/n` or `/sha1` the subject name or thumbprint matches nothing in the store the job user can read. Print the certificate count from the decoded file before signing, as the workflow above does, so the job fails on the delivery step instead of the signing step. ### [Does a Windows signature need a timestamp if the certificate is valid today?](#does-a-windows-signature-need-a-timestamp-if-the-certificate-is-valid-today)Copy Anchor Link Yes. Without a countersignature from an RFC 3161 timestamp authority, every binary signed with that certificate stops verifying on the day the certificate expires, including installers users already downloaded. Pass `/tr` with the authority URL and `/td SHA256`, then check that `Get-AuthenticodeSignature` returns a `TimeStamperCertificate` before the artifact is uploaded. ### [How do I keep the certificate out of artifacts and logs?](#how-do-i-keep-the-certificate-out-of-artifacts-and-logs)Copy Anchor Link Write the decoded `.pfx` into `$env:RUNNER_TEMP`, which sits outside the workspace, so no upload glob rooted at the checkout can match it. Pass the password through `env:` rather than inlining the secret expression in the script body, delete the file in an `if: always()` step, and keep the signing job on tag pushes so fork pull requests never reach it. --- # Cutting Xcode Build Times on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/xcode-build-times-github-actions Description: Xcode jobs run long on GitHub Actions because DerivedData and SwiftPM start cold. Measure by phase, restore both caches, then size the macOS runner. # Cutting Xcode Build Times on GitHub Actions Xcode jobs run long on GitHub Actions because DerivedData and SwiftPM start cold. Measure by phase, restore both caches, then size the macOS runner. Last verified: 2026-08-13 Xcode builds run long on GitHub Actions because every job starts on a fresh machine where DerivedData, the SwiftPM checkout directory, and the module cache are empty, so the compiler redoes work it finished on the previous run. The fix is to measure the job by phase, restore the two caches that matter, pass `xcodebuild` flags that stop redundant resolution and indexing, and size the macOS runner against the phase that dominates. ## [Diagnosis](#diagnosis)Copy Anchor Link An Xcode job on a hosted runner is four phases with different bottlenecks. Treating it as one number hides which one you are paying for. **Package resolution and checkout.** `xcodebuild` resolves the Swift Package Manager graph, clones every dependency, and writes them under `SourcePackages` inside DerivedData unless you redirect that path. On a fresh runner this is network work and repository clone work on every single run, even when `Package.resolved` has not moved in three weeks. Private dependencies over SSH add authentication round trips to the same phase. **Module compilation.** This is where most of the minutes go. Swift compiles per module, and a cold DerivedData means every module in the graph, including every package dependency, compiles from source. Type-checking cost is uneven across a codebase: a handful of expressions with heavy overload resolution can hold a module open long after the rest of it finished. **Linking and debug symbols.** The linker is largely serial, and it runs after compilation completes. The expensive part is often `dsymutil` rather than the link step itself, because the default `DEBUG_INFORMATION_FORMAT` of `dwarf-with-dsym` triggers symbol generation on every configuration. A test build has no use for a dSYM bundle. **Archive and export.** `xcodebuild archive` builds Release configuration, which usually means whole-module optimization, then `-exportArchive` signs the app and every embedded framework and writes the IPA. Codesigning walks the bundle file by file, so a large asset catalog or many embedded frameworks stretches this phase independently of compiler speed. ### [The step-level measurement path](#the-step-level-measurement-path)Copy Anchor Link Get a number per phase before you change a flag. 1. Split the job so each phase is its own step. The GitHub Actions log prints a duration next to every step name, which gives you four numbers per run at no cost. 2. Add `-showBuildTimingSummary` to the `xcodebuild` invocation. It prints an aggregate per task type at the end of the build, so you can see compile time separated from link time inside a single step. 3. Pull raw timestamps for a specific run with `gh run view --log`. Step durations round; the raw log does not. 4. Find slow type-checking with `OTHER_SWIFT_FLAGS="-Xfrontend -warn-long-function-bodies=300 -Xfrontend -warn-long-expression-type-checking=300"`. The compiler then warns on each function body and expression above 300 milliseconds, with a file and line. 5. Confirm the change held across runs rather than on one lucky build. The WarpBuild [reports page](/docs/ci/features/reports) aggregates Duration P75 and P90 per repository, workflow, and job name, alongside queue time P75 and P90, with CSV export. CI observability adds CPU and memory P75 and P90 per job, which is the number that tells you whether a job was compute bound or waiting on a serial phase. Here is the shape the measurement usually produces. Cold means no cache restored, warm means both caches restored, on `warp-macos-15-arm64-6x` at 6 vCPU and 14GB. Substitute your own measured numbers; the arithmetic later in this page carries whatever you put here. | Phase | Cold minutes | Warm minutes | What the warm column depends on | | ------------------------------- | ------------ | ------------ | -------------------------------------------------------------- | | Package resolution and checkout | 2.5 | 0.3 | Restored SourcePackages, resolution pinned to Package.resolved | | Module compilation | 12.0 | 5.0 | Restored DerivedData, index store off | | Linking and debug symbols | 2.5 | 1.5 | dwarf instead of dwarf-with-dsym on test builds | | Archive and export | 5.0 | 4.2 | Fewer embedded frameworks to sign | | **Total** | **22.0** | **11.0** | | The archive phase barely moves, which is the point of measuring. Cache work pays off in compilation, and nothing else you do to the cache configuration will speed up codesigning. ## [Fix](#fix)Copy Anchor Link Six changes, in the order that returns the most minutes per hour of effort. **Redirect the two cache paths, then cache them.** Pass `-derivedDataPath` and `-clonedSourcePackagesDirPath` explicitly so both live inside the workspace where `actions/cache` can reach them. Key the SwiftPM cache on `Package.resolved` alone, because it changes rarely. Key DerivedData on the toolchain plus the commit SHA with a prefix `restore-keys`, so a pull request job that misses on its own SHA still restores the last main build and compiles incrementally from there. **Save DerivedData from main only.** Use `actions/cache/restore` and `actions/cache/save` as separate steps, with the save step guarded on `github.ref == 'refs/heads/main'`. Every pull request writing its own DerivedData entry fills the repository cache allowance and evicts the entry everything else restores from. Check the current per-repository allowance and eviction behavior in GitHub's documentation, and see [GitHub Actions cache size limits](/guides/github-actions-cache-size-limit) for the workarounds when a DerivedData tree does not fit. **Put the toolchain in every cache key.** Xcode invalidates module output across versions, and a restored DerivedData from a different Xcode is worse than no cache at all, because the compiler discovers the mismatch after paying to read it. `xcodebuild -version` in a key generation step costs a second and prevents the whole class of problem. **Stop the redundant work with flags.** `-onlyUsePackageVersionsFromResolvedFile` and `-disableAutomaticPackageResolution` keep the build from re-resolving a graph you already pinned. `-skipPackagePluginValidation` and `-skipMacroValidation` remove interactive trust prompts that turn into wasted time on a headless machine. `COMPILER_INDEX_STORE_ENABLE=NO` stops index-while-building, which exists for the editor and has no consumer in a workflow. `DEBUG_INFORMATION_FORMAT=dwarf` and `CODE_SIGNING_ALLOWED=NO` on test builds skip dSYM generation and signing. `ONLY_ACTIVE_ARCH=YES` builds one slice for the simulator destination. **Split build from test.** `xcodebuild build-for-testing` produces the test bundle, and `test-without-building` runs it. Splitting them lets the build step cache cleanly and lets a simulator matrix reuse one compiled bundle across destinations rather than compiling once per leg. The simulator side of that split is covered in [running iOS simulator tests on GitHub Actions](/guides/ios-simulator-tests-github-actions). **Change the runner label.** WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners that register with GitHub as self-hosted runners carrying `warp-` labels, so the migration for a job is the value of `runs-on`. The macOS catalog offers multiple sizes and configurations per chip, so the size decision is a label choice rather than a support ticket. The list price arithmetic is direct: `warp-macos-latest-arm64-6x` at 6 vCPU and 14GB costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14GB, which is 22 percent lower list price, GitHub list price checked on 2026-08-13. Two supporting facts matter during a migration. Run as many jobs as your workflows need, since generally available Linux and Windows runners do not have plan-level concurrency caps, which is what keeps a simulator matrix from queueing behind itself once the build step is fast. On Xcode versions, the macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes (build 27A266a, with iOS, tvOS, watchOS, and visionOS 27.0 runtimes) while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. A maintenance branch that needs an older toolchain stays on `warp-macos-14-arm64-6x` at the same $0.08 per minute. ## [Configuration](#configuration)Copy Anchor Link The build job on the 6 vCPU macOS runner, with both caches and the flags from above. Copy Text ``` name: ios-build on: pull_request: branches: [main] push: branches: [main] concurrency: group: ios-build-${{ github.ref }} cancel-in-progress: true env: DERIVED_DATA: ${{ github.workspace }}/.derived-data SPM_CHECKOUTS: ${{ github.workspace }}/.spm-checkouts jobs: build: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v4 - name: Record the toolchain in the cache key id: toolchain run: | echo "id=$(xcodebuild -version | tr '\n' '-' | tr -d ' ')" >> "$GITHUB_OUTPUT" - name: Restore SwiftPM checkouts uses: actions/cache@v4 with: path: ${{ env.SPM_CHECKOUTS }} key: spm-${{ steps.toolchain.outputs.id }}-${{ hashFiles('**/Package.resolved') }} - name: Restore DerivedData uses: actions/cache/restore@v4 with: path: ${{ env.DERIVED_DATA }} key: dd-${{ steps.toolchain.outputs.id }}-${{ github.sha }} restore-keys: | dd-${{ steps.toolchain.outputs.id }}- - name: Resolve packages from the lock file only run: | xcodebuild -resolvePackageDependencies \ -scheme App \ -clonedSourcePackagesDirPath "$SPM_CHECKOUTS" \ -onlyUsePackageVersionsFromResolvedFile - name: Build for testing run: | xcodebuild build-for-testing \ -scheme App \ -destination "platform=iOS Simulator,name=iPhone 16" \ -derivedDataPath "$DERIVED_DATA" \ -clonedSourcePackagesDirPath "$SPM_CHECKOUTS" \ -disableAutomaticPackageResolution \ -skipPackagePluginValidation \ -skipMacroValidation \ -showBuildTimingSummary \ -quiet \ COMPILER_INDEX_STORE_ENABLE=NO \ DEBUG_INFORMATION_FORMAT=dwarf \ ONLY_ACTIVE_ARCH=YES \ CODE_SIGNING_ALLOWED=NO - name: Save DerivedData from main only if: github.ref == 'refs/heads/main' uses: actions/cache/save@v4 with: path: ${{ env.DERIVED_DATA }} key: dd-${{ steps.toolchain.outputs.id }}-${{ github.sha }} ``` The archive job is the one place the wider machine can earn its rate, since Release configuration compiles with whole-module optimization across the full target graph. Copy Text ``` archive: if: github.ref == 'refs/heads/main' needs: build runs-on: warp-macos-26-arm64-12x steps: - uses: actions/checkout@v4 - name: Select Xcode 27.0 run: sudo xcode-select -s "$(ls -d /Applications/Xcode_27*.app | tail -1)" - name: Restore SwiftPM checkouts uses: actions/cache/restore@v4 with: path: ${{ github.workspace }}/.spm-checkouts key: spm-archive-${{ hashFiles('**/Package.resolved') }} restore-keys: spm-archive- - name: Archive run: | xcodebuild archive \ -scheme App \ -configuration Release \ -destination "generic/platform=iOS" \ -archivePath build/App.xcarchive \ -clonedSourcePackagesDirPath "${{ github.workspace }}/.spm-checkouts" \ -disableAutomaticPackageResolution \ -showBuildTimingSummary \ SWIFT_COMPILATION_MODE=wholemodule \ COMPILER_INDEX_STORE_ENABLE=NO ``` Four configuration facts decide whether this lands cleanly: 1. **The WarpBuild cache is a Linux runner feature.** On macOS, use `actions/cache` for DerivedData and the SwiftPM directory, exactly as written above. 2. **Runner storage is ephemeral.** Every job starts on a fresh VM, so anything the next job needs travels through an artifact or a cache. 3. **macOS runners cannot run Docker and do not support nested virtualization.** Container builds and Android emulator work belong on Linux x64 runners. 4. **Aliases track the latest image.** `warp-macos-latest-arm64-6x` resolves to `warp-macos-15-arm64-6x`. Pin the explicit version label on a release branch so the pointer moving does not change your toolchain mid-release. Label shapes, sizes, and rates live in the [cloud runners documentation](/docs/ci/cloud-runners). ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link ### [The size decision](#the-size-decision)Copy Anchor Link | Label | vCPU | Memory | Storage | Rate per minute | | ----------------------- | ---- | ------ | --------- | --------------- | | warp-macos-15-arm64-6x | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | 12 | 28GB | 270GB SSD | $0.16 | The 12 vCPU size costs exactly twice the 6 vCPU size per minute, so it breaks even on cost only when it cuts the job to under half its current wall clock. Apply that to the warm 11-minute job from the phase table: the same job on the 12 vCPU size has to finish in 5.5 minutes to cost the same $0.88. Module compilation is the phase with real parallelism, and it scales with how wide the target graph is rather than how many cores exist. Package resolution, linking, and export run largely serial. A job whose warm profile is 5.0 minutes of compilation against 6.0 minutes of everything else cannot reach 5.5 minutes on any number of cores, so it stays on 6 vCPU. The 270GB SSD is the other reason to move up: DerivedData, several simulator runtimes, and an archive on one disk outgrow 120GB before the CPU becomes the constraint. Read CPU P75 and P90 per job on the reports page before you decide. A job sitting at 40 percent peak CPU on 6 vCPU will not use 12. ### [Worked monthly model](#worked-monthly-model)Copy Anchor Link Assumptions, all stated so you can substitute your own: * One iOS repository, 900 Xcode jobs per month, which is 45 per working day across 20 working days. * Every job runs the four phases from the table above. Teams that skip archive on pull requests should model two job shapes and add the totals. * Cold job: 22.0 minutes. Warm job with both caches restored: 11.0 minutes. * Billing is per minute of job time on both platforms. * Minutes are held identical across both platforms so the runner label is the only variable, which makes the comparison conservative. * Cache storage, artifact storage, and reruns are excluded from both columns. Minute totals: 900 x 22.0 = 19,800 cold macOS minutes, or 900 x 11.0 = 9,900 warm macOS minutes. | Configuration | macOS minutes | Rate per minute | Monthly cost | | ---------------------------------------------- | ------------- | --------------- | ------------ | | GitHub-hosted macos-latest-xlarge, cold caches | 19,800 | $0.102 | $2,019.60 | | GitHub-hosted macos-latest-xlarge, warm caches | 9,900 | $0.102 | $1,009.80 | | warp-macos-15-arm64-6x, cold caches | 19,800 | $0.08 | $1,584.00 | | warp-macos-15-arm64-6x, warm caches | 9,900 | $0.08 | $792.00 | The arithmetic behind each row: * 19,800 x $0.102 = $2,019.60 and 9,900 x $0.102 = $1,009.80. * 19,800 x $0.08 = $1,584.00 and 9,900 x $0.08 = $792.00. * Cache work alone at the WarpBuild rate returns $792.00 per month. * The label change alone returns $435.60 per month at cold volume and $217.80 at warm volume. * Both together separate $2,019.60 from $792.00, a $1,227.60 monthly difference at the same job count. The wall clock number matters as much as the invoice. Eleven minutes returned on 900 jobs is 9,900 macOS minutes per month, or 165 hours of engineers waiting on a pull request that they now get back. GitHub rates read from [GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. `macos-latest-xlarge` is the matched shape because it is GitHub's ARM64 macOS runner; the 12-core `macos-latest-large` runner is Intel hardware and the 3-core `macos-latest` runner is a smaller machine. WarpBuild rates come from the [pricing page](/pricing), verified the same day. For the full macOS price arithmetic across every shape, including the non-Xcode job split, see [what macOS runners cost on GitHub Actions](/guides/github-actions-macos-runner-cost). ### [Where the model breaks](#where-the-model-breaks)Copy Anchor Link **Cache hit rate.** The warm column assumes DerivedData restores. A repository where every pull request touches a low-level module gets partial incremental value at best, and the honest number sits between the two columns. Track it: log whether the restore step reported a hit, and compare Duration P75 across the runs that hit and the runs that missed. **Dependency churn.** A team bumping packages weekly invalidates the SwiftPM cache weekly, which puts 2.2 minutes back into every job for the rest of that day. **Archive frequency.** Moving archive to main only removes 5.0 minutes per pull request job from the model and changes every total in the table. Rerun the arithmetic with your own job mix rather than trusting these rows. Start with the runner catalog on [WarpBuild macOS runners for GitHub Actions](/runners/macos), then take the pipeline shape from [iOS pipelines on GitHub Actions](/solutions/ios-github-actions). ## [FAQ](#faq)Copy Anchor Link **Why is my Xcode build slow on GitHub Actions?** Every job starts on a fresh machine, so DerivedData, the SwiftPM checkout directory, and the module cache are empty and the compiler rebuilds every module from source. Package resolution also hits the network on each run unless the resolved file is treated as authoritative. Measure the four phases separately before you change anything, because the phase that dominates decides which fix is worth the effort. **Which caches actually cut Xcode build time?** Two. The SwiftPM clone directory set by `-clonedSourcePackagesDirPath`, keyed on `Package.resolved`, which removes repeated clones. And DerivedData set by `-derivedDataPath`, keyed on the toolchain plus the commit with a prefix `restore-key`, which lets the compiler reuse module output from the last main build. Save DerivedData from main only so pull request jobs restore instead of writing. **Should I use the 6 vCPU or the 12 vCPU macOS runner?** `warp-macos-15-arm64-6x` is $0.08 per minute and `warp-macos-26-arm64-12x` is $0.16 per minute, so the wider machine has to cut the job below half its current wall clock to cost the same. Module compilation parallelizes across a wide target graph. Package resolution, linking, and export are largely serial, so a job dominated by those phases stays on 6 vCPU. **How do I measure where the Xcode build time goes?** Pass `-showBuildTimingSummary` to `xcodebuild` for per-task aggregates, split the job into one step per phase so the GitHub Actions log carries a duration for each, and pull the raw log with `gh run view --log` for timestamps. Then read Duration P75 and P90 per job on the WarpBuild [reports page](/docs/ci/features/reports) to see whether a change held across runs. --- # Caching Xcode DerivedData in GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/xcode-derived-data-cache-github-actions Description: Which DerivedData subdirectories restore safely on GitHub Actions, how to key and prune the archive so it fits the cache allowance, and the minutes it returns. # Caching Xcode DerivedData in GitHub Actions Which DerivedData subdirectories restore safely on GitHub Actions, how to key and prune the archive so it fits the cache allowance, and the minutes it returns. Last verified: 2026-08-13 Caching Xcode DerivedData on GitHub Actions pays off when you restore the four subdirectories that survive a fresh machine and delete the two that only add weight, then key the archive on the toolchain so a mismatched restore cannot poison the build. This page maps DerivedData directory by directory, gives the workflow that prunes, keys, and restores it, and models the minutes a warm restore returns at the macOS per-minute rate. ## [Diagnosis](#diagnosis)Copy Anchor Link DerivedData is the single tree Xcode writes everything into: compiled object files, module output, the build database, the package checkouts, the index store, and the logs. Every GitHub Actions job starts on a fresh machine with an empty tree, so the compiler redoes the work it finished on the previous run. Restoring the whole tree without thinking about it produces one of two bad outcomes: an archive too large for the cache allowance, or a build that fails in a way that looks unrelated to caching. ### [What lives where](#what-lives-where)Copy Anchor Link | Subdirectory | Contents | Restore | Failure mode if you get it wrong | | --------------------------- | --------------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------- | | Build/Products | Built .app, .xctest, and dSYM bundles per configuration | Yes | A tree saved from Release restored into a Debug build links stale products | | Build/Intermediates.noindex | Object files, .swiftmodule output, and the XCBuildData build database | Yes | This is the incremental payload; a toolchain mismatch here is what most stale builds actually are | | ModuleCache.noindex | Precompiled clang modules | Yes, with the toolchain in the key | Surfaces as a module compiled by a different compiler version, mid-build | | SourcePackages | SwiftPM checkouts, binary artifacts, workspace-state.json | Yes, keyed on Package.resolved | A partial restore leaves workspace-state.json pointing at checkouts that are not on disk | | Index.noindex | Index store for editor navigation | No | Often the largest directory in the tree, with no consumer in a workflow | | Logs | .xcactivitylog build logs | No | Grows every run and inflates the archive for nothing | Deleting `Index.noindex` and `Logs` before the save step is the single change that decides whether the archive fits. GitHub documents a per-repository cache allowance and least-recently-used eviction once that allowance is passed, so an unpruned DerivedData tree from a mid-size app can evict every other entry the repository depends on. See [GitHub's caching reference](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows), checked on 2026-08-13. ### [The timestamp problem](#the-timestamp-problem)Copy Anchor Link A cache that restores cleanly can still deliver a full rebuild. The Xcode build system records the size and modification time of every input file in its build database, and `actions/checkout` writes the current time onto every file it materializes. Restored object files therefore look older than the sources that produced them, and the compiler rebuilds them. The half of the graph that reuses reliably is the package dependency half, because those sources arrive from the restored `SourcePackages` tree with the timestamps the archive carried rather than from the checkout. For most app repositories that is where the module count is highest, which is why a restore that appears to do nothing for first-party code still cuts several minutes. Two more mismatches produce stale-build failures rather than slow builds. DerivedData records absolute paths, so the workspace path has to be identical between save and restore. And Xcode invalidates module output across versions, so a DerivedData tree from a different Xcode costs the compiler the time to read it before it discards it. ## [Fix](#fix)Copy Anchor Link **Prune, then save.** Remove `Index.noindex` and `Logs` in the step before the save. Add `COMPILER_INDEX_STORE_ENABLE=NO` to the build so the index store never gets written in the first place. **Put the toolchain in the key.** Generate a key component from `xcodebuild -version` in its own step. It costs a second and removes the entire class of module-version failures. **Split restore from save and save from main only.** Use `actions/cache/restore` and `actions/cache/save` as separate steps, with the save guarded on the default branch. Pull request jobs then read the last main build and never write, which keeps the allowance from filling with one entry per open branch. **Keep the workspace path stable.** Set `-derivedDataPath` to a fixed location under `github.workspace` and use the same expression in the cache `path`. Do not check out into a per-run directory. **Restore modification times for unchanged sources.** After checkout, set each tracked file back to its last commit time so the build database sees the sources it recorded. Restrict it to files that did not change since the cached commit, because it runs one `git log` per file. Copy Text ``` git ls-files -z | while IFS= read -r -d '' f; do ts=$(git log -1 --format=%cd --date=format:%Y%m%d%H%M.%S -- "$f") [ -n "$ts" ] && touch -t "$ts" "$f" done ``` **Measure the hit in minutes rather than as a boolean.** `cache-hit` being true says only that the archive downloaded. Compare build-step duration across runs that hit and runs that missed. CI observability reports CPU and memory P75 and P90 per job alongside duration, which tells you whether a warm job became compute bound or spent its time on the restore. **Then pick the machine.** WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so the change for an Xcode job is the value of `runs-on`. On WarpBuild runners `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4`; cache entries carry a version derived from the compression tool and the cached paths, so a macOS DerivedData entry restores on macOS runners only. The details are in the [caching documentation](/docs/ci/features/caching). ## [Configuration](#configuration)Copy Anchor Link Copy Text ``` name: ios-build on: pull_request: branches: [main] push: branches: [main] env: DERIVED_DATA: ${{ github.workspace }}/.derived-data SPM_CHECKOUTS: ${{ github.workspace }}/.spm-checkouts jobs: build: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v4 - name: Record the toolchain in the cache key id: toolchain run: echo "id=$(xcodebuild -version | tr '\n' '-' | tr -d ' ')" >> "$GITHUB_OUTPUT" - name: Restore source timestamps run: | git ls-files -z | while IFS= read -r -d '' f; do ts=$(git log -1 --format=%cd --date=format:%Y%m%d%H%M.%S -- "$f") [ -n "$ts" ] && touch -t "$ts" "$f" done - name: Restore DerivedData id: dd uses: actions/cache/restore@v4 with: path: | ${{ env.DERIVED_DATA }} ${{ env.SPM_CHECKOUTS }} key: dd-${{ steps.toolchain.outputs.id }}-${{ hashFiles('**/Package.resolved') }}-${{ github.sha }} restore-keys: | dd-${{ steps.toolchain.outputs.id }}-${{ hashFiles('**/Package.resolved') }}- dd-${{ steps.toolchain.outputs.id }}- - name: Build for testing run: | xcodebuild build-for-testing \ -scheme App \ -destination "platform=iOS Simulator,name=iPhone 16" \ -derivedDataPath "$DERIVED_DATA" \ -clonedSourcePackagesDirPath "$SPM_CHECKOUTS" \ -onlyUsePackageVersionsFromResolvedFile \ -disableAutomaticPackageResolution \ -showBuildTimingSummary \ -quiet \ COMPILER_INDEX_STORE_ENABLE=NO \ DEBUG_INFORMATION_FORMAT=dwarf \ ONLY_ACTIVE_ARCH=YES \ SWIFT_COMPILATION_MODE=incremental \ CODE_SIGNING_ALLOWED=NO - name: Prune the tree before saving if: github.ref == 'refs/heads/main' run: rm -rf "$DERIVED_DATA/Index.noindex" "$DERIVED_DATA/Logs" - name: Save DerivedData if: github.ref == 'refs/heads/main' uses: actions/cache/save@v4 with: path: | ${{ env.DERIVED_DATA }} ${{ env.SPM_CHECKOUTS }} key: dd-${{ steps.toolchain.outputs.id }}-${{ hashFiles('**/Package.resolved') }}-${{ github.sha }} ``` Three settings in that file carry the incremental behavior. `SWIFT_COMPILATION_MODE=incremental` keeps the compiler working per file so restored object files can be reused, since whole-module optimization recompiles the module whenever any file in it moves. `ONLY_ACTIVE_ARCH=YES` builds one slice, which halves the object output that has to travel through the cache. `DEBUG_INFORMATION_FORMAT=dwarf` skips dSYM generation, which a test build has no use for. The restore-keys ladder matters as much as the key. A pull request misses its own SHA, falls back to the same toolchain and the same resolved package graph, and only then falls back to the toolchain alone. That last rung is the one that hands you a tree from before a dependency bump, which restores but compiles more than you expect. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link macOS sizes and rates, from the [cloud runners documentation](/docs/ci/cloud-runners): | Label | vCPU | Memory | Storage | Rate per minute | | ----------------------- | ---- | ------ | --------- | --------------- | | warp-macos-26-arm64-6x | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-15-arm64-6x | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-15-arm64-12x | 12 | 28GB | 270GB SSD | $0.16 | Assumptions, stated so you can substitute your own measurements: * One iOS repository, 600 Xcode jobs per month, on a 6 vCPU macOS runner at $0.08 per minute. * Clean job: 14.0 minutes of compile and link with an empty DerivedData. * Warm job: 1.2 minutes to download and unpack a pruned 4.5GB archive, then 6.5 minutes of compile and link, so 7.7 minutes total. * Pull request jobs restore only. The save cost lands on main builds and is excluded from both columns. | Configuration | Minutes per month | Rate per minute | Monthly cost | | ------------------------ | ----------------- | --------------- | ------------ | | Clean build every run | 8,400 | $0.08 | $672.00 | | Warm DerivedData restore | 4,620 | $0.08 | $369.60 | The arithmetic: 600 x 14.0 = 8,400 minutes at $0.08 is $672.00, and 600 x 7.7 = 4,620 minutes at $0.08 is $369.60, a $302.40 monthly difference and 3,780 minutes of engineers no longer waiting on a pull request. The break-even is one line: the cache pays when the compilation it removes exceeds the time the restore costs. In the model above the restore costs 1.2 minutes and removes 7.5 minutes, so the margin is wide. Halve the compilation saving and the margin narrows to 5.3 minutes; double the archive size on a repository that never pruned `Index.noindex` and the restore alone can eat the whole gain. Log the restore duration as its own step so this number stays visible. The runner rate is the other half of the same invoice. `warp-macos-latest-arm64-6x` at 6 vCPU and 14GB costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner at 5 vCPU and 14GB, which is 22 percent lower list price, GitHub list price checked on 2026-08-13 from [GitHub Actions per-minute rates](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The same 4,620 warm minutes are $471.24 at the GitHub-hosted rate against $369.60 here. Rates per size are on the [pricing page](/pricing). Where the model breaks: a pull request that touches a low-level module invalidates everything downstream of it, and the warm column moves toward the clean one. Track the split by comparing build-step duration on runs that restored against runs that missed, rather than assuming the average. The wider [macOS runner catalog](/runners/macos) covers the size decision, [cutting Xcode build times on GitHub Actions](/guides/xcode-build-times-github-actions) covers the phases outside compilation, and [iOS pipelines on GitHub Actions](/solutions/ios-github-actions) covers the full workflow shape. ## [FAQ](#faq)Copy Anchor Link **Which DerivedData subdirectories should I cache?** `Build/Products`, `Build/Intermediates.noindex`, `ModuleCache.noindex`, and `SourcePackages`. Delete `Index.noindex` and `Logs` before the save step, because the index store exists for the Xcode editor and has no consumer in a workflow, and the logs grow on every run. Pruning those two usually removes the largest part of the archive. **Why did my build still recompile everything after the cache restored?** The Xcode build system records the size and modification time of every input in its build database, and `actions/checkout` writes the current time onto every tracked file. Restored object files then look older than the sources they came from, so the compiler rebuilds them. Restore modification times from git commit dates for files that have not changed since the cached commit, or expect the reuse to come mostly from the package dependency half of the graph. **What makes a restored DerivedData produce a stale-build failure?** Three mismatches. A different Xcode version, which invalidates the precompiled module cache and surfaces as a module built by a different compiler version. A different absolute workspace path, since DerivedData records absolute paths. And a partially restored `SourcePackages` tree, where `workspace-state.json` points at checkouts that are missing. Putting the toolchain identifier and the `Package.resolved` hash in the cache key removes the first two classes. The shorter version of this answer is at [can I keep DerivedData between GitHub Actions runs](/answers/can-i-keep-derived-data-between-github-actions-runs). --- # Pinning an Xcode Version in GitHub Actions | WarpBuild URL: https://www.warpbuild.com/guides/xcode-version-pinning-github-actions Description: Pin the macOS runner label, set DEVELOPER_DIR, and assert the resolved toolchain in the job log so a moving alias or an image update cannot change Xcode. # Pinning an Xcode Version in GitHub Actions Pin the macOS runner label, set DEVELOPER\_DIR, and assert the resolved toolchain in the job log so a moving alias or an image update cannot change Xcode. Pinning an Xcode version in GitHub Actions takes two decisions inside the same job: the `runs-on` label decides which Xcode bundles exist on the machine, and `DEVELOPER_DIR` decides which of those bundles the build uses. Pin both, assert the resolved version in the job log before any compile starts, and move a new toolchain onto one job before the rest of the pipeline follows. ## [Diagnosis](#diagnosis)Copy Anchor Link A job with no pin still resolves to a specific Xcode. It resolves to whatever the image default happens to be on the day it runs, which is why an Xcode pipeline goes red on a morning when nobody touched the workflow file. **The label is an alias.** `warp-macos-latest-arm64-6x` and `warp-macos-latest-arm64-12x` resolve to the macOS 15 labels, in sync with GitHub's `macos-latest` tag ([cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13). The day an alias moves to another image, every job naming it gets a different Xcode set with no diff in the repository to explain it. **The image updates under a stable label.** WarpBuild macOS images are built from the matching upstream GitHub images and carry the same tooling ([preinstalled software documentation](/docs/ci/preinstalled-software)), and GitHub regenerates those images on a release cadence. What moves is the bundle inventory: an exact bundle name you hardcoded can be absent from the next build of the same image. The published inventories are the [macOS 14 ARM64](https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md), [macOS 15 ARM64](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-arm64-Readme.md), and [macOS 26 ARM64](https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md) readmes. **Half the pipeline is pinned.** The build job names a bundle and the test or archive job inherits the image default. Module output then gets compiled by one toolchain and read by another, and the error text talks about a module built by a different compiler version rather than about pinning. The same mismatch poisons a DerivedData cache whose key omits the toolchain identifier. One drift is cheap to rule out because it fails loudly: retired labels. macOS 13 runners were removed on June 8, 2026, so a workflow still naming one queues without a match ([cloud runners documentation](/docs/ci/cloud-runners)). ## [Fix](#fix)Copy Anchor Link Three mechanisms can carry a pin. They differ in what they fix and in how the failure presents. | Rank | Mechanism | What it pins | How it fails | When you notice | | ---- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | 1 | Versioned label in runs-on, such as warp-macos-26-arm64-6x | The set of Xcode bundles present on the machine | The label is retired and the job finds no matching runner | Immediately, as a queued job that never starts | | 2 | DEVELOPER\_DIR set at workflow level | Which installed bundle every xcodebuild, swift, and xcrun call uses | The bundle path is absent from the image, so the developer directory is invalid and every tool inherits the bad value | On the first tool call, with an error that names the path | | 3 | Runtime selection: sudo xcode-select -s, or an action resolving a version spec such as 16.x | The active developer directory for the rest of the job | The spec matches a different patch release after an image update and the build silently changes toolchain | Only if something printed the resolved version | Rank one and rank two are the pin. Rank three is a convenience for a matrix leg that genuinely wants a range, and it belongs behind an assertion whenever it is used. **Pin once and read the value everywhere.** Put `DEVELOPER_DIR` in workflow level `env` rather than repeating a path in three jobs. Build, test, and archive then agree by construction, and a toolchain move is a one line diff that reviewers can see. **Assert the resolved version instead of trusting the path.** A missing bundle and a bundle that quietly moved produce the same symptom later in the run. A guard step that tests the directory, prints `xcodebuild -version`, and compares that output against an expected string turns both into a ten second failure with the installed bundles listed in the log. **Keep the toolchain in every cache key.** Generate a key fragment from `xcodebuild -version` and put it in the DerivedData and SwiftPM cache keys. A tree saved under one Xcode and restored under another costs the compiler the time to read it before it discards it. **Treat the runner label as the real switch.** WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners that register with GitHub as self-hosted runners carrying `warp-` labels, so moving a job to a different macOS image is the value of `runs-on`. The macOS 26 labels ship Xcode 27.0 (build 27A266a) with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes on top of the Xcode versions in the upstream GitHub macOS 26 image, while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released ([macOS 26 tooling](/docs/ci/cloud-runners#macos-26-tooling)). A workflow pinning the Xcode 27.0 path takes one edit at that switch. ## [Configuration](#configuration)Copy Anchor Link The pin lives in two places: the label on each job and one `DEVELOPER_DIR` value at the top of the file. Copy Text ``` name: ios on: pull_request: branches: [main] push: branches: [main] env: DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer EXPECTED_XCODE: "Xcode 27.0" jobs: build-and-test: runs-on: warp-macos-26-arm64-6x steps: - name: Assert the pinned toolchain run: | if [ ! -d "$DEVELOPER_DIR" ]; then echo "pinned developer dir missing: $DEVELOPER_DIR" ls -1 /Applications | grep -i '^Xcode' exit 1 fi resolved=$(xcodebuild -version | head -1) echo "resolved: $resolved" [ "$resolved" = "$EXPECTED_XCODE" ] || { echo "expected $EXPECTED_XCODE" exit 1 } xcrun simctl list runtimes | grep -i ios - uses: actions/checkout@v4 - name: Build and test run: | xcodebuild test \ -scheme App \ -destination "platform=iOS Simulator,name=iPhone 17,OS=27.0" \ -resultBundlePath TestResults.xcresult ``` Three details decide whether that file holds up over a year. 1. The guard runs before `actions/checkout`, so a broken pin costs seconds of macOS time rather than a checkout plus a dependency resolution. 2. `EXPECTED_XCODE` is compared against the first line of `xcodebuild -version`, which is the only string that reflects what the compiler will be. A path check alone passes on a symlink that was repointed. 3. Every job in the file inherits the workflow level `DEVELOPER_DIR`. A job that deliberately wants a different toolchain overrides it in its own `env` block, and that override is visible in review. Point the label at the versioned image rather than an alias in any workflow where the toolchain matters, and read the per-image Xcode set from the [Xcode image reference](/runners/macos/xcode-images) before you write the bundle path. ### [The upgrade path](#the-upgrade-path)Copy Anchor Link Moving a whole pipeline to a new Xcode in one commit means every job fails together if the assumption is wrong. Run a canary instead. Copy Text ``` xcode-canary: runs-on: warp-macos-26-arm64-6x if: github.event_name == 'schedule' continue-on-error: true env: DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer steps: - uses: actions/checkout@v4 - name: Build against the candidate toolchain run: | xcodebuild -version xcodebuild build-for-testing \ -scheme App \ -destination "platform=iOS Simulator,name=iPhone 17,OS=27.0" \ -derivedDataPath "${{ github.workspace }}/.dd-canary" ``` The sequence that keeps the fleet green: add the canary on a nightly schedule with `continue-on-error: true`, give it its own DerivedData path so it cannot write over the pinned cache, let it run for a week of real commits, then change the workflow level `DEVELOPER_DIR` once it has been green across a dependency bump and a release branch. Runner image changes are published in the [WarpBuild changelog](/docs/ci/changelog), which is the event that should send you back to the canary. When a canary fails in a way the log does not explain, inspect the machine rather than guessing at the image. The Action Debugger opens a session into a running workflow so you can list `/Applications` and run `xcrun simctl list runtimes` by hand. ## [Cost or Time Model](#cost-or-time-model)Copy Anchor Link macOS sizes and rates, from the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13: | Label | vCPU | Memory | Storage | Rate per minute | | ----------------------- | ---- | ------ | --------- | --------------- | | warp-macos-26-arm64-6x | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | 12 | 28GB | 270GB SSD | $0.16 | | warp-macos-15-arm64-6x | 6 | 14GB | 120GB SSD | $0.08 | The guard step is the cheapest line in the file. Assume 10 seconds, which is 0.17 minutes, on the 6 vCPU label at $0.08 per minute: $0.0133 per job, or $8.00 per month across 600 jobs. Compare that against one drift event on the same repository. A build that fails at the 28 minute mark costs 28 x $0.08 = $2.24 in macOS time, and a moving alias hits every job in the queue, not one. Forty jobs into the morning that is 40 x $2.24 = $89.60 of macOS time plus the debugging hour, against $8.00 of guard steps for the whole month. The canary carries its own number. One nightly build of 12 minutes on the 6 vCPU label is 12 x $0.08 = $0.96 per run, or 30 x $0.96 = $28.80 per month. A week of canary before the switch is 7 x $0.96 = $6.72. Rates for every size are on the [pricing page](/pricing). Where the model breaks: a repository that archives on every pull request has a longer failing job than 28 minutes, which pushes the drift cost up, and a repository whose jobs are dominated by simulator tests should price the 12 vCPU label at $0.16 per minute instead. Substitute your own job length and job count; the arithmetic above carries whatever you put in. Start from the label list on the [macOS runner hub](/runners/macos), and take the pipeline shape from [iOS pipelines on GitHub Actions](/solutions/ios-github-actions). ## [FAQ](#faq)Copy Anchor Link **What is the most reliable way to pin an Xcode version in GitHub Actions?** Pin the versioned runner label first, because the label decides which Xcode bundles exist on the machine, then set `DEVELOPER_DIR` at workflow level so every job selects the same bundle. Runtime selection through a version spec is the least reliable of the three, because it resolves against whatever the image carries on the day it runs. The job level mechanics of both forms are in [how to select an Xcode version in a GitHub Actions job](/answers/how-do-i-select-an-xcode-version-in-github-actions). **How do I stop an Xcode pin from breaking after a runner image update?** Add a guard step that fails the job when the pinned bundle path is missing and that compares `xcodebuild -version` against the string you expect. The job then fails in ten seconds with the installed bundles printed in the log, instead of failing forty minutes later with a compiler error that reads like a code problem. **How do I test a new Xcode version before moving every job to it?** Add one scheduled canary job that runs the same build against the candidate bundle path with `continue-on-error: true`. Let it run for a week, then move the workflow level `DEVELOPER_DIR` once the canary is green and once the DerivedData cache keys carry the toolchain identifier. --- # WarpBuild for iOS Dev Weekly Readers | $50 in Free Credits | WarpBuild URL: https://www.warpbuild.com/ios Description: Found us in iOS Dev Weekly? Run Xcode builds on Apple silicon M4 Pro GitHub Actions runners, up to 3x faster than standard runners, with a one-line change. Redeem code iosdev2026 for $50 in free credits. //iOS Dev Weekly Readers # Xcode builds on GitHub Actions, up to 3x faster on M4 Pro. Thanks for reading iOS Dev Weekly. Move your macOS jobs to Apple silicon M4 Pro runners with a one-line change to your workflow. From $0.08 per minute, with Xcode, Fastlane, CocoaPods, and iOS Simulator preinstalled. $50 in free credits for iOS Dev Weekly readersiosdev2026Redeem under [Settings > Billing](https://app.warpbuild.com/settings/billing) after signing up [Claim $50 in Credits](https://app.warpbuild.com)[Schedule Demo](https://cal.com/suryao/start) No credit card required | 2-minute setup //GitHub Actions for iOS teams ## Your GitHub Actions bill is now a function of how many Xcode builds you queue AI is writing more Swift than ever, which means GitHub Actions is compiling more than ever. WarpBuild keeps the Xcode build queue short and the macOS bill flat. ### More commits means more Xcode builds AI tools write more Swift than ever, and every commit still has to compile, run its tests, and pass GitHub Actions before it merges. More commits means more Xcode builds, longer queues, and more macOS minutes. ### macOS minutes are the expensive line On most iOS teams the macOS jobs are the biggest line on the GitHub Actions bill. WarpBuild macOS runners start at $0.08 per minute and finish jobs faster, so you pay less per minute and use fewer minutes. ### Faster feedback for every TestFlight build A sample iOS app builds in 8 minutes on WarpBuild against 25 minutes on GitHub-hosted runners. Shorter builds mean shorter simulator test runs, faster PR checks, and a quicker path to TestFlight. //Getting started ## One line of YAML. $50 in credits. Three steps from this week's issue to your first faster Xcode build. ``` - runs-on: macos-latest+ runs-on: warp-macos-latest-arm64-6x ``` 1 ### Create your account Sign up at [app.warpbuild.com](https://app.warpbuild.com). No credit card required. 2 ### Redeem your credits Add promo code iosdev2026 under [Settings > Billing](https://app.warpbuild.com/settings/billing) for $50 in free credits. 3 ### Change one line of YAML Swap runs-on in your workflow file and your next Xcode build runs on an M4 Pro. //macOS runners ## Built for Xcode, simulator tests, and TestFlight Drop-in macOS runner infrastructure for GitHub Actions: Apple silicon M4 Pro, the latest Xcode images, and the same preinstalled tooling as GitHub-hosted runners. [Apple silicon M4 ProNative Apple silicon performance on M4 Pro runners, with 6 vCPU and 12 vCPU sizes to pick from.](/products/ci/macos-runners)[Up to 3x faster buildsUp to 3x faster than standard runners. A sample iOS app build drops from 25 minutes to 8 minutes.](/products/ci/macos-runners)[From $0.08 per minutemacOS runners start at $0.08 per minute. WarpBuild runners are 50-90% cheaper across the board.](/docs/ci/cloud-runners#macos-m4-pro-on-arm64)[Latest Xcode imagesmacOS 14, 15, and 26 images. The macOS 26 image ships Xcode 27 and its simulator runtimes.](/docs/ci/cloud-runners#macos-26-tooling)[Fastlane, CocoaPods, and SimulatorXcode, iOS Simulator, Fastlane, and CocoaPods are preinstalled. Same tooling as the GitHub-hosted image.](/docs/ci/preinstalled-software)[Linux and Windows tooMove the backend, web, and Android jobs in the same workflow onto WarpBuild Linux and Windows runners.](/docs/ci/cloud-runners) //Trusted at scale ## Trusted by teams like Sky from Comcast, Bitcoin, and Braintrust Enterprise ready and SOC2 Type 2 attested, from solo open-source projects to thousand-developer organizations. ![Sonar](/images/customers/sonarqube.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Comcast](/images/customers/comcast.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Sky](/_next/image?url=%2Fimages%2Fcustomers%2Fsky-uk.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Braintrust](/images/customers/braintrust.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Adaptive](/_next/image?url=%2Fimages%2Fcustomers%2Fadaptive.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![OxCaml](/images/customers/oxcaml.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![DDEV](/images/customers/ddev.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Kintsugi](/images/customers/kintsugi-ai.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Bitcoin](/images/customers/bitcoin.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![LanceDB](/_next/image?url=%2Fimages%2Fcustomers%2Flancedb.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) Wow. These macOS runners are a LOT faster than GitHub's. Previous builds would take 30 mins, now they're done in 8! Run times went down from \~25 mins to \~9 mins. Even bazel cache load time was faster by 30% Tried out pretty much every provider in the market, and this one works the best out of the box //FAQ ## Frequently asked questions How do I redeem the $50 in free credits? Sign up at then add promo code iosdev2026 on the billing settings page at The credits are applied to your account immediately. Does this work with my existing GitHub Actions workflow? Yes. WarpBuild macOS runners are drop-in replacements for GitHub-hosted macOS runners and carry the same preinstalled tooling. The only change is the runs-on label in your workflow file. Fastlane lanes, xcodebuild steps, and simulator tests run as they do today. Which Xcode versions are available? WarpBuild ships macOS 14, macOS 15, and macOS 26 images, each with the Xcode versions from the matching GitHub-hosted image. The macOS 26 image also ships Xcode 27.0 and its iOS, tvOS, watchOS, and visionOS simulator runtimes. See for the current list. Do I need a credit card to get started? No. Sign up, redeem your credits, and run your first Xcode builds without entering a card. Can I switch back? Yes. Change the runs-on label back to a GitHub-hosted runner and the job returns to GitHub. There is no lock-in and nothing else in the workflow changes. ## Stop waiting on Xcode. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Claim $50 in Credits](https://app.warpbuild.com) --- # Jobs at WarpBuild | WarpBuild URL: https://www.warpbuild.com/jobs Description: Join the team building blazing fast CI infrastructure and AI-native developer tools. See open positions at WarpBuild. //Careers # Jobs at WarpBuild WarpBuild provides compute infrastructure and tools for enabling AI-powered teams to work efficiently. We are a small team working on multiple products: WarpBuild CI (blazing fast GitHub Actions runners), and Helios (agentic harness for your company's knowledge). As a part of the early team, you'll play a role in everything we do. ## //Engineering [Member of Technical StaffRemote | India | Full-time | INR 30L - 1Cr + 0.1% - 0.5% equity](/jobs/member-of-technical-staff)[GTM EngineerRemote | India | Full-time | INR 25L - 60L + 0.1% - 1% equity](/jobs/gtm-engineer)[Senior Network EngineerRemote | India | Full-time | INR 30L - 80L + 0.1% - 0.2% equity](/jobs/senior-network-engineer) ## //Founder's Office [Chief of Staff to the CEORemote | India | Full-time | INR 30L - 80L + 0.1% - 0.25% equity](/jobs/chief-of-staff) Apply only through the Apply button on this page. Applications sent by LinkedIn message or email lead to instant disqualification. Don't see a role that fits? [Apply anyway](https://www.ycombinator.com/companies/warpbuild/jobs/). --- # Chief of Staff to the CEO | Jobs at WarpBuild | WarpBuild URL: https://www.warpbuild.com/jobs/chief-of-staff Description: Work directly with the CEO on the most important problems at WarpBuild: strategy, growth, finance, hiring and operations. [All positions](/jobs) # Chief of Staff to the CEO Team Founder's Office Location Remote (India) Type Full-time Compensation INR 30L - 80L + 0.1% - 0.25% equity [Apply for this position](https://www.ycombinator.com/companies/warpbuild/jobs/) Work directly with the CEO on the most important problems at WarpBuild: strategy, growth, finance, hiring and operations. WarpBuild provides compute infrastructure and tools for enabling AI-powered teams to work efficiently. We are a small team working on multiple products: WarpBuild CI (blazing fast GitHub Actions runners), and Helios (agentic harness for your company's knowledge). This is a breadth-first role. You own problems end to end across functions. You use AI tools to do the work of a team. ## What you'll do * \>Own cross-functional projects for the CEO, from problem statement to shipped result. * \>Run the company cadence: planning, goals, metrics, and follow-through on decisions. * \>Build financial and operating models: pricing, unit economics, infrastructure cost and capacity. * \>Run growth experiments: channel strategy, attribution, signup funnel and budget allocation. * \>Research markets, competitors and customers. Turn the research into clear recommendations. * \>Run hiring pipelines, and vendor, legal and finance processes. * \>Build AI agents and automations that replace recurring manual work. * \>Prepare investor updates and key external communication. ## What you'll bring * \>AI-first way of working. Daily use of AI tools for research, analysis, writing and automation. * \>Breadth. Comfort moving between strategy, finance, marketing, operations and product in the same week. * \>Very high agency. You find the problem, decide, and finish without being asked. * \>Structured problem solving and strong quantitative skills. Fast in a spreadsheet. * \>Clear, concise writing. * \>Top-tier academic record. * \>Experience at a top strategy consulting firm, in a founder's office, or as a founder is a plus. * \>Interest in developer tools and infrastructure. No industry experience is required. ## What we offer * \>High equity and compensation. * \>Direct work with the CEO of a high growth, profitable startup. * \>A very wide and interesting set of problems to work on. ## About the interview Three rounds, each about 60 minutes over video. No industry experience is assumed. Every case comes with the context and numbers you need. AI tools are allowed for information and knowledge. 1. 01 Background and business case (60 min) Things you have started, built, run or owned. Then a strategy case about a real question WarpBuild faces: structure the problem, ask for data, estimate, and recommend. 2. 02 Marketing and growth (60 min) A working session on how a self-serve developer product grows: attribution, signup funnel, budget allocation and simple experiments. 3. 03 Financial modeling (60 min) Build a model live in a spreadsheet on a screen share. The problem is about infrastructure economics and capacity allocation under variable demand. Each round is a knockout. An offer is rolled out in person over a final 30 minute conversation. ## Interested? The process is quick and we review every application. Apply only through the Apply button on this page. Applications sent by LinkedIn message or email lead to instant disqualification. [Apply now](https://www.ycombinator.com/companies/warpbuild/jobs/) --- # GTM Engineer | Jobs at WarpBuild | WarpBuild URL: https://www.warpbuild.com/jobs/gtm-engineer Description: Own customer growth for WarpBuild. The role is technical: you build automations, write content, and run the website, docs and blog. [All positions](/jobs) # GTM Engineer Team Engineering Location Remote (India) Type Full-time Compensation INR 25L - 60L + 0.1% - 1% equity [Apply for this position](https://www.ycombinator.com/companies/warpbuild/jobs/) Own customer growth for WarpBuild. The role is technical: you build automations, write content, and run the website, docs and blog. WarpBuild provides compute infrastructure and tools for enabling AI-powered teams to work efficiently. We are a small team working on multiple products: WarpBuild CI (blazing fast GitHub Actions runners), and Helios (agentic harness for your company's knowledge). The key measure of success for this role is customer growth. The ideal candidate is technical, has taste, communicates very well, creates content hands-on, and has a strong affinity for automation. ## What you'll do * \>Automate every part of your own work and the company's go-to-market work. Build and manage agents that do the work. * \>Engage with users across channels and create user-focused content. * \>Create videos, technical blogs, feature walkthroughs and more. * \>Own the website, docs, blog and other user-facing surfaces. ## What you'll bring * \>You are an engineer with first-principles thinking and passionate about product and user experience. Customers are at the heart of everything you do. * \>You have a strong entrepreneurial mindset. You might have been a founder previously, have built an impressive side project, or led a project in your day job. ## What we offer * \>High equity and compensation. * \>High impact role as a founding member of a high growth, profitable startup. * \>A very wide and interesting set of problems to work on. ## About the interview The process is quick and consists of 3 rounds. Use your own computer, and the language and environment you are most comfortable with. AI tools and IDEs are encouraged. Each round is a screen share with video. We look for ownership, communication and autonomy. This is evaluated throughout the process, in how you communicate your approach, solutions and overall thinking. 1. 01 Building an app (90 min) Take a problem statement and build a tool, app or automation that solves it. This tests for user focus and taste. 2. 02 Content creation (60 min) Create a piece of technical content, such as a blog, case study, social media post, video or explainer. 3. 03 Marketing and growth (60 min) Deep dive into the practices and tools you will use to acquire customers. Each round is a knockout. An offer is rolled out over a final 15 minute conversation. ## Interested? The process is quick and we review every application. Apply only through the Apply button on this page. Applications sent by LinkedIn message or email lead to instant disqualification. [Apply now](https://www.ycombinator.com/companies/warpbuild/jobs/) --- # Member of Technical Staff | Jobs at WarpBuild | WarpBuild URL: https://www.warpbuild.com/jobs/member-of-technical-staff Description: WarpBuild provides compute infrastructure and tools for enabling AI-powered teams to work efficiently. [All positions](/jobs) # Member of Technical Staff Team Engineering Location Remote (India) Type Full-time Compensation INR 30L - 1Cr + 0.1% - 0.5% equity [Apply for this position](https://www.ycombinator.com/companies/warpbuild/jobs/) WarpBuild provides compute infrastructure and tools for enabling AI-powered teams to work efficiently. We are a small team working on multiple products: WarpBuild CI (blazing fast GitHub Actions runners), and Helios (agentic harness for your company's knowledge). As a part of the early team, you'll play a role in everything we do. ## What you'll bring * \>You are an engineer with first-principles thinking and passionate about product and user experience. Customers are at the heart of everything you do. * \>You have a strong entrepreneurial mindset. You might have been a founder previously, have built an impressive side project, or led a project in your day job. ## What we offer * \>High equity and compensation. * \>High impact role as a founding member of a high growth, profitable startup. * \>A very wide and interesting set of problems to work on. ## About the interview The process is super quick and consists of 4 rounds. You'll be using your own computer, and using the language/environment with which you are most comfortable. You are encouraged to use AI tools and IDEs for the exercises. You'll be doing a screen share with video during the call. A local env for development is preferred because some exercises might need inter-communicating services. The interview process is a deeper dive into technical abilities, product thinking and overall fit. For the "fit" portion, we are again looking for ownership and autonomy, but we also dig deeper into communication. This is evaluated throughout the process: in how you communicate your approach, solutions and overall thinking. Each interview is up to 1 hour long, with 50 minutes allocated for the technical portion, and 10 minutes for Q&A. 1. 01 Prototyping (60 min) This is a real-world type simulation: take an app description to a working prototype. 2. 02 Design and Architecture (60 min) This is a brainstorming session where we discuss solutions to a real-world scenario. 3. 03 Product thinking (60 min) This simulates a real-world day-to-day experience where you will build user experiences (UI, API, CLI or other user facing surfaces). The emphasis here is on finesse, quality, and attention to detail. 4. 04 Team fit and Aptitude (60 min) This is a discussion about how you would build a feature (typically one we've already had to build) and your decision making. We will also explore behavioral aspects and cover your past experiences, interest in startups and how you operate in smaller teams. Each round is a knockout. An offer is rolled out in person over a final 30 minute conversation. ## Interested? The process is quick and we review every application. Apply only through the Apply button on this page. Applications sent by LinkedIn message or email lead to instant disqualification. [Apply now](https://www.ycombinator.com/companies/warpbuild/jobs/) --- # Senior Network Engineer | Jobs at WarpBuild | WarpBuild URL: https://www.warpbuild.com/jobs/senior-network-engineer Description: Build WarpBuild's datacenter network from empty racks to production traffic. Run it and grow it to new sites. [All positions](/jobs) # Senior Network Engineer Team Engineering Location Remote (India) Type Full-time Compensation INR 30L - 80L + 0.1% - 0.2% equity [Apply for this position](https://www.ycombinator.com/companies/warpbuild/jobs/) Build WarpBuild's datacenter network from empty racks to production traffic. Run it and grow it to new sites. WarpBuild runs fast CI runners for GitHub Actions. We are building our own bare-metal infrastructure in colocation datacenters. We are a small team, and every engineer owns a large part of the product. You will be our first network engineer. This is a hands-on role with full ownership of design, deployment, automation and operations. You will work directly with the founders and the platform engineering team. ## What you'll do * \>Design, deploy and operate leaf-spine datacenter networks, including the edge and the out-of-band network. * \>Run the internet edge: BGP with transit providers and peers, routing security, and DDoS protection. * \>Lead new site turn-ups from planning to handover: cabling plans, device staging, validation and acceptance testing. * \>Manage network configuration as code, with a source of truth, templates, automated checks and zero-touch provisioning. * \>Direct remote hands, cabling vendors, carriers and hardware vendors. Our sites have no on-site staff. * \>Build monitoring and alerting for the network. Use the data for capacity planning. * \>Work with platform engineers on Linux host networking for our virtualization stack. * \>Write the designs, procedures and runbooks that the rest of the team relies on. * \>Take part in the on-call rotation. ## What you'll bring * \>7+ years in network engineering, mostly on production datacenter, cloud or service provider networks. * \>Experience building a datacenter or PoP network from the ground up at least once. * \>Strong knowledge of BGP, ECMP, leaf-spine fabrics, link aggregation and fast convergence. * \>Experience running an internet edge: transit and peering turn-ups, IRR and RPKI, and traffic engineering. * \>Production experience with at least one major switching platform, such as Arista, Juniper, Cisco or SONiC. * \>Strong automation skills with Python and Ansible or similar tools. Config lives in Git. * \>Good knowledge of the physical layer: optics, DAC and fiber cabling, structured cabling and cross-connects. * \>Comfort on the Linux command line and a working understanding of the Linux network stack. * \>Clear written English. A technician who has never seen the site can follow your procedure. ## Nice to have * \>Experience at a cloud provider, CDN, carrier or internet exchange. * \>NetBox or another network source of truth. * \>Streaming telemetry and flow monitoring, such as gNMI and sFlow. * \>KVM and virtual machine networking, NAT at scale, nftables. * \>IPv6 in production. * \>Participation in SANOG, INNOG, APNIC or a similar operator community. ## What we offer * \>High equity and compensation. * \>Full ownership of the network at an early stage of the company. * \>Remote work from anywhere in India. * \>Occasional international travel for site builds. ## About the interview Four rounds, each up to 60 minutes: 50 minutes technical, 10 minutes Q&A. Use your own computer and tools. AI tools are allowed. Each round is a screen share with video. 1. 01 Network design (60 min) Design a leaf-spine fabric and internet edge for a real site. Focus on tradeoffs, failure domains and growth. 2. 02 Troubleshooting (60 min) Debug a broken network. Find the cause, fix it, and explain how to prevent it. 3. 03 Automation (60 min) Write working code for a network task: config generation from a source of truth, or a validation check. 4. 04 Team fit and aptitude (60 min) Past builds, work with vendors and remote hands, written communication, and working in a small team. Each round is a knockout. An offer is rolled out in person over a final 30 minute conversation. ## Interested? The process is quick and we review every application. Apply only through the Apply button on this page. Applications sent by LinkedIn message or email lead to instant disqualification. [Apply now](https://www.ycombinator.com/companies/warpbuild/jobs/) --- # WarpBuild for Kent C. Dodds Viewers | $50 in Free Credits | WarpBuild URL: https://www.warpbuild.com/kcd Description: More AI code means more commits and more CI. Run GitHub Actions 2x faster at half the cost with a one-line change. Redeem code kcd50 for $50 in free credits. //For Kent C. Dodds Viewers # More AI code. More commits. More CI. Run GitHub Actions 2x faster at half the cost with a one-line change. Linux, macOS, and Windows on x86-64 and ARM64, with unlimited macOS concurrency. $50 in free credits for Kent C. Dodds Viewerskcd50Redeem under [Settings > Billing](https://app.warpbuild.com/settings/billing) after signing up [Claim $50 in Credits](https://app.warpbuild.com)[Schedule Demo](https://cal.com/suryao/start) No credit card required | 2-minute setup //CI in the AI era ## AI agents write the code. CI is now the bottleneck. AI is writing more code than ever, which means GitHub Actions is running more than ever. WarpBuild keeps the feedback loop fast and the bill low. ### More AI code, more commits Agents open pull requests all day. Every one has to build, test, and merge through the same GitHub Actions pipeline. ### More commits, more CI Every commit runs a full pipeline. CI is now the bottleneck for you and for your coding agents, and the bill grows with it. ### Twice as fast at half the cost WarpBuild runners are priced at half of GitHub-hosted runners and finish jobs faster, so you save on both rate and runtime. //Getting started ## One line of YAML. $50 in credits. Three steps from this page to your first faster build. ``` - runs-on: ubuntu-latest+ runs-on: warp-ubuntu-latest-x64-2x ``` 1 ### Create your account Sign up at [app.warpbuild.com](https://app.warpbuild.com). No credit card required. 2 ### Redeem your credits Add promo code kcd50 under [Settings > Billing](https://app.warpbuild.com/settings/billing) for $50 in free credits. 3 ### Change one line of YAML Swap runs-on in your workflow file and your next build runs on WarpBuild. //WarpBuild CI ## The broadest OS and architecture support Drop-in GitHub Actions runner infrastructure: 2-10x faster builds, half the cost, faster caching, and BYOC for teams that want CI in their own cloud. [2x faster, 50% cheaperFaster CPUs, faster disks, and 3-10x faster caching than GitHub-hosted runners.](/compare/github)[One line of YAMLChange runs-on and you're done. No workflow rewrites, no migration project.](/docs/ci/quick-start)[Linux, macOS, and Windowsx86-64 and ARM64 runners across all major operating systems, including M4 Pro Macs.](/docs/ci/cloud-runners)[macOS with unlimited concurrencyRun as many Mac jobs at once as your agents create. No queuing behind your own jobs.](/products/ci/macos-runners)[Our cloud or yoursManaged runners in WarpBuild's cloud, or BYOC deployments in your own AWS, GCP, or Azure.](/docs/ci/byoc)[Enterprise readySOC2 Type 2 attested, ephemeral VMs, SSO, and 24x7 support with SLA guarantees.](/docs/ci/security) //Trusted at scale ## Trusted by teams at Braintrust, Raycast, OpenRouter, Comcast, Jane Street, and more Enterprise ready and SOC2 Type 2 attested, from solo open-source projects to thousand-developer organizations. ![Sonar](/images/customers/sonarqube.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Comcast](/images/customers/comcast.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Sky](/_next/image?url=%2Fimages%2Fcustomers%2Fsky-uk.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Braintrust](/images/customers/braintrust.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Adaptive](/_next/image?url=%2Fimages%2Fcustomers%2Fadaptive.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![OxCaml](/images/customers/oxcaml.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![DDEV](/images/customers/ddev.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Kintsugi](/images/customers/kintsugi-ai.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Bitcoin](/images/customers/bitcoin.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![LanceDB](/_next/image?url=%2Fimages%2Fcustomers%2Flancedb.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) Run times went down from \~25 mins to \~9 mins. Even bazel cache load time was faster by 30% Wow. These macOS runners are a LOT faster than GitHub's. Previous builds would take 30 mins, now they're done in 8! Tried out pretty much every provider in the market, and this one works the best out of the box //FAQ ## Frequently asked questions How do I redeem the $50 in free credits? Sign up at then add promo code kcd50 on the billing settings page at The credits are applied to your account immediately. Do I need a credit card to get started? No. Sign up, redeem your credits, and run your first builds without entering a card. Will my existing GitHub Actions workflows work? Yes. WarpBuild runners are fully compatible with GitHub Actions and come with the standard tooling preinstalled. The only change is the runs-on label in your workflow file. Which operating systems and architectures are supported? Linux, macOS, and Windows, on x86-64 and ARM64\. macOS runners have unlimited concurrency, so Mac jobs do not queue behind each other. Can we run WarpBuild in our own cloud? Yes. Bring Your Own Cloud deployments run WarpBuild runners inside your own AWS, GCP, or Azure account, with static IPs and custom machine images. See for details. Is WarpBuild ready for enterprise use? Yes. WarpBuild is SOC2 Type 2 attested with ephemeral, fully isolated VMs, SSO and access controls, and 24x7 support backed by SLAs. Teams at Braintrust, Raycast, OpenRouter, Comcast, Jane Street, and more run their CI on WarpBuild. ## Stop waiting on CI. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Claim $50 in Credits](https://app.warpbuild.com) --- # Privacy Policy | WarpBuild URL: https://www.warpbuild.com/legal/privacy Description: Our Privacy Policy governs your visit to warpbuild.com, and explains how we collect, safeguard and disclose information that results from your use of our Service. [Subscribe](/legal/rss.xml) ## Privacy Policy Last updated August 19, 2026 ## 1\. Introduction Welcome to WarpBuild. At WarpBuild, we believe in transparency and responsible data handling. This privacy policy explains what data we collect, and how we use data to provide you with both our CI/CD infrastructure and Helios AI-powered development tools while respecting your privacy and maintaining security. Argonaut HQ, Inc ("us", "we", or "our") operates (hereinafter referred to as "Service"). Our Privacy Policy governs your visit to , and explains how we collect, and safeguard information that results from your use of our Service. We use your data to provide and improve Service. By using Service, you agree to the collection and use of information in accordance with this policy. Unless otherwise defined in this Privacy Policy, the terms used in this Privacy Policy have the same meanings as in our Terms and Conditions. Our Terms and Conditions ("Terms") govern all use of our Service and together with the Privacy Policy constitutes your agreement with us ("agreement"). ## 2\. Definitions * **SERVICE** means the website operated by Argonaut HQ, Inc * **PERSONAL DATA** means data about a living individual who can be identified from those data (or from those and other information either in our possession or likely to come into our possession). * **USAGE DATA** is data collected automatically either generated by the use of Service or from Service infrastructure itself (for example, the duration of a page visit). * **COOKIES** are small files stored on your device (computer or mobile device). * **DATA CONTROLLER** means a natural or legal person who (either alone or jointly or in common with other persons) determines the purposes for which and the manner in which any personal data are, or are to be, processed. For the purpose of this Privacy Policy, we are a Data Controller of your data. * **DATA PROCESSOR (OR SERVICE PROVIDER)** means any natural or legal person who processes the data on behalf of the Data Controller. We may use the services of various Service Providers in order to process your data more effectively. * **DATA SUBJECT** is any living individual who is the subject of Personal Data. * **THE USER** is the individual using our Service. The User corresponds to the Data Subject, who is the subject of Personal Data. ## 3\. Information Collection and Use We collect several different types of information for various purposes to provide and improve our Service to you. ## 4\. Types of Data Collected ### Personal Data While using our Service, we may ask you to provide us with certain personally identifiable information that can be used to contact or identify you ("Personal Data"). Personally identifiable information may include, but is not limited to: * Email address * First name and last name * Cookies and Usage Data * Rough location (e.g. city, state, country) We may use your Personal Data to contact you with newsletters, marketing or promotional materials and other information that may be of interest to you. You may opt out of receiving any, or all, of these communications from us by following the unsubscribe link. ### Usage Data We may also collect information that your browser sends whenever you visit our Service or when you access Service by or through a mobile device ("Usage Data"). This Usage Data may include information such as your computer's Internet Protocol address (e.g. IP address), browser type, browser version, the pages of our Service that you visit, the time and date of your visit, the time spent on those pages, unique device identifiers and other diagnostic data. When you access Service with a mobile device, this Usage Data may include information such as the type of mobile device you use, your mobile device unique ID, the IP address of your mobile device, your mobile operating system, the type of mobile Internet browser you use, unique device identifiers and other diagnostic data. ### Tracking Technologies and Cookies We use cookies and similar technologies on our websites and in the Service. They fall into three categories, matching the choices offered in our cookie banner: * **Strictly necessary.** Required for the Service to function - for example, authentication and session cookies, and the first-party cookie that records your consent choices (`wb_consent`). These do not require consent. * **Analytics.** Used to understand how our websites and Service are used - pages viewed, features used, approximate location derived from IP address, and device and browser information. Where the law requires prior consent (for example in the EEA, the UK and Switzerland), these are set only after you opt in through our cookie banner. Elsewhere they are on by default and you can turn them off at any time. * **Marketing.** Used, with your consent, by us and our advertising partners to measure marketing campaigns and attribute signups to their source. This includes tags and pixels from our advertising partners and our first-party attribution cookie (`wb_attribution`), which stores campaign parameters (such as UTM codes and advertising click identifiers), the page you landed on, and the referring site for up to 90 days and is readable across warpbuild.com subdomains. **Your choices.** Our websites do not load marketing technologies until you opt in through our cookie banner. Analytics technologies follow the regional rule described above. You can change or withdraw your choices at any time using the "Cookie settings" link in the footer of our websites, or through your browser's cookie controls. Withdrawing consent stops the associated technologies from loading and running. **Session replay and interaction recording.** We do not use session replay on our public marketing websites. Within the logged-in WarpBuild application, we may use session replay technology that records how you interact with the application - such as pages visited, mouse movements, clicks, scrolling, and form interactions - and that is designed to mask sensitive inputs such as passwords. We use these recordings to diagnose usability problems, investigate errors, and improve the Service. By creating an account and using the logged-in application you consent to this recording; if you have questions or objections, contact us at [support@warpbuild.com](mailto:support@warpbuild.com). ## 5\. Use of Data Argonaut HQ, Inc uses the collected data for various purposes: * to provide and maintain our Service; * to notify you about changes to our Service; * to allow you to participate in interactive features of our Service when you choose to do so; * to provide customer support; * to gather analysis or valuable information so that we can improve our Service; * to monitor the usage of our Service; * to detect, prevent and address technical issues; * to fulfill any other purpose for which you provide it; * to carry out our obligations and enforce our rights arising from any contracts entered into between you and us, including for billing and collection; * to provide you with notices about your account and/or subscription, including expiration and renewal notices, email-instructions, etc.; * to provide you with news, special offers and general information about other goods, services and events which we offer that are similar to those that you have already purchased or enquired about unless you have opted not to receive such information; * in any other way we may describe when you provide the information; * for any other purpose with your consent. **WarpBuild CI**: No source code or build logs are stored on our servers. We only receive the event trigger from the CI/CD provider to process the build. **WarpBuild Helios**: Source code representation is stored for providing code intelligence services. However, this is not used for any AI model training. We have Zero Data Retention agreements in place with the AI model providers to ensure there is no access to any sensitive data. **WarpBuild CI Insights**: Aggregate information about build trends and metrics are stored to improve our Service. ## 6\. Retention of Data We will retain your Personal Data only for as long as is necessary for the purposes set out in this Privacy Policy. We will retain and use your Personal Data to the extent necessary to comply with our legal obligations (for example, if we are required to retain your data to comply with applicable laws), resolve disputes, and enforce our legal agreements and policies. We will also retain Usage Data for internal analysis purposes. Usage Data is generally retained for a shorter period, except when this data is used to strengthen the security or to improve the functionality of our Service, or we are legally obligated to retain this data for longer time periods. ## 7\. Transfer of Data Your information, including Personal Data, may be transferred to – and maintained on – computers located outside of your state, province, country or other governmental jurisdiction where the data protection laws may differ from those of your jurisdiction. Please note that we transfer the data, including Personal Data, to the United States or where our servers are located and process it there. Where we transfer Personal Data originating from the European Economic Area, the United Kingdom, or Switzerland to countries that have not received an adequacy decision, we rely on appropriate safeguards recognized under applicable law, such as the European Commission's Standard Contractual Clauses. Argonaut HQ, Inc will take all the steps reasonably necessary to ensure that your data is treated securely and in accordance with this Privacy Policy and no transfer of your Personal Data will take place to an organization or a country unless there are adequate controls in place including the security of your data and other personal information. ## 8\. Disclosure of Data We may disclose personal information that we collect, or you provide: ### Disclosure for Law Enforcement Under certain circumstances, we may be required to disclose your Personal Data if required to do so by law or in response to valid requests by public authorities. ### Business Transaction If we or our subsidiaries are involved in a merger, acquisition or asset sale, your Personal Data may be transferred. ### Other cases We may disclose your information also: * to our subsidiaries and affiliates; * to contractors, service providers, and other third parties we use to support our business; * to fulfil the purpose for which you provide it; * for the purpose of including your company's logo on our website; * for any other purpose disclosed by us when you provide the information; * with your consent in any other cases; * if we believe disclosure is necessary or appropriate to protect the rights, property, or safety of the Company, our customers, or others. ## 9\. Security of Data The security of your data is important to us. We strive to use commercially acceptable means to protect your Personal Data. We implement industry-standard security measures: * Regular security audits and penetration testing * Access controls and authentication safeguards * SOC 2 compliance ## 10\. Your Data Protection Rights Under General Data Protection Regulation (GDPR) If you are a resident of the European Union (EU) and European Economic Area (EEA), you have certain data protection rights, covered by GDPR. – See more at We aim to take reasonable steps to allow you to correct, amend, delete, or limit the use of your Personal Data. If you wish to be informed what Personal Data we hold about you and if you want it to be removed from our systems, please email us at [support@warpbuild.com](mailto:support@warpbuild.com). ### Legal Bases for Processing We process your Personal Data under the following legal bases (Article 6 GDPR): * **Performance of a contract** \- to provide and maintain the Service, manage your account, and handle billing; * **Legitimate interests** \- to secure and improve the Service, prevent fraud and abuse, and communicate with business contacts, where those interests are not overridden by your rights; * **Consent** \- for marketing cookies and similar technologies on our websites, for analytics cookies where the law requires prior consent, and for marketing communications; you may withdraw consent at any time without affecting the lawfulness of processing before withdrawal; * **Legal obligation** \- to comply with applicable law, such as tax, accounting, and lawful requests from public authorities. In certain circumstances, you have the following data protection rights: * the right to access, update or to delete the information we have on you; * the right of rectification. You have the right to have your information rectified if that information is inaccurate or incomplete; * the right to object. You have the right to object to our processing of your Personal Data; * the right of restriction. You have the right to request that we restrict the processing of your personal information; * the right to data portability. You have the right to be provided with a copy of your Personal Data in a structured, machine-readable and commonly used format; * the right to withdraw consent. You also have the right to withdraw your consent at any time where we rely on your consent to process your personal information; Please note that we may ask you to verify your identity before responding to such requests. Please note, we may not be able to provide Service without some necessary data. You have the right to complain to a Data Protection Authority about our collection and use of your Personal Data. For more information, please contact your local data protection authority in the European Economic Area (EEA). ## 11\. Your Data Protection Rights under the California Privacy Protection Act (CalOPPA) CalOPPA is the first state law in the nation to require commercial websites and online services to post a privacy policy. The law's reach stretches well beyond California to require a person or company in the United States (and conceivable the world) that operates websites collecting personally identifiable information from California consumers to post a conspicuous privacy policy on its website stating exactly the information being collected and those individuals with whom it is being shared, and to comply with this policy. – See more at: According to CalOPPA we agree to the following: * users can visit our site anonymously; * our Privacy Policy link includes the word "Privacy", and can easily be found on the page specified above on the home page of our website; * users will be notified of any privacy policy changes on our Privacy Policy Page; * users are able to change their personal information by emailing us at [support@warpbuild.com](mailto:support@warpbuild.com). ## 12\. Your California Privacy Rights (CCPA, as amended by the CPRA) If you are a California resident, you are entitled to learn what data we collect about you, ask us to correct or delete your data, and opt out of its sale or sharing. ### Categories of personal information we collect In the preceding 12 months we have collected the following categories of personal information: identifiers (such as name, email address, and IP address); commercial information (such as subscription and billing records); internet or other electronic network activity information (such as pages visited, interactions with our websites and Service - including, in the logged-in application, session replay recordings - and cookie data); coarse geolocation derived from IP address; and professional information you choose to provide. We collect them from you directly, from your devices, and from our service providers, for the business and commercial purposes described in "Use of Data." ### Sale and sharing of personal information We do not sell personal information for money. With your opt-in consent, we allow advertising partners to collect internet activity information through cookies and tags on our websites, which may constitute "sharing" (cross-context behavioral advertising) - and in some cases a "sale" - under California law. Our websites do not load these advertising technologies unless you opt in through our cookie banner, so no sale or sharing occurs for visitors who have not opted in - including browsers transmitting a Global Privacy Control (GPC) signal that have not opted in, consistent with that signal. We do not knowingly sell or share the personal information of consumers under 16 years of age. To opt out of sale or sharing (or to withdraw a previous opt-in), use the "Cookie settings" link in the footer of our websites, or email us at [support@warpbuild.com](mailto:support@warpbuild.com). ### Sensitive personal information We do not collect sensitive personal information as defined by the CPRA except as incidentally contained in content you choose to submit, and we do not use or disclose sensitive personal information to infer characteristics about you. To exercise your other rights, you can make certain requests and ask us: ### What personal information we have about you If you make this request, we will return to you: * The categories of personal information we have collected about you. * The categories of sources from which we collect your personal information. * The business or commercial purpose for collecting or selling your personal information. * The categories of third parties with whom we share personal information. * The specific pieces of personal information we have collected about you. * A list of categories of personal information that we have sold, along with the category of any other company we sold it to. If we have not sold your personal information, we will inform you of that fact. * A list of categories of personal information that we have disclosed for a business purpose, along with the category of any other company we shared it with. Please note, you are entitled to ask us to provide you with this information up to two times in a rolling twelve-month period. When you make this request, the information provided may be limited to the personal information we collected about you in the previous 12 months. ### To correct your personal information If you make this request, we will correct inaccurate personal information we maintain about you, taking into account the nature of the personal information and the purposes of the processing. ### To delete your personal information If you make this request, we will delete the personal information we hold about you as of the date of your request from our records and direct any service providers to do the same. In some cases, deletion may be accomplished through de-identification of the information. If you choose to delete your personal information, you may not be able to use certain functions that require your personal information to operate. Please note, if you ask us to delete your data or opt out of sale or sharing, it may impact your experience with us, and you may not be able to participate in certain programs or membership services which require the usage of your personal information to function. But in no circumstances will we discriminate against you for exercising your rights. To exercise your California data protection rights described above, please send your request(s) by one of the following means: By email: [support@warpbuild.com](mailto:support@warpbuild.com) Your data protection rights, described above, are covered by the California Consumer Privacy Act (CCPA), as amended by the California Privacy Rights Act (CPRA) effective January 1, 2023\. To find out more, visit the official California Legislative Information website. ## 13\. Service Providers We may employ third party companies and individuals to facilitate our Service ("Service Providers"), provide Service on our behalf, perform Service-related services or assist us in analyzing how our Service is used. These third parties have access to your Personal Data only to perform these tasks on our behalf and are obligated not to disclose or use it for any other purpose. We rely on third-party Service Providers to provide our Service. The list of subprocessors is available at . ## 14\. Children's Privacy Our Services are not intended for use by children under the age of 13 ("Children"). We do not knowingly collect personally identifiable information from Children under 13\. If you become aware that a Child has provided us with Personal Data, please contact us. If we become aware that we have collected Personal Data from Children without verification of parental consent, we take steps to remove that information from our servers. ## 15\. Changes to This Privacy Policy We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page. We may let you know via email and/or a prominent notice on our Service, prior to the change becoming effective and update "effective date" at the top of this Privacy Policy. You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted on this page. ## 16\. Contact Us If you have any questions about this Privacy Policy, please contact us: By email: [support@warpbuild.com](mailto:support@warpbuild.com) We have appointed the following representatives under Article 27 GDPR: **EU representative** (Art 27 EU GDPR): Prighter EU Rep GmbH Schellinggasse 3/10, 1010 Vienna Austria **UK representative** (Art 27 UK GDPR): Prighter Ltd 20 Mortlake High Street, London, SW14 8JN United Kingdom --- # Subprocessors | WarpBuild URL: https://www.warpbuild.com/legal/subprocessors Description: WarpBuild may use the following Subprocessors to host Customer Data, provide infrastructure that helps with delivery of our Services or related functions. [Subscribe](/legal/rss.xml) ## Subprocessors Last updated September 16, 2026 ## 1\. Introduction ‍To support delivery of our services, Argonaut HQ, Inc. ("WarpBuild", "Company", "Argonaut") uses service providers that may store and process Customer Data (each, a "Subprocessor"). This page provides important information about the identity and role of each Subprocessor. Terms used on this page but not defined have the meaning set forth in the [Terms of Service](https://www.warpbuild.com/legal/terms) or superseding written agreement between Customer and WarpBuild (the "Agreement"). ## 2\. Third-Party Subprocessors WarpBuild may use the following Subprocessors to host Customer Data, provide infrastructure that helps with delivery of our Services, or perform other functions related to the WarpBuild Service. Prior to engaging any third party Subprocessor, WarpBuild performs diligence to evaluate their privacy, security and confidentiality practices. We collect several different types of information for various purposes to provide and improve our Service to you. | Entity | Subprocessing Activity | | ------------------------- | --------------------------------------------------------------- | | Amazon Web Services, Inc. | Cloud Service Provider | | Google LLC | Cloud Service Provider, Website Analytics, Email Communications | | Microsoft Azure | Cloud Service Provider | | Cloudflare, Inc. | Web Application Firewall | | Datadog, Inc. | Infrastructure Monitoring | | MacStadium, Inc. | macOS Cloud Infrastructure | | Neon, Inc. | Serverless Database Services | | UnifyGTM | Go-to-Market / Sales Intelligence | | Attio | CRM | | Slack Technologies, Inc. | Messaging Services | | Vercel Inc. | Web Hosting Provider | | GitHub, Inc. | Version Control and Developer Services | | Posthog, Inc. | Product Analytics & Session Replay (logged-in application) | | Atlas Support, Inc. | Customer Communications | | Linear Orbit | Project management and Tracking | | OpenAI, L.L.C. | AI Services | | Anthropic, PBC | AI Services | | Stripe, Inc. | Payment Processing | | Hetzner Online GmbH | Cloud Service Provider | | E2B Dev, Inc. | Cloud Sandboxes | | ClickHouse, Inc. | Data Storage and Analytics | | Incident.io Ltd. | Status Page and Incident Management | | Latitude.sh LTDA | Bare Metal Cloud Infrastructure | | UAB Cherry Servers | Bare Metal Cloud Infrastructure | --- # Terms of Service | WarpBuild URL: https://www.warpbuild.com/legal/terms Description: These Terms of Service govern your use of our web pages located at https://warpbuild.com operated by Argonaut HQ, Inc. [Subscribe](/legal/rss.xml) ## Terms of Service Last updated September 30, 2026 ## 1\. Introduction Welcome to Argonaut HQ, Inc (“Company”, “we”, “our”, “us”)! As you have just clicked our Terms of Service, please pause, grab a cup of coffee and carefully read the following pages. It will take you approximately 20 minutes. These Terms of Service (“Terms”, “Terms of Service”) govern your use of our web pages located at operated by Argonaut HQ, Inc Our Privacy Policy also governs your use of our Service and explains how we collect, safeguard and disclose information that results from your use of our web pages. Please read it here . Your agreement with us includes these Terms and our Privacy Policy (“Agreements”). You acknowledge that you have read and understood Agreements, and agree to be bound by them. If you do not agree with (or cannot comply with) Agreements, then you may not use the Service, but please let us know by emailing at [support@warpbuild.com](mailto:support@warpbuild.com) so we can try to find a solution. These Terms apply to all visitors, users and others who wish to access or use Service. These terms may be superseded by custom agreements with specific customers. Thank you for being responsible. ## 2\. Communications By creating an Account on our Service, you agree to subscribe to newsletters, marketing or promotional materials and other information we may send. However, you may opt out of receiving any, or all, of these communications from us by following the unsubscribe link or by emailing at [support@warpbuild.com](mailto:support@warpbuild.com). ## 3\. Purchases If you wish to purchase any product or service made available through Service ("Purchase"), you may be asked to supply certain information relevant to your Purchase including, without limitation, your credit card number, the expiration date of your credit card, your billing address, and your shipping information. You represent and warrant that: * You have the legal right to use any credit card(s) or other payment method(s) in connection with any Purchase; and that * The information you supply to us is true, correct and complete. We may employ the use of third party services for the purpose of facilitating payment and the completion of Purchases. By submitting your information, you grant us the right to provide the information to these third parties subject to our Privacy Policy. We reserve the right to refuse or cancel your order at any time for reasons including but not limited to: product or service availability, errors in the description or price of the product or service, error in your order or other reasons. We reserve the right to refuse or cancel your order if fraud or an unauthorized or illegal transaction is suspected. ## 4\. Subscriptions Some parts of Service are billed on a subscription basis ("Subscription(s)"). You will be billed in advance on a recurring and periodic basis ("Billing Cycle"). Billing cycles are set either on a monthly or annual basis, depending on the type of subscription plan you select when purchasing a Subscription. At the end of each Billing Cycle, your Subscription will automatically renew unless you cancel it or Argonaut HQ, Inc cancels it. You may cancel your Subscription renewal either through your online account management page or by contacting Argonaut HQ, Inc customer support team. A valid payment method, including credit card, is required to process the payment for your subscription. You shall provide Argonaut HQ, Inc with accurate and complete billing information including full name, address, state, zip code, telephone number, and a valid payment method information. By submitting such payment information, you automatically authorize Argonaut HQ, Inc to charge all Subscription fees incurred through your account to any such payment instruments. Should automatic billing fail to occur for any reason, Argonaut HQ, Inc will issue an electronic invoice indicating that you must proceed manually, within a certain deadline date, with the full payment corresponding to the billing period as indicated on the invoice. ## 5\. Free Trial Argonaut HQ, Inc may, at its sole discretion, offer a Subscription with a free trial for a limited period of time ("Free Trial"). You may be required to enter your billing information in order to sign up for Free Trial. If you do enter your billing information when signing up for Free Trial, you will not be charged by Argonaut HQ, Inc until Free Trial has expired. On the last day of Free Trial period, unless you cancelled your Subscription, you will be automatically charged the applicable Subscription fees for the type of Subscription you have selected. At any time and without notice, Argonaut HQ, Inc reserves the right to: * Modify Terms of Service of Free Trial offer, or * Cancel such Free Trial offer. ## 6\. Fee Changes Argonaut HQ, Inc, in its sole discretion and at any time, may modify Subscription fees for the Subscriptions. Any Subscription fee change will become effective at the end of the then-current Billing Cycle. Argonaut HQ, Inc will provide you with a reasonable prior notice of any change in Subscription fees to give you an opportunity to terminate your Subscription before such change becomes effective. Your continued use of Service after Subscription fee change comes into effect constitutes your agreement to pay the modified Subscription fee amount. ## 7\. Refunds We do not offer refunds for products that are billed on a usage based model. We may offer refunds for products that are billed on a subscription basis, at our sole discretion. ## 8\. Content Our Service allows you to post, link, store, share and otherwise make available certain information, text, graphics, videos, or other material ("Content"). You are responsible for Content that you post on or through Service, including its legality, reliability, and appropriateness. By posting Content on or through Service, You represent and warrant that: * Content is yours (you own it) and/or you have the right to use it and the right to grant us the rights and license as provided in these Terms, and * That the posting of your Content on or through Service does not violate the privacy rights, publicity rights, copyrights, contract rights or any other rights of any person or entity. We reserve the right to terminate the account of anyone found to be infringing on a copyright. You retain any and all of your rights to any Content you submit, post or display on or through Service and you are responsible for protecting those rights. We take no responsibility and assume no liability for Content you or any third party posts on or through Service. Argonaut HQ, Inc has the right but not the obligation to monitor all Content provided by users. You may not distribute, modify, transmit, reuse, download, repost, copy, or use Content, whether in whole or in part, for commercial purposes or for personal gain, without express advance written permission from us. **Source Code Protection.** We do not access, read, or analyze your source code, repositories, or other proprietary code ("Source Code") that you provide to or process through the Service, except as strictly necessary to provide the Service to you (for example, to execute builds or run workflows you have configured). We do not share, sell, license, or disclose your Source Code to any third party. Your Source Code remains your sole property at all times, and we claim no rights, ownership, or interest in it. ## 9\. Prohibited Uses You may use Service only for lawful purposes and in accordance with Terms. You agree not to use Service: * In any way that violates any applicable national or international law or regulation. * For the purpose of exploiting, harming, or attempting to exploit or harm minors in any way by exposing them to inappropriate content or otherwise. * To transmit, or procure the sending of, any advertising or promotional material, including any "junk mail", "chain letter," "spam," or any other similar solicitation. * To impersonate or attempt to impersonate Company, a Company employee, another user, or any other person or entity. * In any way that infringes upon the rights of others, or in any way is illegal, threatening, fraudulent, or harmful, or in connection with any unlawful, illegal, fraudulent, or harmful purpose or activity. * To engage in any other conduct that restricts or inhibits anyone's use or enjoyment of Service, or which, as determined by us, may harm or offend Company or users of Service or expose them to liability. Additionally, you agree not to: * Use Service in any manner that could disable, overburden, damage, or impair Service or interfere with any other party's use of Service, including their ability to engage in real time activities through Service. * Use any robot, spider, or other automatic device, process, or means to access Service for any purpose, including monitoring or copying any of the material on Service. * Use any manual process to monitor or copy any of the material on Service or for any other unauthorized purpose without our prior written consent. * Use any device, software, or routine that interferes with the proper working of Service. * Introduce any viruses, trojan horses, worms, logic bombs, or other material which is malicious or technologically harmful. * Attempt to gain unauthorized access to, interfere with, damage, or disrupt any parts of Service, the server on which Service is stored, or any server, computer, or database connected to Service. * Attack Service via a denial-of-service attack or a distributed denial-of-service attack. * Take any action that may damage or falsify Company rating. * Attempt to interfere with the proper working of Service without proper explicit permission. * Use the Service to compete with Argonaut HQ, Inc. * Otherwise attempt to interfere with the proper working of Service. Violation of any of these Terms will result in fines and penalties as determined by Argonaut HQ, Inc in its sole discretion. ## 10\. Analytics We may use third-party Service Providers as subprocessors to provide analytics services. Within the logged-in application, this includes technologies that record how you interact with the Service (session replay), as described in our Privacy Policy. We do not use session replay on our public marketing websites, and analytics and advertising technologies on those websites load only after you opt in through our cookie banner; you can change or withdraw your choices at any time via the "Cookie settings" link in the website footer. The list of subprocessors is available at . ## 11\. No Use By Minors Service is intended only for access and use by individuals at least eighteen (18) years old. By accessing or using any of the Company, you warrant and represent that you are at least eighteen (18) years of age and with the full authority, right, and capacity to enter into this agreement and abide by all of the terms and conditions of Terms. If you are not at least eighteen (18) years old, you are prohibited from both the access and usage of Service. ## 12\. Accounts When you create an account with us, you guarantee that you are above the age of 18, and that the information you provide us is accurate, complete, and current at all times. Inaccurate, incomplete, or obsolete information may result in the immediate termination of your account on Service. You are responsible for maintaining the confidentiality of your account and password, including but not limited to the restriction of access to your computer and/or account. You agree to accept responsibility for any and all activities or actions that occur under your account and/or password, whether your password is with our Service or a third-party service. You must notify us immediately upon becoming aware of any breach of security or unauthorized use of your account. You may not use as a username the name of another person or entity or that is not lawfully available for use, a name or trademark that is subject to any rights of another person or entity other than you, without appropriate authorization. You may not use as a username any name that is offensive, vulgar or obscene. We reserve the right to refuse service, terminate accounts, remove or edit content, or cancel orders at our sole discretion. ## 13\. Intellectual Property Service and its original content (excluding Content provided by users), features and functionality are and will remain the exclusive property of Argonaut HQ, Inc and its licensors. Service is protected by copyright, trademark, and other laws of the United States and foreign countries. Our trademarks and trade dress may not be used in connection with any product or service without the prior written consent of Argonaut HQ, Inc. Unless you tell us otherwise in writing, you allow us to use your company name and logo on our websites and in our marketing materials. We use them only to show that you use the Service. We will follow any brand guidelines you give us. We will not say that you endorse the Service. You can withdraw this permission at any time by emailing [support@warpbuild.com](mailto:support@warpbuild.com). We will remove your name and logo from our websites within a reasonable time. If you have a separate written agreement with us on this topic, that agreement applies instead. ## 14\. Error Reporting and Feedback You may provide us either directly at [support@warpbuild.com](mailto:support@warpbuild.com) or via third party sites and tools with information and feedback concerning errors, suggestions for improvements, ideas, problems, complaints, and other matters related to our Service ("Feedback"). You acknowledge and agree that: * You shall not retain, acquire or assert any intellectual property right or other right, title or interest in or to the Feedback; * Company may have development ideas similar to the Feedback; * Feedback does not contain confidential information or proprietary information from you or any third party; and * Company is not under any obligation of confidentiality with respect to the Feedback. In the event the transfer of the ownership to the Feedback is not possible due to applicable mandatory laws, you grant Company and its affiliates an exclusive, transferable, irrevocable, free-of-charge, sub-licensable, unlimited and perpetual right to use (including copy, modify, create derivative works, publish, distribute and commercialize) Feedback in any manner and for any purpose. ## 15\. Links To Other Web Sites Our Service may contain links to third party web sites or services that are not owned or controlled by Argonaut HQ, Inc. Argonaut HQ, Inc has no control over, and assumes no responsibility for the content, privacy policies, or practices of any third party web sites or services. We do not warrant the offerings of any of these entities/individuals or their websites. YOU ACKNOWLEDGE AND AGREE THAT ARGONAUT HQ, INC SHALL NOT BE RESPONSIBLE OR LIABLE, DIRECTLY OR INDIRECTLY, FOR ANY DAMAGE OR LOSS CAUSED OR ALLEGED TO BE CAUSED BY OR IN CONNECTION WITH USE OF OR RELIANCE ON ANY SUCH CONTENT, GOODS OR SERVICES AVAILABLE ON OR THROUGH ANY SUCH THIRD PARTY WEB SITES OR SERVICES. WE STRONGLY ADVISE YOU TO READ THE TERMS OF SERVICE AND PRIVACY POLICIES OF ANY THIRD PARTY WEB SITES OR SERVICES THAT YOU VISIT. ## 16\. Disclaimer Of Warranty THESE SERVICES ARE PROVIDED BY COMPANY ON AN "AS IS" AND "AS AVAILABLE" BASIS. COMPANY MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED, AS TO THE OPERATION OF THEIR SERVICES, OR THE INFORMATION, CONTENT OR MATERIALS INCLUDED THEREIN. YOU EXPRESSLY AGREE THAT YOUR USE OF THESE SERVICES, THEIR CONTENT, AND ANY SERVICES OR ITEMS OBTAINED FROM US IS AT YOUR SOLE RISK. NEITHER COMPANY NOR ANY PERSON ASSOCIATED WITH COMPANY MAKES ANY WARRANTY OR REPRESENTATION WITH RESPECT TO THE COMPLETENESS, SECURITY, RELIABILITY, QUALITY, ACCURACY, OR AVAILABILITY OF THE SERVICES. WITHOUT LIMITING THE FOREGOING, NEITHER COMPANY NOR ANYONE ASSOCIATED WITH COMPANY REPRESENTS OR WARRANTS THAT THE SERVICES, THEIR CONTENT, OR ANY SERVICES OR ITEMS OBTAINED THROUGH THE SERVICES WILL BE ACCURATE, RELIABLE, ERROR-FREE, OR UNINTERRUPTED, THAT DEFECTS WILL BE CORRECTED, THAT THE SERVICES OR THE SERVER THAT MAKES IT AVAILABLE ARE FREE OF VIRUSES OR OTHER HARMFUL COMPONENTS OR THAT THE SERVICES OR ANY SERVICES OR ITEMS OBTAINED THROUGH THE SERVICES WILL OTHERWISE MEET YOUR NEEDS OR EXPECTATIONS. COMPANY HEREBY DISCLAIMS ALL WARRANTIES OF ANY KIND, WHETHER EXPRESS OR IMPLIED, STATUTORY, OR OTHERWISE, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, NON-INFRINGEMENT, AND FITNESS FOR PARTICULAR PURPOSE. THE FOREGOING DOES NOT AFFECT ANY WARRANTIES WHICH CANNOT BE EXCLUDED OR LIMITED UNDER APPLICABLE LAW. ## 17\. Limitation Of Liability EXCEPT AS PROHIBITED BY LAW, YOU WILL HOLD US AND OUR OFFICERS, DIRECTORS, EMPLOYEES, AND AGENTS NOT LIABLE FOR ANY INDIRECT, PUNITIVE, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGE, HOWEVER IT ARISES IN CONNECTION WITH THIS AGREEMENT. EXCEPT AS PROHIBITED BY LAW, IF THERE IS LIABILITY FOUND ON THE PART OF COMPANY, IT WILL BE LIMITED TO THE AGGREGATED AMOUNT PAID FOR THE PRODUCTS AND/OR SERVICES IN THE PREVIOUS 12 MONTHS OF THE SPECIFIC CLAIM ARISING, LESS COSTS AND EXPENSES TO PROVIDE THE PRODUCTS AND/OR SERVICES, AND UNDER NO CIRCUMSTANCES WILL THERE BE CONSEQUENTIAL OR PUNITIVE DAMAGES. SOME STATES DO NOT ALLOW THE EXCLUSION OR LIMITATION OF PUNITIVE, INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO THE PRIOR LIMITATION OR EXCLUSION MAY NOT APPLY TO YOU. ## 18\. Termination We may terminate or suspend your account and bar access to Service immediately, without prior notice or liability, under our sole discretion, for any reason whatsoever and without limitation, including but not limited to a breach of Terms. If you wish to terminate your account, you may simply discontinue using Service. All provisions of Terms which by their nature should survive termination shall survive termination, including, without limitation, ownership provisions, warranty disclaimers, indemnity and limitations of liability. ## 19\. Governing Law These Terms shall be governed and construed in accordance with the laws of State of Delaware without regard to its conflict of law provisions. Our failure to enforce any right or provision of these Terms will not be considered a waiver of those rights. If any provision of these Terms is held to be invalid or unenforceable by a court, the remaining provisions of these Terms will remain in effect. These Terms constitute the entire agreement between us regarding our Service and supersede and replace any prior agreements we might have had between us regarding Service. ## 20\. Changes To Service We reserve the right to withdraw or amend our Service, and any service or material we provide via Service, in our sole discretion without notice. We will not be liable if for any reason all or any part of Service is unavailable at any time or for any period. From time to time, we may restrict access to some parts of Service, or the entire Service, to users, including registered users. ## 21\. Amendments To Terms We may amend Terms at any time by posting the amended terms on this site. It is your responsibility to review these Terms periodically. Your continued use of the Platform following the posting of revised Terms means that you accept and agree to the changes. You are expected to check this page frequently so you are aware of any changes, as they are binding on you. By continuing to access or use our Service after any revisions become effective, you agree to be bound by the revised terms. If you do not agree to the new terms, you are no longer authorized to use Service. ## 22\. Waiver And Severability No waiver by Company of any term or condition set forth in Terms shall be deemed a further or continuing waiver of such term or condition or a waiver of any other term or condition, and any failure of Company to assert a right or provision under Terms shall not constitute a waiver of such right or provision. If any provision of Terms is held by a court or other tribunal of competent jurisdiction to be invalid, illegal or unenforceable for any reason, such provision shall be eliminated or limited to the minimum extent such that the remaining provisions of Terms will continue in full force and effect. ## 23\. Acknowledgement BY USING SERVICE OR OTHER SERVICES PROVIDED BY US, YOU ACKNOWLEDGE THAT YOU HAVE READ THESE TERMS OF SERVICE AND AGREE TO BE BOUND BY THEM. ## 24\. Contact Us Please send your feedback, comments, requests for technical support: By email: [support@warpbuild.com](mailto:support@warpbuild.com). --- # Migration Guides | WarpBuild URL: https://www.warpbuild.com/migrate Description: Migration guides to WarpBuild runners: before and after workflow diffs, cache migration, and same-day rollback. # Migration Guides Step by step migrations to WarpBuild, with workflow diffs, cache notes, and rollback plans. * [Migrate from ARC to WarpBuildMove GitHub Actions jobs off Actions Runner Controller. Map scale-set values to WarpBuild runners, change runs-on, run both in parallel, then decommission.](/migrate/arc-to-warpbuild) * [Migrate from Blacksmith to WarpBuildMove GitHub Actions workflows from Blacksmith runners to WarpBuild: the runs-on label map, cache migration, a compatibility table, parallel runs, and rollback.](/migrate/blacksmith-to-warpbuild) * [Migrate from Depot to WarpBuildMove GitHub Actions jobs from Depot runners to WarpBuild: label mapping, cache changes, compatibility table, parallel run plan, billing overlap, and rollback.](/migrate/depot-to-warpbuild) * [Migrate From GitHub-Hosted Runners to WarpBuildMove GitHub Actions jobs off GitHub-hosted runners with a runs-on label change, a cache swap, a parallel-run week, and a same-day rollback path.](/migrate/github-hosted-to-warpbuild) * [Migrate from Namespace to WarpBuildMove GitHub Actions workflows off Namespace: label mapping, cache equivalents, a full workflow example, parallel running, billing notes and same day rollback.](/migrate/namespace-to-warpbuild) --- # Migrate from ARC to WarpBuild | WarpBuild URL: https://www.warpbuild.com/migrate/arc-to-warpbuild Description: Move GitHub Actions jobs off Actions Runner Controller. Map scale-set values to WarpBuild runners, change runs-on, run both in parallel, then decommission. # Migrate from ARC to WarpBuild Move GitHub Actions jobs off Actions Runner Controller. Map scale-set values to WarpBuild runners, change runs-on, run both in parallel, then decommission. Last updated: 2026-08-13 Moving GitHub Actions jobs from [Actions Runner Controller](https://github.com/actions/actions-runner-controller) to WarpBuild is two pieces of work with very different sizes. The workflow change is one line per job. The cluster change is a decommissioning project you can take at your own pace, because ARC and WarpBuild runners coexist without conflict. This page maps the `gha-runner-scale-set` configuration surface to WarpBuild runner configuration, shows the `runs-on` diff and a full workflow, covers caching, the parallel run, rollback, and the teardown order for the cluster deployment. ## [Prerequisites](#prerequisites)Copy Anchor Link * **A WarpBuild account and the GitHub App installed.** Sign up at [app.warpbuild.com](https://app.warpbuild.com/), then install the WarpBuild GitHub bot from the dashboard and grant access to the repositories you plan to move. The bot cannot be installed from the GitHub Marketplace directly ([quick start](https://www.warpbuild.com/docs/ci/quick-start)). * **Organization owner access on GitHub**, the same level ARC needed. WarpBuild registers its runners in your organization's `Default` runner group, id 1\. If your workflows run in public repositories, check "Allow public repositories" on that group ([public repos](https://www.warpbuild.com/docs/ci/public-repos)). If your organization uses a non-default runner group, confirm the group selected in WarpBuild has access to the repository ([common issues](https://www.warpbuild.com/docs/ci/common-issues)). * **Your ARC `values.yaml` files.** You need the scale-set values for the mapping table below, and the controller values if you customized flags or the image repository. * **A cloud account, only if you want BYOC.** For runners inside your own AWS, GCP, or Azure account, connect the cloud account, create a stack that pins region, VPC, and object storage, and define a custom runner ([BYOC docs](https://www.warpbuild.com/docs/ci/byoc)). Skip this if WarpBuild-hosted runners are acceptable. * **GitHub Enterprise Server or GitHub Enterprise Cloud with data residency** needs the enterprise app flow instead of the default install, which is available on the Enterprise plan ([GHE setup](https://www.warpbuild.com/docs/ci/ghe)). TODO(verification): no published figure exists in the repo for expected time to first green build. Add one once a measured number is available. ## [Map Your Scale Set Configuration](#map-your-scale-set-configuration)Copy Anchor Link Read the left column out of your `values.yaml`. Chart keys and their documented meanings come from the [gha-runner-scale-set chart](https://github.com/actions/actions-runner-controller/blob/master/charts/gha-runner-scale-set/values.yaml) and the [gha-runner-scale-set-controller chart](https://github.com/actions/actions-runner-controller/blob/master/charts/gha-runner-scale-set-controller/values.yaml) at chart version 0.14.2, read on 2026-08-13. | ARC value | What it does on ARC | WarpBuild equivalent | | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | githubConfigUrl | Repository, organization, or enterprise scope for the runners | The repositories you grant the WarpBuild GitHub App at install time. Enterprise estates use the [GHE app flow](https://www.warpbuild.com/docs/ci/ghe). | | githubConfigSecret | PAT or GitHub App credentials stored as a Kubernetes secret in the scale-set namespace | None. Authentication is the WarpBuild GitHub App installation, so there is no in-cluster credential to store or rotate. | | runnerScaleSetName | The name a workflow targets in runs-on | The runner label. Hosted runners use the published warp- labels; BYOC custom runners use the Runner ID, which is the runner name prefixed with warp-custom-. | | scaleSetLabels, rendered as the CRD field runnerScaleSetLabels | Extra labels for multi-label runs-on targeting | A single runner label per runner type, plus dynamic labels for per-job features: snapshot.enabled=true, nested-virtualization.enabled=true, network.name=. | | runnerGroup | Which organizations and repositories can reach the scale set | The GitHub runner group WarpBuild registers into, default group id 1, selectable in the WarpBuild dashboard under Runners. | | maxRunners | Concurrency ceiling, static Helm value | Generally available Linux and Windows runners have no plan-level concurrency cap to port. Review macOS and beta-runner quotas separately. | | minRunners | Idle runner pods held warm on your nodes | Nothing to configure on hosted runners. On BYOC, [standby disks](https://www.warpbuild.com/docs/ci/byoc/standby-disks) hold a pre-initialized pool per custom runner and boot a job in about 15 seconds. | | containerMode.type (dind, kubernetes, kubernetes-novolume) | Whether container jobs, service containers, and container actions work, and how | Not applicable. Runners are full VMs, so container jobs and service containers work the way they do on GitHub-hosted runners. Docker layer caching and [remote Docker builders](https://www.warpbuild.com/docs/ci/docker-builders) replace the dind sidecar for image builds. | | containerMode.kubernetesModeWorkVolumeClaim | PVC shape for Kubernetes mode, needs a dynamic provisioner | Not applicable. Each runner gets its own ephemeral encrypted volume, and BYOC instance types with local NVMe get [local SSD auto-mount](https://www.warpbuild.com/docs/ci/features/local-ssd). | | template (runner PodSpec: resources, image, securityContext, nodeSelector, volumes) | The machine shape | The runner label picks the shape: 2x through 32x sizes with fixed vCPU, memory, and SSD per label ([cloud runners](https://www.warpbuild.com/docs/ci/cloud-runners)). BYOC custom runners set instance types, fallback instance types, disks, spot, and static IPs. | | template.spec.containers\[name: runner\].image | Your custom runner image, which you build and keep valid | WarpBuild maintains the hosted images and publishes changes in the [docs changelog](https://www.warpbuild.com/docs/ci/changelog). BYOC can use [custom VM images](https://www.warpbuild.com/docs/ci/byoc/custom-vm-images) subject to the documented AMI requirements. | | listenerTemplate, listenerMetrics | Listener pod shape and the metrics you enable and scrape | Not applicable. [Observability](https://www.warpbuild.com/docs/ci/features/observability) ships with per-instance metrics and logs and right-sizing recommendations by repository, workflow, job, and instance type. | | proxy.http.url, proxy.https.url, proxy.noProxy | Outbound proxy for controller, listener, and runners | BYOC places runners in your VPC with your egress path, with static IPs available. For private service access from hosted runners, the [Tailscale network addon](https://www.warpbuild.com/docs/ci/features/networking) joins the runner to your tailnet for the duration of the job. | | githubServerTLS.certificateFrom | Custom CA injected into the runner trust store | TODO(verification): no repo doc covers injecting a custom CA into WarpBuild runner trust stores. For GHES estates, the documented path is the enterprise app flow plus allowlisting the [control plane egress IPs](https://www.warpbuild.com/docs/ci/ghe#control-plane-egress-ips). | | keyVault | Azure Key Vault credential retrieval, public preview | Not applicable, since there is no GitHub credential in your infrastructure to fetch. | | replicaCount, flags.\*, watchSingleNamespace, runnerMaxConcurrentReconciles, rateLimiter | Controller deployment shape and throughput tuning | Not applicable. There is no controller in your infrastructure. | ## [Change the runs-on Label](#change-the-runs-on-label)Copy Anchor Link An ARC workflow targets the scale set by its Helm installation name. The quickstart installs `arc-runner-set`, so workflows read: Copy Text ``` jobs: build: runs-on: arc-runner-set ``` Multi-label targeting looks like this when `runnerScaleSetLabels` is set to `[linux, gpu, private-network]`: Copy Text ``` jobs: build: runs-on: [linux, gpu, private-network] ``` Both forms are documented in [use ARC in a workflow](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/use-arc-in-a-workflow). The WarpBuild replacement is a single label: Copy Text ``` jobs: build: - runs-on: arc-runner-set + runs-on: warp-ubuntu-latest-x64-8x ``` For BYOC, the label is the Runner ID from the custom runners page, including the required prefix: Copy Text ``` jobs: build: - runs-on: [linux, private-network] + runs-on: warp-custom-ci-stack-runner ``` Size mapping is direct: an ARC PodSpec requesting 8 CPU and 32 GB maps to `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB, 150 GB SSD). ARM64 jobs map to the `warp-ubuntu-latest-arm64-*` family, and the full label list with vCPU, memory, storage, and per-minute price is in the [cloud runners docs](https://www.warpbuild.com/docs/ci/cloud-runners). ### [Full workflow example](#full-workflow-example)Copy Anchor Link .github/workflows/ci.yml Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore dependency cache uses: WarpBuilds/cache@v1 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - run: npm ci - run: npm test env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres build-arm: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - run: make build ``` The service container block is unchanged from the GitHub-hosted form. On ARC the same block requires `containerMode` set to `dind` or `kubernetes`, and Kubernetes mode has an open issue about service containers ([issue 1768](https://github.com/actions/actions-runner-controller/issues/1768), open since 2022-09-01). ## [Cache Migration](#cache-migration)Copy Anchor Link **What you have on ARC.** ARC ships no dependency cache of its own. Dependency caching for ephemeral runners has been an [open request since 2023-07-07](https://github.com/actions/actions-runner-controller/issues/2726), and image caching is on GitHub's [out-of-scope list](https://docs.github.com/en/actions/concepts/runners/support-for-arc) for ARC support. In practice ARC teams either use `actions/cache` against the GitHub Actions cache service and accept its quota and locality, or self-host a cache: an S3 or MinIO bucket behind a cache action, a registry mirror for base images, or a persistent volume the runner pods reuse in Kubernetes mode. **What replaces it.** WarpBuild runners include a cache that is enabled by default on Linux runners. Swap the action and keep the same key and path arguments: Copy Text ``` - - uses: actions/cache@v4 + - uses: WarpBuilds/cache@v1 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} ``` `WarpBuilds/cache` is a drop-in replacement for `actions/cache@v4` and supports the split restore and save forms as well ([caching docs](https://www.warpbuild.com/docs/ci/features/caching)). Container layer caching is available on hosted runners and on all three BYOC clouds, and [remote Docker builders](https://www.warpbuild.com/docs/ci/docker-builders) provide dedicated build VMs with a persistent layer cache for image-heavy pipelines. **What to expect on the first runs.** Cache entries do not transfer between platforms. The first run of each workflow after the switch writes a fresh cache, so compare durations from the second run onward. If you self-hosted a cache for ARC, keep it reachable until the moved workflows stop referencing it, then remove it during decommissioning. **One action to leave alone.** Snapshot runners capture a runner VM mid-workflow and boot later jobs from it, which is a different lever from dependency caching. Add snapshots after the migration is stable rather than during it ([snapshot runners](https://www.warpbuild.com/docs/ci/features/snapshot-runners)). ## [Run ARC and WarpBuild Side by Side](#run-arc-and-warpbuild-side-by-side)Copy Anchor Link Both platforms register runners with the same GitHub Actions API, and jobs go wherever the `runs-on` label points. There is no conflict, no shared state, and no ordering requirement. 1. **Start with one noisy workflow.** Pick a workflow that runs often and fails visibly, so signal arrives in hours rather than weeks. 2. **Move it on a branch, then merge.** One `runs-on` change plus the cache action swap. 3. **Compare the second run onward.** Job duration, queue time, and failure rate. First runs write a cold cache, so exclude them. 4. **Move in batches by runner size.** Every job that requested the same PodSpec resources on ARC moves to the same WarpBuild label together. 5. **Leave the ARC scale sets installed and idle.** With `minRunners` at zero, an idle scale set consumes no runner pods while remaining ready for a rollback. 6. **Track spend on both sides.** Your cluster keeps its cost while it stays warm. WarpBuild bills per minute by runner type with no base subscription fee, no platform fee, and no seat fee, and signup includes $10 in free credits, which usually covers the validation batches. Rates are on the [pricing page](/pricing). Secrets and permissions do not change. Repository and organization secrets, OIDC federation, and `GITHUB_TOKEN` permissions behave as they do on any self-hosted runner, because the runner still registers against your organization. What disappears is the Kubernetes secret that held your PAT or GitHub App private key. ## [Rollback](#rollback)Copy Anchor Link Rollback is a revert of the `runs-on` line, valid as long as the ARC deployment is still installed. That is the reason the decommissioning section comes last. 1. Revert the commit that changed `runs-on`, or change the label back by hand. 2. If you drained the scale set already, restore `maxRunners` and `minRunners` to their previous values and reapply the Helm release. 3. Leave the cache action swap in place or revert it; either works, since a cold cache is the only cost. Keep the cluster warm for at least one full release cycle after the last workflow moves, including whatever periodic and scheduled workflows only run weekly or monthly. Those are the jobs that discover a gap after everything else looks green. ## [Decommission the ARC Deployment](#decommission-the-arc-deployment)Copy Anchor Link Do this after the full workflow set has run green on WarpBuild, including scheduled workflows. The order below follows GitHub's documented teardown sequence, which is published as the [upgrade procedure](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets#upgrading-arc) and applies equally to removal. **1\. Drain the queue.** Set `maxRunners: 0` and `minRunners: 0` on each scale set. GitHub documents this state: "If you set both properties to `0`, Actions Runner Controller will not create new runner pods when new jobs are available and assigned." Confirm no jobs are still routing to the scale set before continuing. **2\. Uninstall every scale-set release.** `helm uninstall` each `gha-runner-scale-set` installation, one per scale set, and wait for resource cleanup. Watch for pods stuck in `Terminating`, which is a documented pattern in [issue 3010](https://github.com/actions/actions-runner-controller/issues/3010) and [issue 4155](https://github.com/actions/actions-runner-controller/issues/4155); the repository [troubleshooting guide](https://github.com/actions/actions-runner-controller/blob/master/TROUBLESHOOTING.md) documents finalizer removal for the legacy equivalent. **3\. Uninstall the controller.** Remove the `gha-runner-scale-set-controller` release only after the scale sets are gone. **4\. Remove the CRDs.** Delete the CRDs in the `actions.github.com` API group, and, if legacy ARC was ever installed in this cluster, the `actions.summerwind.net` group as well. Helm does not remove CRDs for you. **5\. Delete the leftovers the charts do not own.** Each installation generates resources named after the installation with fixed suffixes, documented in the [ARC components reference](https://docs.github.com/en/actions/concepts/runners/actions-runner-controller#actions-runner-controller-components): | Leftover | Where it came from | | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | INSTALLATION\_NAME-gha-rs-github-secret | Created when githubConfigSecret was an object. If you passed a pre-defined secret name as a string, that secret was created by you and is still yours to delete. | | \-gha-rs-manager, \-gha-rs-kube-mode, \-gha-rs-no-permission | Roles, RoleBindings, and ServiceAccounts created by the scale-set chart | | \-gha-rs-controller, \-gha-rs-controller-listener, \-gha-rs-controller-single-namespace | Controller ServiceAccount, ClusterRole, ClusterRoleBinding, and the namespace-scoped Roles created when flags.watchSingleNamespace was set | | Proxy credential secret referenced by credentialSecretRef | Created by you for authenticated outbound proxying | | Custom CA ConfigMap referenced by githubServerTLS.certificateFrom | Created by you for enterprise TLS | | Namespaces created for ARC | Runner namespaces, the controller namespace, and any per-organization namespaces created for isolation | **6\. Revoke the GitHub credentials.** Delete the GitHub App installation ARC used, or revoke the classic PAT. Enterprise-level ARC runners required a classic PAT, so check for one at the enterprise level as well ([auth docs](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/authenticate-to-the-api)). **7\. Remove the webhook, only if you ran legacy ARC.** Webhook-driven scaling is a legacy-mode feature that installs a separate webhook server through `githubWebhookServer.enabled=true`, optionally exposed by a NodePort or an ingress ([legacy autoscaling docs](https://github.com/actions/actions-runner-controller/blob/master/docs/automatically-scaling-runners.md)). Remove that server, its ingress, and the matching GitHub organization or repository webhook. The modern scale-set mode uses an outbound long-poll listener and registers no inbound webhook, so a modern deployment has nothing to remove here. cert-manager is likewise a legacy-mode dependency; the modern charts do not require it. **8\. Reclaim the cluster.** Delete the node groups or node pools that existed only for runner pods, the storage class and persistent volumes provisioned for Kubernetes mode, the private registry mirror of the controller and runner images, and the log and metric pipelines that only carried ARC telemetry. This step is where the cluster bill actually falls. **9\. Clean the GitHub side.** Remove empty runner groups that existed only for ARC scale sets, and delete offline self-hosted runner registrations left over from stuck pods. ## [Compatibility](#compatibility)Copy Anchor Link | ARC feature or requirement | WarpBuild equivalent | Status | | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | Ephemeral runners, one job per machine | Every runner is an ephemeral VM, created on demand and destroyed after the job ([security](https://www.warpbuild.com/docs/ci/security)) | Supported | | Autoscaling on queue depth | Dynamic capacity with no plan-level concurrency caps for generally available Linux and Windows runners | Supported | | Linux x64 and Linux ARM64 | Ubuntu 22.04, 24.04, and 26.04 on x64; Ubuntu 24.04 and 26.04 on ARM64, in five sizes each | Supported | | Windows runners | Windows Server 2022 and 2025 in four sizes. ARC publishes no Windows image for scale-set mode; the only Windows doc is legacy mode ([issue 1001](https://github.com/actions/actions-runner-controller/issues/1001) open since 2021-12-10) | Supported, with no ARC counterpart | | macOS runners | macOS 14, 15, and 26 in 6 vCPU and 12 vCPU sizes. ARC documentation does not cover macOS runners, verified on 2026-08-13 | Supported on WarpBuild's cloud, absent on BYOC | | Container jobs and service containers | Work as they do on GitHub-hosted runners, with no containerMode to configure | Supported | | Docker-in-Docker with privileged containers | Docker runs on Linux runners directly; remote Docker builders handle image builds with a persistent layer cache | Supported by a different mechanism | | Kubernetes mode with PVCs | No persistent volume provisioner to run. Each runner has its own ephemeral encrypted volume, with local NVMe auto-mount on suitable BYOC instance types | Not applicable | | Full PodSpec control (nodeSelector, tolerations, topology spread, priority classes) | Runner size labels on hosted runners; instance types, fallback instance types, disks, spot, and static IPs on BYOC custom runners | Partial: cluster-scheduling primitives have no equivalent | | Runners inside your own network | BYOC creates VMs in your VPC and region, with static IPs available; the Tailscale addon joins hosted runners to your tailnet per job | Supported | | No third-party control plane in the build path | WarpBuild operates a control plane, SOC 2 Type 2 certified with evidence at [trust.warpbuild.com](https://trust.warpbuild.com/) | Unsupported: if policy forbids a vendor control plane, stay on ARC | | Custom runner images | Custom VM images on BYOC, subject to documented AMI requirements. Hosted runner images are maintained by WarpBuild | Partial | | Custom CA injection into the runner trust store | TODO(verification): no repo doc covers this. GHES estates use the enterprise app flow and control plane egress IP allowlisting | Unverified | | Runner groups for repository access | Runners register into your GitHub runner group, default id 1, selectable in the dashboard | Supported | | GitHub Enterprise Server | GHES and GitHub Enterprise Cloud with data residency are supported on the Enterprise plan through the enterprise app flow. ARC requires GHES 3.9 or greater | Supported | | Scheduled scaling of warm capacity | Standby disks hold a warm pool per BYOC custom runner. ARC has no scheduled scaling ([issue 3313](https://github.com/actions/actions-runner-controller/issues/3313)) | Partial | | Metrics and logs | Observability with per-instance metrics, logs, and right-sizing recommendations, on by default | Supported | Questions about a row in this table, or a cluster shape not covered here? Slack support channels are available on demand, and documents the dimension-by-dimension comparison behind these choices. If ARC and WarpBuild are not the only two options on your list, [managed alternatives to ARC](/alternatives/arc) covers the wider field. ## FAQ ### How much of my workflow file changes? The runs-on line, and the cache action if you use one. ARC targets a runner scale set by its Helm installation name or by runnerScaleSetLabels; WarpBuild runners register under warp- labels for hosted runners and warp-custom- labels for BYOC runners. Steps, matrices, services, and secrets are unchanged. ### Do I have to delete my ARC deployment before I start? No, and you should not. Keep the controller and scale sets installed through the parallel-run period so a rollback is a single revert of the runs-on line. Decommission only after the full workflow set has run green on WarpBuild. ### What happens to my existing caches? Caches do not transfer. ARC deployments usually rely on the GitHub Actions cache service or on a cache the platform team self-hosts, since dependency caching for ephemeral runners is still an open request in the ARC repository. On WarpBuild, the first runs after the switch write a fresh cache and later runs restore from it. ### Is there a webhook to remove when I decommission? Only if you ran legacy ARC. Webhook-driven scaling is a legacy-mode feature that installs a separate webhook server through githubWebhookServer.enabled. The modern scale-set mode uses an outbound long-poll listener and registers no inbound webhook, so there is nothing to remove. ### Can WarpBuild runners stay inside my own cloud account? Yes. BYOC runs on AWS, GCP, and Azure, with runner VMs created in your account and region, and a Terraform provider for BYOC on AWS. macOS runners are available on WarpBuild's cloud rather than on BYOC. ### What does the parallel-run period cost? Your cluster keeps costing whatever it costs while it stays warm, and WarpBuild bills the moved jobs per minute by runner type. Signup includes $10 in free credits, which usually covers the validation batches. --- # Migrate from Blacksmith to WarpBuild | WarpBuild URL: https://www.warpbuild.com/migrate/blacksmith-to-warpbuild Description: Move GitHub Actions workflows from Blacksmith runners to WarpBuild: the runs-on label map, cache migration, a compatibility table, parallel runs, and rollback. # Migrate from Blacksmith to WarpBuild Move GitHub Actions workflows from Blacksmith runners to WarpBuild: the runs-on label map, cache migration, a compatibility table, parallel runs, and rollback. Last updated: 2026-08-13 Moving GitHub Actions workflows from Blacksmith to WarpBuild is a `runs-on` label change plus a replacement for any `useblacksmith/*` action steps in the file. Nothing else in the workflow changes: the same steps run, the same secrets resolve, and the same GitHub events trigger the jobs. This page carries the label map, a full workflow example, what happens to caches, a compatibility table with the unsupported rows stated plainly, guidance for running both platforms side by side, and the rollback steps. Every Blacksmith fact links to the page that states it, checked on 2026-08-13. Still weighing the decision? [WarpBuild vs Blacksmith](/compare/blacksmith) covers the two platforms dimension by dimension, and [Blacksmith alternatives](/alternatives/blacksmith) covers the wider option set. ## [Prerequisites](#prerequisites)Copy Anchor Link * **A GitHub organization.** WarpBuild registers runners in the `Default` runner group (id `1`) of your GitHub organization, per the [public repos doc](/docs/ci/public-repos). Blacksmith carries the same constraint from the other side: their docs state that Blacksmith is limited to GitHub organizations and is not available for personal repositories. * **Organization owner access**, to install the WarpBuild GitHub app and, for public repositories, to check `Allow public repositories` on runner group 1. * **A WarpBuild account.** Sign up at [app.warpbuild.com](https://app.warpbuild.com/), then install the GitHub app from the WarpBuild dashboard. The app cannot be installed from the GitHub marketplace directly, per the [quick start](/docs/ci/quick-start). * **An inventory of vendor-specific steps.** Search your workflows for `useblacksmith/` and for `blacksmith-` labels before you start. The count of the first tells you how much work this is; the count of the second tells you how many lines change. * **Your GitHub organization IP allowlist, if you use one.** Their [network allowlisting doc](https://docs.blacksmith.sh/blacksmith-administration/network-allowlisting) publishes the control-plane addresses their platform needs. Leave those entries in place until the parallel run ends. * **Any third-party allowlists pointing at a Blacksmith static IP**, if you bought that add-on, need re-pointing before the last workflow moves. Secrets and permissions need no migration. Workflow secrets stay in GitHub. The only new secret is `WARPBUILD_API_KEY`, and only if you drive WarpBuild [Docker builders](/docs/ci/docker-builders) from runners that are not WarpBuild runners. ## [The Label Change](#the-label-change)Copy Anchor Link The most common Blacksmith label is `blacksmith-2vcpu-ubuntu-2404`, which is what their quickstart maps `ubuntu-latest` to and what their Migration Wizard produces by default. The diff for that job is two lines: Copy Text ``` jobs: test: - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 ``` The full label map, from their published [runner list](https://docs.blacksmith.sh/blacksmith-runners/overview) to the [WarpBuild runner catalog](/docs/ci/cloud-runners). `` is one of 2, 4, 8, 16, 32 unless a note says otherwise. | Blacksmith label | WarpBuild label | Note | | ---------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | blacksmith-vcpu-ubuntu-2404 | warp-ubuntu-latest-x64-x | Same vCPU and RAM at every size. WarpBuild ships 150 GB SSD on all Linux sizes. | | blacksmith-vcpu-ubuntu-2204 | warp-ubuntu-2204-x64-x | Ubuntu 22.04 x64 is published on both sides. | | blacksmith-vcpu-ubuntu-2404-arm | warp-ubuntu-latest-arm64-x | RAM differs at the small sizes. Ubuntu 24.04 ARM64 runners set the work dir to /runner/\_work. | | blacksmith-vcpu-ubuntu-2204-arm | warp-ubuntu-latest-arm64-x | WarpBuild deprecated Ubuntu 22.04 ARM64 images on March 31, 2025, so ARM jobs land on 24.04. | | blacksmith-2vcpu-windows-2025 | warp-windows-2025-x64-4x | WarpBuild Windows starts at 4 vCPU; the 2 vCPU Windows shape was removed on June 8, 2026. | | blacksmith-vcpu-windows-2025 | warp-windows-2025-x64-x | For of 4, 8, 16, 32\. WarpBuild also publishes Windows Server 2022 labels and a vs2026 variant. | | blacksmith-6vcpu-macos-latest | warp-macos-26-arm64-6x | Their macos-latest currently resolves to macOS 26\. The WarpBuild warp-macos-latest-arm64-6x alias points at macOS 15, so name the version explicitly during the move. | | blacksmith-12vcpu-macos-latest | warp-macos-26-arm64-12x | Same version caveat as above. | | blacksmith-6vcpu-macos-26 | warp-macos-26-arm64-6x | The WarpBuild macOS 26 image ships Xcode 27.0 with iOS, tvOS, watchOS, and visionOS simulator runtimes. | | blacksmith-12vcpu-macos-26 | warp-macos-26-arm64-12x | 12 vCPU, 28 GB RAM, 270 GB SSD. | | blacksmith-6vcpu-macos-15 | warp-macos-15-arm64-6x | WarpBuild also publishes a macOS 14 image at 6 vCPU. | | blacksmith-12vcpu-macos-15 | warp-macos-15-arm64-12x | Multiple sizes and configurations are available per chip. | Sizes are matched on vCPU count. Check RAM and disk against the [runner catalog](/docs/ci/cloud-runners) for memory-bound jobs before you assume a like-for-like swap. ## [A Full Workflow Example](#a-full-workflow-example)Copy Anchor Link Before, on Blacksmith, with their Docker builder, their checkout action, and the transparent dependency cache their docs describe: Copy Text ``` name: build on: push: branches: [main] jobs: build: runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: useblacksmith/checkout@v1 - uses: actions/setup-node@v4 with: node-version: 22 - uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ hashFiles('**/package-lock.json') }} restore-keys: npm- - run: npm ci - run: npm test - uses: useblacksmith/setup-docker-builder@v2 with: cache-key: web-app - uses: useblacksmith/build-push-action@v2 with: context: . push: true tags: ghcr.io/acme/web:${{ github.sha }} ``` After, on WarpBuild: Copy Text ``` name: build on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - uses: WarpBuilds/cache@v1 with: path: ~/.npm key: npm-${{ hashFiles('**/package-lock.json') }} restore-keys: npm- - run: npm ci - run: npm test - uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/acme/web:${{ github.sha }} profile-name: web-app-builder ``` Four changes: the label, the checkout action back to upstream, the cache action to the WarpBuild drop-in, and one Docker step instead of two. The `profile-name` value is a builder profile you create once on the [Docker builders page](https://app.warpbuild.com/ci/docker-builders). ## [Cache Migration](#cache-migration)Copy Anchor Link Blacksmith's caching surface is broad, and the migration work is proportional to how much of it a repository uses. Their features, what their docs say happens, and the WarpBuild path: | Blacksmith feature | What their docs publish | WarpBuild path | | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Dependency cache](https://docs.blacksmith.sh/blacksmith-caching/dependencies-actions) | Transparent interception of actions/cache and setup-\* actions, zero code changes, 25 GB per repository per week free, entries evicted after 7 days without access | Swap actions/cache@v4 for [WarpBuilds/cache@v1](/docs/ci/features/caching), a drop-in with the same key, path, and restore-keys inputs. Cache is enabled by default on WarpBuild runners. | | [Sticky disks](https://docs.blacksmith.sh/blacksmith-caching/dependencies-sticky-disks) | Persistent ext4 volumes mounted by key, up to 5 per job, auto-evicted after 7 days of inactivity | No direct equivalent. Remove the useblacksmith/stickydisk and stickydisk-delete steps. For keyed paths use the cache action; for whole-VM state use [snapshot runners](/docs/ci/features/snapshot-runners). | | [Docker layer caching](https://docs.blacksmith.sh/blacksmith-caching/docker-builds) | setup-docker-builder plus their build-push-action with a required cache-key. Their docs instruct users to delete cache-from and cache-to because layer state lives on a persistent builder disk | [Remote Docker builders](/docs/ci/docker-builders) hold the layer cache on a dedicated builder VM. Use Warpbuilds/build-push-action@v6 with profile-name. cache-from and cache-to stay unnecessary. | | [Docker container caching](https://docs.blacksmith.sh/blacksmith-caching/docker-container-caching) | Keeps pulled images warm across runs, one cache disk per organization per region and architecture, enabled by default | No published equivalent. There is no YAML to remove, and first-run image pull time returns. | | [Bazel build caching](https://docs.blacksmith.sh/blacksmith-caching/bazel-build-caching) | Transparent, with no .bazelrc changes and no workflow configuration | No published equivalent. Teams relying on it need their own remote cache configured, or they accept cold Bazel builds. Weigh this before committing to the move. | | [Git checkout caching](https://docs.blacksmith.sh/blacksmith-caching/git-checkout-caching) | useblacksmith/checkout backed by a sticky-disk git mirror, labelled beta, with extra dissociate and verbose inputs | Revert to actions/checkout@v4 and drop the two extra inputs, which have no upstream equivalent. | **What to expect on the first run.** Every cache key starts cold on the new platform. Blacksmith's own Docker docs set the same expectation in the other direction, stating that the first Docker run after a cache-backend change is an uncached run. Judge the migration on runs three and later, once dependency caches, layer caches, and any snapshots have hydrated. A pilot that gets measured on run one measures cache warming rather than the platform. ## [Compatibility Table](#compatibility-table)Copy Anchor Link | Blacksmith feature or label | WarpBuild equivalent | Status | Note | | ------------------------------------------ | --------------------------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | blacksmith-\* runner labels | warp-\* runner labels | Supported | One line per job. See the label map above. | | Transparent actions/cache interception | WarpBuilds/cache@v1 | Partial | Same inputs and outputs, added as an explicit action swap rather than a transparent redirect. | | Docker layer caching | Remote Docker builders with a builder profile | Supported | Different shape. Layer state lives on a dedicated builder VM shared by the jobs using that profile. | | Sticky disks | None | Unsupported | Nearest paths are the cache action for keyed paths and snapshot runners for whole-VM state. | | Docker container and service-image caching | None | Unsupported | First-run image pulls return. | | Bazel build caching | None | Unsupported | Configure your own remote cache or accept cold Bazel builds. | | Git checkout caching | actions/checkout@v4 | Partial | The dissociate and verbose inputs have no upstream equivalent and get dropped. | | Nested virtualization on x64 Linux | nested-virtualization.enabled=true label | Supported | Linux x64 only on both sides. | | SSH into a running job | [Action Debugger](/docs/ci/tools/action-debugger) | Supported | Different mechanism: a workflow step that pauses the job and opens an SSH session. | | GitHub Actions analytics dashboard | [CI observability](/docs/ci/features/observability) plus [Reports](/docs/ci/features/reports) | Supported | Reports covers per-job cost, duration, and queue timings by runner label, with CSV export. | | Slack monitors on job failure or duration | None | Unsupported | No published equivalent alerting product. | | Static IP add-on | None | Partial | No static egress IP product is published. The [networking addon](/docs/ci/features/networking) joins runners to a Tailscale tailnet for private service access. | | Scale Apps for GitHub API headroom | Not applicable | Not applicable | Specific to their app architecture. | | Windows Server 2025 runners at 2 vCPU | warp-windows-2025-x64-4x and larger | Partial | WarpBuild Windows starts at 4 vCPU. Their Windows family is labelled public beta in their own docs. | | macOS runners | warp-macos-26, warp-macos-15, and warp-macos-14 labels | Supported | WarpBuild publishes three macOS versions and Xcode contents per image. Name the macOS version explicitly. | | Automatic free size upgrades | None | Unsupported | WarpBuild bills the size you request, at the rate on the [pricing page](/pricing). | | Testboxes | None | Unsupported | No local-to-microVM sync product is published. | | GitHub-only authentication | SAML 2.0 and OIDC single sign-on | Supported | a flat $250 per month, whatever the user count ([pricing](/pricing)). GitHub login continues to work. | ## [Run Both Platforms Side by Side](#run-both-platforms-side-by-side)Copy Anchor Link A parallel run is the honest test, and both platforms support it because the routing decision lives in the workflow file. 1. **Pick a narrow, representative subset.** One busy repository, or one job inside a matrix, gives a clean read. Leave everything else on its current labels. 2. **Keep the Blacksmith GitHub app installed on every repository that still carries a `blacksmith-*` label.** Their [quickstart](https://docs.blacksmith.sh/introduction/quickstart) documents the failure mode: org-level runner registration can hand their runners jobs from repositories they never provisioned for, which causes queuing. This is the one parallel-run gotcha that bites teams. 3. **Wait out the cold runs.** Give each side at least three runs after the caches warm before you compare anything. 4. **Compare the same numbers on both sides.** WarpBuild [Reports](/docs/ci/features/reports) publishes per-job duration, queue time by runner label, and cost per repository, with CSV export. Their [GitHub Actions analytics dashboard](https://docs.blacksmith.sh/blacksmith-observability/dashboard) publishes p99 job duration and cost by repository. Export both and compare the same workflows over the same week. 5. **Watch queue time separately from run time.** A platform that runs a job quickly and queues it for minutes has not made your pipeline faster. ## [Billing During a Parallel Run](#billing-during-a-parallel-run)Copy Anchor Link Running two platforms means paying two platforms for the overlap. Four things to plan for. **Per-minute charges accrue on both sides.** Check current rates on the [WarpBuild pricing page](/pricing) and on [their pricing page](https://www.blacksmith.sh/pricing). This page restates no vendor's rates as numbers, because both change. **Their free minute allowance keeps working while you test.** Blacksmith publishes 3,000 free minutes per month per organization, denominated in x64 2vCPU minutes with published conversion ratios for other families, and those minutes reset monthly. Whatever stays on their labels draws from that pool first. **WarpBuild pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits**, which covers a meaningful slice of a pilot before any invoice appears. **Storage-shaped meters keep billing until the data goes away.** Their sticky disks and Docker layer cache are priced per GB per month on their pricing page, and both auto-evict after a period of inactivity (7 days for sticky disks, 8 days for the layer cache). Deleting the workflow steps stops new writes; existing volumes age out on their own schedule. One question for your own account: pay-as-you-go carries no published base subscription or seat fee, and committed-spend and annual contracts do exist on their side, since those are the qualifier for their Premium Support plan. Check whether your account has a term commitment before you plan a full cutover. ## [Rollback](#rollback)Copy Anchor Link Reverting is the same one-line change in the other direction, and it can be done the same day. 1. Change `runs-on` back to the `blacksmith-*` label on the affected jobs. 2. Restore any `useblacksmith/*` steps you removed: the Docker builder pair, sticky disks, and the checkout action. 3. Confirm the Blacksmith GitHub app is still installed on those repositories. Leaving it installed for the whole parallel-run period is why this stays a one-line revert. 4. Expect one cold run per cache key on the way back, with one exception: their sticky disks evict after 7 days of inactivity and their Docker layer cache after 8 days, so a rollback inside a week may still find warm state. There is no data to export, no account to close, and no configuration living outside the workflow file. Reversibility is the reason to run a pilot rather than a big-bang cutover. ## FAQ ### How long does the migration take? For a workflow with no vendor-specific action steps, the change is one line per job and the first green build lands in the same working session, once the WarpBuild GitHub app is installed on the repository. Workflows that use useblacksmith Docker builder, sticky disk, or checkout steps take longer, because those steps need replacing rather than relabelling. ### What happens to my existing caches? Nothing moves. Cache state is not portable between the platforms in either direction, and Blacksmith's own Docker caching docs state that the first Docker run after a cache-backend change is an uncached run (https://docs.blacksmith.sh/blacksmith-caching/docker-builds). Expect one cold run per cache key on WarpBuild, then hydrated runs. Their sticky disks auto-evict after 7 days of inactivity and their Docker layer cache after 8 days, so a rollback inside a week may still find warm state on their side. ### Do I need to change my secrets or permissions? No. Workflow secrets stay in GitHub and are read the same way on either platform. The one addition is a WARPBUILD\_API\_KEY secret, and only when you use WarpBuild Docker builders from runners that are not WarpBuild runners (https://www.warpbuild.com/docs/ci/docker-builders). WarpBuild registers as a self-hosted runner in the Default runner group of your GitHub organization, so an organization owner has to install the app and, for public repositories, allow public repositories on that runner group (https://www.warpbuild.com/docs/ci/public-repos). ### Can I run both platforms at the same time? Yes, and it is the recommended way to validate. Move a subset of workflows and leave the rest where they are. Keep the Blacksmith GitHub app installed on every repository that still carries a blacksmith label, because their docs warn that org-level runner registration can otherwise hand their runners jobs from repositories they never provisioned for, which causes queuing (https://docs.blacksmith.sh/introduction/quickstart). ### How do I roll back? Change the runs-on labels back to the blacksmith labels and restore any useblacksmith steps you removed. The workflow diff is the only change, so a rollback is a same-day operation with no data to migrate and no account to close. ### Which Blacksmith features have no WarpBuild equivalent? Sticky disks, Docker container and service-image caching, Bazel build caching, automatic free size upgrades, and Testboxes have no published WarpBuild equivalent as of 2026-08-13\. The compatibility table on this page lists each one with its status and the nearest WarpBuild path. --- # Migrate from Depot to WarpBuild | WarpBuild URL: https://www.warpbuild.com/migrate/depot-to-warpbuild Description: Move GitHub Actions jobs from Depot runners to WarpBuild: label mapping, cache changes, compatibility table, parallel run plan, billing overlap, and rollback. # Migrate from Depot to WarpBuild Move GitHub Actions jobs from Depot runners to WarpBuild: label mapping, cache changes, compatibility table, parallel run plan, billing overlap, and rollback. Last updated: 2026-08-13 Moving GitHub Actions jobs from Depot runners to WarpBuild runners is a `runs-on` label change plus a caching change. This page has the label mapping for Linux, Windows, and macOS, the cache features that do and do not have a WarpBuild equivalent, a compatibility table with the unsupported rows stated plainly, the parallel run plan, and the rollback steps. Still evaluating? [WarpBuild vs Depot](/compare/depot) has the dimension table, and [Depot alternatives](/alternatives/depot) covers the wider field. Every Depot fact below links to the page that states it, checked on 2026-08-13. ## [Prerequisites](#prerequisites)Copy Anchor Link * **GitHub organization owner permissions**, to install the WarpBuild GitHub app from the WarpBuild dashboard. The app cannot be installed from the GitHub Marketplace directly; sign up first, then install from the dashboard. See the [quick start](/docs/ci/quick-start). * **Nothing new on repository ownership.** Depot's docs already require that "your repository must be owned by a GitHub organization (not a personal account)", per their [GitHub Actions overview](https://depot.dev/docs/github-actions/overview), so a repository running Depot runners today already satisfies any organization prerequisite. * **Public repositories need one org setting.** WarpBuild registers runners into the organization's `Default` runner group, and GitHub disables self hosted runners in public repositories by default. Check `Allow public repositories` on that runner group. See [public GitHub repos](/docs/ci/public-repos). * **A list of the labels you use today.** Grep your workflows for `runs-on: depot-` and count the distinct values. That list is the whole scope of the mechanical part of this migration. Signup includes $10 in free credits, which covers a parallel run on most repositories. Per minute rates by runner label are on the [pricing page](/pricing). ## [The runs-on Diff](#the-runs-on-diff)Copy Anchor Link Adoption in either direction is one line per job. Copy Text ``` jobs: build: name: Build - runs-on: depot-ubuntu-24.04-8 + runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 ``` `runs-on` is evaluated per job, so a single workflow file can send some jobs to the new labels and leave the rest where they are. That is what makes the parallel run below cheap. ## [Label Mapping](#label-mapping)Copy Anchor Link Depot publishes a six rung size ladder for every Linux and Windows family: 2 CPU with 8 GB, 4 with 16, 8 with 32, 16 with 64, 32 with 128, and 64 with 256, per their [runner types page](https://depot.dev/docs/github-actions/runner-types). WarpBuild publishes five rungs on Linux, from 2 vCPU with 8 GB up to 32 vCPU with 128 GB, in [cloud runners](/docs/ci/cloud-runners). Map by vCPU and memory, and read the notes on the rows where the ladders differ. ### [Linux x64](#linux-x64)Copy Anchor Link | Depot label | vCPU / memory | WarpBuild label | vCPU / memory | | ----------------------------------- | ------------------ | ----------------------------------------- | ------------- | | depot-ubuntu-24.04 | 2 / 8 GB | warp-ubuntu-latest-x64-2x | 2 / 8 GB | | depot-ubuntu-24.04-4 | 4 / 16 GB | warp-ubuntu-latest-x64-4x | 4 / 16 GB | | depot-ubuntu-24.04-8 | 8 / 32 GB | warp-ubuntu-latest-x64-8x | 8 / 32 GB | | depot-ubuntu-24.04-16 | 16 / 64 GB | warp-ubuntu-latest-x64-16x | 16 / 64 GB | | depot-ubuntu-24.04-32 | 32 / 128 GB | warp-ubuntu-latest-x64-32x | 32 / 128 GB | | depot-ubuntu-24.04-64 | 64 / 256 GB | warp-ubuntu-latest-x64-32x is the largest | 32 / 128 GB | | depot-ubuntu-22.04 and its suffixes | same ladder | warp-ubuntu-2204-x64-2x through \-32x | same ladder | | depot-ubuntu-latest | current Ubuntu LTS | warp-ubuntu-latest-x64-2x | 2 / 8 GB | Ubuntu 26.04 has no Depot label at time of writing and is available on WarpBuild as `warp-ubuntu-2604-x64-2x` through `-32x`. ### [Linux ARM64](#linux-arm64)Copy Anchor Link | Depot label | vCPU / memory | WarpBuild label | vCPU / memory | | --------------------------------------- | ------------- | ---------------------------------------------------------------- | ------------- | | depot-ubuntu-24.04-arm | 2 / 8 GB | warp-ubuntu-latest-arm64-2x | 2 / 8 GB | | depot-ubuntu-24.04-arm-4 | 4 / 16 GB | warp-ubuntu-latest-arm64-4x | 4 / 16 GB | | depot-ubuntu-24.04-arm-8 | 8 / 32 GB | warp-ubuntu-latest-arm64-8x | 8 / 32 GB | | depot-ubuntu-24.04-arm-16 | 16 / 64 GB | warp-ubuntu-latest-arm64-16x | 16 / 64 GB | | depot-ubuntu-24.04-arm-32 | 32 / 128 GB | warp-ubuntu-latest-arm64-32x | 32 / 128 GB | | depot-ubuntu-24.04-arm-64 | 64 / 256 GB | warp-ubuntu-latest-arm64-32x is the largest | 32 / 128 GB | | depot-ubuntu-22.04-arm and its suffixes | same ladder | warp-ubuntu-latest-arm64-\* (24.04) or warp-ubuntu-2604-arm64-\* | same ladder | WarpBuild deprecated its Ubuntu 22.04 ARM64 images on March 31, 2025, so a 22.04 ARM job moves to 24.04 or 26.04\. Also note that the WarpBuild Ubuntu 24.04 ARM64 image sets the work directory to `/runner/_work` rather than GitHub's `/home/runner/work/`, which matters for any step with a hardcoded path. ### [Windows](#windows)Copy Anchor Link | Depot label | vCPU / memory | WarpBuild label | vCPU / memory | | ----------------------------------- | ------------------- | ---------------------------------------- | --------------------------- | | depot-windows-2022 | 2 / 8 GB | warp-windows-2022-x64-4x | 4 / 16 GB | | depot-windows-2022-4 | 4 / 16 GB | warp-windows-2022-x64-4x | 4 / 16 GB | | depot-windows-2022-8 | 8 / 32 GB | warp-windows-2022-x64-8x | 8 / 32 GB | | depot-windows-2022-16 | 16 / 64 GB | warp-windows-2022-x64-16x | 16 / 64 GB | | depot-windows-2022-32 | 32 / 128 GB | warp-windows-2022-x64-32x | 32 / 128 GB | | depot-windows-2022-64 | 64 / 256 GB | warp-windows-2022-x64-32x is the largest | 32 / 128 GB | | depot-windows-2025 and its suffixes | same ladder | warp-windows-2025-x64-4x through \-32x | 4 / 16 GB up to 32 / 128 GB | | depot-windows-latest | Windows Server 2025 | warp-windows-2025-x64-4x | 4 / 16 GB | Two traps on this platform. WarpBuild removed its 2 vCPU Windows runners on June 8, 2026, so the smallest Windows rung is 4 vCPU and a 2 CPU job sizes up. And `warp-windows-latest-x64-*` currently means Windows Server 2022, while `depot-windows-latest` means Windows Server 2025, so map by the explicit version label rather than by `latest`. Visual Studio 2026 is available on the transitional `warp-windows-2025-vs2026-x64-*` labels; the other Windows Server 2025 labels carry Visual Studio 2022. ### [macOS](#macos)Copy Anchor Link | Depot label | CPU / memory | WarpBuild label | vCPU / memory | | ---------------------------------- | ------------ | ------------------------------------------------- | ----------------------- | | depot-macos-26, depot-macos-latest | 8 / 24 GB | warp-macos-26-arm64-6x or warp-macos-26-arm64-12x | 6 / 14 GB or 12 / 28 GB | | depot-macos-15 | 8 / 24 GB | warp-macos-15-arm64-6x or warp-macos-15-arm64-12x | 6 / 14 GB or 12 / 28 GB | | depot-macos-14 | 8 / 24 GB | warp-macos-14-arm64-6x | 6 / 14 GB | All three Depot macOS labels share one hardware shape. WarpBuild publishes two sizes on macOS 15 and 26 and one on macOS 14, so a job that was memory bound at 24 GB has a larger rung available, and a job that was CPU bound at 8 CPUs should be tested on the 12 vCPU label rather than assumed equivalent. ## [Full Workflow Example](#full-workflow-example)Copy Anchor Link A Node build with caching and a macOS job, after the migration. The `runs-on` values and the two cache related action swaps are the whole diff. Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci - run: npm test build-ios: runs-on: warp-macos-26-arm64-12x steps: - uses: actions/checkout@v4 - name: Restore derived data uses: WarpBuilds/cache@v1 with: path: ~/Library/Developer/Xcode/DerivedData key: ${{ runner.os }}-derived-${{ hashFiles('**/Package.resolved') }} restore-keys: | ${{ runner.os }}-derived- - run: xcodebuild -scheme App -destination 'platform=iOS Simulator,name=iPhone 16' test ``` ## [Cache Migration](#cache-migration)Copy Anchor Link This is the part of the migration that is not a label change, so read it before you plan the cutover. **What happens to your Depot cache.** Depot pre-configures their runners so that any action using the GitHub Actions cache API writes to Depot Cache instead of GitHub's, transparently, for `actions/cache`, `actions/setup-node`, `actions/setup-python`, `actions/setup-java`, and anything else on that API. Their [cache integration docs](https://depot.dev/docs/cache/integrations/github-actions) state it plainly. That substitution is a property of running on their labels, so it ends the moment the label changes. Cache contents are not portable to another provider and no export path is documented on depot.dev, verified absent on 2026-08-13\. Entries age out under the retention policy on their [cache overview](https://depot.dev/docs/cache/overview), and storage is billed as an hourly sampled monthly average, so a cache left in place during a parallel run keeps accruing. **What you configure on WarpBuild.** WarpBuild Cache is a drop-in action swap rather than a transparent interception. Replace `actions/cache@v4` with `WarpBuilds/cache@v1`, and replace `actions/setup-*` with the matching `WarpBuilds/setup-*` action, which uses WarpBuild Cache under the hood for toolchains and package manager dependencies. Caching is enabled by default on Linux runners. See [caching](/docs/ci/features/caching) and [setup actions](/docs/ci/features/caching/setup-actions). **Expect cold caches for a run or two.** This is reasoning rather than a vendor statement: because the two caches are separate stores keyed independently, the first job on a new label finds nothing and populates from scratch. Budget one or two slower runs per cache key, not a slower week. ### [Depot cache features and their WarpBuild equivalents](#depot-cache-features-and-their-warpbuild-equivalents)Copy Anchor Link | Depot feature | WarpBuild equivalent | Status | Note | | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Depot Cache](https://depot.dev/docs/cache/overview) remote cache service | [WarpBuild Cache](/docs/ci/features/caching) | Equivalent | Different store, same job: build and dependency caching for GitHub Actions | | [Transparent GitHub Actions cache API interception](https://depot.dev/docs/cache/integrations/github-actions) | WarpBuilds/cache and WarpBuilds/setup-\* actions | Partial | One line per cache using step instead of zero workflow edits | | [Cache scoped by repository](https://depot.dev/docs/github-actions/overview) | Cache scoped by key, version, and branch | Partial | WarpBuild scoping is narrower; see the note below this table | | [Non branch isolated cache namespace](https://depot.dev/docs/github-actions/overview) | No equivalent | Unsupported | Keys designed so that feature branches read main entries behave differently | | [Configurable cache retention](https://depot.dev/docs/cache/overview) (7, 14, or 30 days, plus size caps) | Entries expire 7 days after last use, manual delete from the action or console | Partial | No configurable retention window or size cap is documented | | [Cache Explorer](https://depot.dev/docs/cache/overview) | [Reports](/docs/ci/features/reports) cache billing tab | Partial | Usage and cost view rather than a per entry browser | | [Cache integrations](https://depot.dev/docs/cache/overview) for Bazel, sccache, Turborepo, Pants, moonrepo, Maven, Gradle, Go | WarpBuilds/setup-\* actions for Node, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise | Partial | No documented WarpBuild remote cache backend for Bazel, sccache, Turborepo, Pants, moonrepo, or Maven. Those builds keep whatever remote cache they use today or fall back to the dependency cache | | [Depot Registry](https://depot.dev/docs/registry/overview) and pull through cache | No equivalent | Unsupported | Keep the registry you use today or move images to another OCI registry | | [Container build layer cache](https://depot.dev/docs/container-builds/overview) attached to depot build | [Remote Docker builders](/docs/ci/docker-builders) with persistent layer cache, or Docker layer caching through the cache backend | Partial | Different mechanism, and depot build steps do not move with a runs-on change | | [In memory disk accelerator](https://depot.dev/docs/github-actions/runner-types) | No equivalent under that name | Unsupported | WarpBuild runners ship SSD backed ephemeral storage per runner | One behavioral difference deserves a second read. Depot states that cache entries from `main` and other branches share one namespace, so any job can read any entry. WarpBuild cache is scoped by key, version, and branch, following the GitHub Actions cache matching rules. If your `restore-keys` were written on the assumption that a feature branch inherits `main` caches without the standard fallback, expect lower hit rates on the first pull request builds and adjust the keys. ## [Compatibility Notes](#compatibility-notes)Copy Anchor Link | Source platform feature | WarpBuild status | Note | | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Six rung Linux size ladder, x64 and ARM64 | Partial | Five rungs, 2 vCPU through 32 vCPU. A 64 vCPU job splits or sizes down | | Ubuntu 22.04 and 24.04 label families | Supported | Plus Ubuntu 26.04 labels with no source equivalent | | Ubuntu 22.04 on ARM64 | Unsupported | Deprecated March 31, 2025; move to 24.04 or 26.04 ARM64 | | Windows Server 2022 and 2025 families | Supported | Smallest rung is 4 vCPU; latest means different OS versions on each side | | macOS 14, 15, and 26 | Supported | Two sizes on macOS 15 and 26, one on macOS 14 | | Docker on Windows runners | Not applicable | The source platform's Windows runners have no Hyper-V, so no migrating workflow can already depend on this. This is a Depot fact only; it implies nothing about WarpBuild Windows runners, documented separately in [cloud runners](/docs/ci/cloud-runners) | | Docker on macOS runners | Unsupported | WarpBuild macOS runners do not support nested virtualization and cannot run Docker | | Cache on Windows runners | Unsupported | WarpBuild caching is available on Linux runners; Windows jobs keep using the GitHub Actions cache | | Nested virtualization on Linux x64 | Supported | Enabled with the nested-virtualization.enabled=true label, see [nested virtualization](/docs/ci/features/nested-virtualization) | | [Egress filtering rules](https://depot.dev/docs/github-actions/how-to-guides/egress-filtering), Linux only, incompatible with Tailscale | Not carried over | Rules configured in the source platform's org settings do not transfer. Rebuild the equivalent controls in your own network policy | | [Tailscale integration](https://depot.dev/docs/integrations/tailscale) | Supported | WarpBuild network addons cover Tailscale, see [networking](/docs/ci/features/networking) | | OIDC trust relationships with GitHub Actions | Supported | Cloud role trust policies keyed on the GitHub OIDC issuer are unaffected by the runner change | | [Custom runner AMIs](https://depot.dev/docs/github-actions/runner-types), top plan only | Partial | WarpBuild custom VM images are a BYOC feature, see [custom VM images](/docs/ci/byoc/custom-vm-images) | | [Dedicated macOS runners](https://depot.dev/docs/github-actions/troubleshooting) on an annual commitment | Contact support | Talk to WarpBuild support about sustained high macOS concurrency before you cut over | | [depot build steps](https://depot.dev/docs/container-builds/integrations/github-actions) inside a workflow | Separate migration | A container build product with its own meter. It keeps running until you replace it; [remote Docker builders](/docs/ci/docker-builders) are the WarpBuild equivalent | | [Depot CI workflows](https://depot.dev/docs/ci/overview) in .depot/workflows/ | Different shape entirely | Those are not GitHub Actions workflows. Moving them back to .github/workflows/ is a separate project from a label swap | ## [Secrets and Permissions](#secrets-and-permissions)Copy Anchor Link Nothing moves. Secrets and variables for GitHub Actions runners live in GitHub on both platforms, so a runners to runners migration does not touch them. The permission changes are limited to two things: installing the WarpBuild GitHub app, which requires organization owner permissions, and the `Allow public repositories` setting on the `Default` runner group if any public repository uses the runners. Cloud credentials obtained through OIDC are unaffected, because the trust relationship is between your cloud and GitHub's OIDC issuer rather than the runner vendor. ## [Parallel Run and Validation](#parallel-run-and-validation)Copy Anchor Link `runs-on` is per job, so run both platforms at once on the same repository. 1. Pick two or three workflows that run often and block nobody, such as unit tests on pull requests. 2. Move one job in each to a `warp-` label and leave its siblings where they are. 3. Let a week of real traffic go through, then compare queue time and total duration in each vendor's own dashboard. WarpBuild queue timings and per job metrics are on the [Reports](/docs/ci/features/reports) page. 4. Watch the failure modes that are specific to a platform change: hardcoded runner paths, tools present in one image and absent in the other, and cache keys that assumed the old scoping. Carry one caveat from Depot's own parallel run guidance, because it applies to any side by side validation: "Be mindful of workflows that write to external systems (deploys, artifact updates) since they will execute twice." Keep deploy jobs and anything that publishes artifacts on one platform at a time. ## [Billing During the Parallel Run](#billing-during-the-parallel-run)Copy Anchor Link You pay both platforms during the overlap. Plan the window around the billing shapes. On the Depot side, per their [pricing page](https://depot.dev/pricing) checked 2026-08-13: plans bill monthly on the 1st, and proration applies to mid month signup rather than to cancellation, so a cancelled plan runs to the end of its month. Included allowances are per month and do not roll over. Included minutes are consumed at the size multiplier published on their [runner types page](https://depot.dev/docs/github-actions/runner-types), so a parallel run on large runners burns the allowance faster than elapsed time suggests. Their docs state that included monthly minutes are not a hard cap, and self imposed usage caps are the control for that during a migration month. Storage is billed as an hourly sampled monthly average across cache and registry, so leaving cache in place during the overlap keeps accruing. On the WarpBuild side, billing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so the parallel run costs exactly the minutes it uses, against the $10 in signup credits first. Per minute rates by label are on the [pricing page](/pricing), and per job costs land in the [Reports](/docs/ci/features/reports) billing tab as they accrue. The cheap sequence: start the parallel run early in a billing month, cap usage on the platform you are leaving, cut over before the next 1st, and cancel after the last job has moved. ## [Rollback](#rollback)Copy Anchor Link Reversibility is the reason to start with one job. 1. **Revert the pull request** that changed the `runs-on` values. Depot documents adoption as a one line change to the runner label on their [GitHub Actions overview](https://depot.dev/docs/github-actions/overview), so going back is the same one line change in the other direction. 2. **Revert the cache action swap** in the same pull request if you made it there, so `actions/cache` and `actions/setup-*` return and the transparent interception resumes on their labels. 3. **Leave the old GitHub app installed and the old plan active** until the parallel run ends. No teardown is needed to keep the option open; the cost of keeping it is the monthly plan charge. 4. **Expect cold caches again** on the way back, for the same reason they were cold on the way over. Steps 1 and 2 are a single pull request, so a rollback is a same day operation as long as the app and the plan are still in place. Decommission only after you have run a full week without reaching for this section. ## [Expected Time to First Green Build](#expected-time-to-first-green-build)Copy Anchor Link Signup, app installation, and the first label change fit in one working session on a repository whose workflows you already know. The realistic schedule for a full migration is one afternoon for the label mapping and the first green job, one week of parallel running for confidence, and one pull request to move the rest. Slack support channels are available on demand if a job behaves differently on the new labels. ## FAQ ### How long does the migration take? For a repository with a handful of workflows, the label change is a single pull request and most teams get a first green build in the same working session. The parallel run window is the part worth budgeting time for, and one week of real traffic is enough to compare queue time and duration on both platforms. ### Do my caches move to WarpBuild? No. Cache contents are not portable between providers, and no export path for Depot Cache is documented on depot.dev as of 2026-08-13\. Expect cold caches on the first runs after the switch, then normal hit rates once keys repopulate. Depot Cache entries age out on their own under the retention policy documented at https://depot.dev/docs/cache/overview. ### Do I have to change my workflow files beyond runs-on? Only the caching steps. Depot pre-configures their runners so any action using the GitHub Actions cache API hits Depot Cache automatically (https://depot.dev/docs/cache/integrations/github-actions). WarpBuild Cache is a drop-in action swap instead: WarpBuilds/cache for actions/cache, and WarpBuilds/setup-\* for the matching setup actions. See https://www.warpbuild.com/docs/ci/features/caching. ### What happens to my depot build steps? They keep running against Depot's container build service, because that is a separate product from their GitHub Actions runners and does not move with a runs-on change (https://depot.dev/docs/container-builds/integrations/github-actions). Plan that piece separately; WarpBuild remote Docker builders are the closest equivalent, documented at https://www.warpbuild.com/docs/ci/docker-builders. ### How do I roll back if the migration goes badly? Revert the pull request that changed the runs-on labels. Depot documents adoption as a one line change to the runner label (https://depot.dev/docs/github-actions/overview), so the reverse is the same one line change, and it is a same day operation as long as the GitHub app is still installed and the plan is still active. --- # Migrate From GitHub-Hosted Runners to WarpBuild | WarpBuild URL: https://www.warpbuild.com/migrate/github-hosted-to-warpbuild Description: Move GitHub Actions jobs off GitHub-hosted runners with a runs-on label change, a cache swap, a parallel-run week, and a same-day rollback path. # Migrate From GitHub-Hosted Runners to WarpBuild Move GitHub Actions jobs off GitHub-hosted runners with a runs-on label change, a cache swap, a parallel-run week, and a same-day rollback path. Last updated: 2026-08-13 Moving a GitHub Actions workflow from GitHub-hosted runners to WarpBuild is a `runs-on` label change plus, on Linux, a one-line cache action swap. WarpBuild runners register as self-hosted runners against your GitHub organization, so actions from the marketplace, secrets, environments, and `GITHUB_TOKEN` behave the way GitHub documents them. This page carries the label mapping, a full workflow example, cache behavior, the compatibility table including the rows where WarpBuild has no equivalent, billing differences during a parallel run, and the rollback steps. Every GitHub number is linked to the GitHub page it came from and was checked on 2026-08-13. Still evaluating? [WarpBuild vs GitHub-hosted runners](/compare/github-actions) has the price and feature comparison, and [GitHub Actions larger runner alternatives](/alternatives/github-actions) covers the wider vendor set. ## [Prerequisites](#prerequisites)Copy Anchor Link * **A GitHub organization.** WarpBuild registers runners in the Default runner group (id `1`) of an organization, per the [public repository docs](/docs/ci/public-repos). Repositories under a personal account cannot receive the runners. * **A WarpBuild account.** Sign up at [app.warpbuild.com](https://app.warpbuild.com). Signup includes $10 free credits. * **The WarpBuild GitHub app, installed from the WarpBuild dashboard.** The app cannot be installed from the GitHub marketplace; you sign up first and install from the dashboard, per the [quick start](/docs/ci/quick-start). Grant it access to the repositories you plan to move, which can be a subset. * **Organization permission to edit runner group settings, if you have public repositories.** GitHub disables self-hosted runners in public repositories by default. Enable `Allow public repositories` on the Default runner group before moving a public repository, per the [public repository docs](/docs/ci/public-repos). * **Write access to the workflow files.** The only file change is `runs-on`, plus the cache action on Linux jobs. Nothing else in the repository changes. No cluster, no runner installation on your machines, and no changes to secrets. ## [Label Mapping](#label-mapping)Copy Anchor Link GitHub-hosted shapes below are the private-repository shapes from [GitHub's hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) and the [larger runners reference](https://docs.github.com/en/actions/reference/runners/larger-runners), checked on 2026-10-22\. Public repositories get 4 vCPU and 16 GB on Linux and Windows instead. WarpBuild shapes come from the [cloud runner catalog](/docs/ci/cloud-runners). | GitHub-hosted label | GitHub shape | WarpBuild label | WarpBuild shape | | -------------------------------------------------- | ------------------------------ | ------------------------------- | ------------------------------------ | | ubuntu-latest, ubuntu-24.04 | 2 vCPU, 8 GB, x64 | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | | ubuntu-22.04 | 2 vCPU, 8 GB, x64 | warp-ubuntu-2204-x64-4x | 4 vCPU, 16 GB | | ubuntu-26.04 | 2 vCPU, 8 GB, x64 | warp-ubuntu-2604-x64-4x | 4 vCPU, 16 GB | | ubuntu-24.04-arm | 2 vCPU, 8 GB, arm64 | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | | ubuntu-26.04-arm | 2 vCPU, 8 GB, arm64 | warp-ubuntu-2604-arm64-4x | 4 vCPU, 16 GB | | ubuntu-22.04-arm | 2 vCPU, 8 GB, arm64 | warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB, Ubuntu 24.04 | | ubuntu-slim | 1 vCPU, 5 GB, shared container | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | | windows-latest, windows-2025 | 2 vCPU, 8 GB | warp-windows-2025-x64-4x | 4 vCPU, 16 GB | | windows-2025-vs2026 | 2 vCPU, 8 GB | warp-windows-2025-vs2026-x64-4x | 4 vCPU, 16 GB | | windows-2022 | 2 vCPU, 8 GB | warp-windows-latest-x64-4x | 4 vCPU, 16 GB, Windows Server 2022 | | macos-latest, macos-26 | 3 vCPU, 7 GB | warp-macos-26-arm64-6x | 6 vCPU, 14 GB | | macos-15 | 3 vCPU, 7 GB | warp-macos-15-arm64-6x | 6 vCPU, 14 GB | | macos-14 | 3 vCPU, 7 GB | warp-macos-14-arm64-6x | 6 vCPU, 14 GB | | macos-latest-xlarge, macos-26-xlarge | 5 vCPU, 14 GB | warp-macos-26-arm64-6x | 6 vCPU, 14 GB | | macos-latest-large, macos-26-large | 12 vCPU, 30 GB, Intel | warp-macos-26-arm64-12x | 12 vCPU, 28 GB, Apple Silicon | | windows-11-arm, windows-11-vs2026-arm | 2 vCPU, 8 GB | No equivalent | WarpBuild Windows runners are x86-64 | | linux\_4\_core\_gpu, windows\_4\_core\_gpu classes | 4 vCPU, Tesla T4 | No equivalent | Not offered | Larger sizes exist on every WarpBuild platform: Linux x64 and ARM64 at 2, 4, 8, 16, and 32 vCPU, Windows at 4, 8, 16, and 32 vCPU, macOS at 6 and 12 vCPU. Unlike GitHub's larger runners, no plan gate applies. WarpBuild's largest general runner is 32 vCPU and 128 GB; GitHub's larger runners reach 96 vCPU, per the [larger runners reference](https://docs.github.com/en/actions/reference/runners/larger-runners). For container builds above that, use [remote Docker builders](/docs/ci/docker-builders), which reach 192 vCPU and 384 GB. Sizing rule of thumb: start at the same or the next shape up. `ubuntu-latest` on a private repository is a 2 vCPU machine, so `warp-ubuntu-latest-x64-2x` is the like-for-like move and `warp-ubuntu-latest-x64-4x` is the move that also buys headroom. On a public repository it is a 4 vCPU machine, so `warp-ubuntu-latest-x64-4x` is the like-for-like move and `warp-ubuntu-latest-x64-8x` is the move that also buys headroom. The table above picks `warp-ubuntu-latest-x64-4x` by default in both cases. ## [The Workflow Diff](#the-workflow-diff)Copy Anchor Link For most jobs the change is one line: Copy Text ``` jobs: build: - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 ``` On a matrix that spans operating systems it is three lines: Copy Text ``` jobs: test: strategy: matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [warp-ubuntu-latest-x64-4x, warp-windows-2025-x64-4x, warp-macos-26-arm64-6x] runs-on: ${{ matrix.os }} ``` A complete workflow after the change, with the WarpBuild cache on the Linux job and the stock GitHub cache left alone on the macOS job: Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - uses: WarpBuilds/cache@v1 with: path: ~/.npm key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }} restore-keys: | npm-${{ runner.os }}- - run: npm ci - run: npm test ios-build: runs-on: warp-macos-26-arm64-6x timeout-minutes: 60 steps: - uses: actions/checkout@v4 - uses: actions/cache@v4 with: path: ~/Library/Caches/org.swift.swiftpm key: spm-${{ hashFiles('**/Package.resolved') }} - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build ``` Keep the workflow name, the triggers, the step list, and the actions exactly as they are. The runner label is the contract between GitHub and the machine; everything above and below it stays GitHub's. ## [Cache Migration](#cache-migration)Copy Anchor Link **What happens to your existing caches.** They stay where they are. GitHub Actions caches live in GitHub, keyed per repository, inside the included 10 GB per repository allowance GitHub publishes on the [Actions billing page](https://docs.github.com/en/billing/concepts/product-billing/github-actions). Nothing deletes them, and a job that keeps `actions/cache@v4` keeps reading and writing them from a WarpBuild runner. **What changes if you switch.** `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` with the same inputs, outputs, keys, and restore-key semantics, per the [cache documentation](/docs/ci/features/caching) and the [action repository](https://github.com/WarpBuilds/cache). The WarpBuild cache is enabled by default on Linux runners and is a separate store from GitHub's, so entries do not carry across. **First-run behavior.** The first job after the swap is a cache miss on every key, and it writes the entries it computes. Expect the first run in each cache-key family to look like a cold build, and the second run to look normal. Plan the swap on a day when a slower first build is acceptable. **What you must do.** On Linux jobs, replace the `uses:` line and leave `with:` untouched. On Windows jobs, keep `actions/cache@v4`; WarpBuild caches are not supported on Windows runners. On macOS jobs, keep `actions/cache@v4` as well. Cache storage and cache operations are metered on WarpBuild and priced on the [pricing page](/pricing). On BYOC deployments the add-ons are included, because the storage sits in your own cloud account. ## [Run Both Platforms Side by Side](#run-both-platforms-side-by-side)Copy Anchor Link Move one non-critical workflow first, on a branch, so the job runs in a pull request before anything merges. For a real side-by-side comparison, duplicate a single job and pin one copy to each platform for a week: Copy Text ``` jobs: test-github: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: ./ci/test.sh test-warp: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./ci/test.sh ``` Compare pass and fail outcomes first, then wall-clock duration, then queue time. GitHub's own [Actions performance metrics](https://docs.github.com/en/actions/concepts/metrics) break job run time, queue time, and failure rate down by runner type, including self-hosted against GitHub-hosted, which makes it the neutral place to read the comparison. WarpBuild reports the same job data plus OpenTelemetry system metrics from the runner through [CI observability](/docs/ci/features/observability). Two things to watch during the parallel week. First, a duplicated job doubles the concurrency you consume on the GitHub side, and GitHub's standard-runner caps of 20, 40, 60, and 500 concurrent jobs by plan still apply to the GitHub half, per [Actions limits](https://docs.github.com/en/actions/reference/limits). Second, you pay both platforms for the duplicated job, which is covered under billing below. ## [Compatibility Table](#compatibility-table)Copy Anchor Link | GitHub-hosted feature | Status on WarpBuild | Note | | ------------------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Free minutes on public repositories | Unsupported | WarpBuild bills from the first minute. Signup includes $10 free credits. Public repositories also need Allow public repositories on the Default runner group. | | Included minutes on paid plans | Unsupported | WarpBuild pricing is purely usage based, with no base subscription fee, no platform fee, and no seat fee. | | GITHUB\_TOKEN | Supported | No change. GitHub's published limit is 1,000 API requests per hour per repository, or 15,000 on GitHub Enterprise Cloud, per [Actions limits](https://docs.github.com/en/actions/reference/limits). | | Repository and organization secrets | Supported | No change. Secrets resolve before the job reaches a runner. | | Environments, required reviewers, deployment protection rules | Supported | No change. These gate the job before dispatch. | | Marketplace actions | Supported | Runner images carry the same tooling as GitHub-hosted runners. | | Larger runners on Team and Enterprise Cloud | Supported, without the plan gate | Every WarpBuild size is available on every account. GitHub's 64 and 96 vCPU classes have no WarpBuild counterpart. | | actions/cache@v4 | Supported | Keeps using GitHub's cache and its 10 GB per repository allowance. | | WarpBuild cache | Supported on Linux | WarpBuilds/cache@v1 is the drop-in. Not supported on Windows runners. | | Nested virtualization and /dev/kvm | Supported on Linux x86-64 | Enable with the nested-virtualization.enabled=true label. Not available on ARM64 runners. | | Docker on macOS runners | Unsupported | WarpBuild macOS runners do not support nested virtualization and cannot run Docker. | | ubuntu-slim single-CPU runner | No equivalent | The smallest WarpBuild Linux runner is 2 vCPU and 8 GB. GitHub's ubuntu-slim carries a 15 minute job timeout and is a shared container. | | windows-11-arm, windows-11-vs2026-arm | No equivalent | WarpBuild Windows runners are x86-64. | | linux\_4\_core\_gpu and windows\_4\_core\_gpu classes | No equivalent | Not offered. | | Six hour job limit on hosted runners | Changed | GitHub applies a 6 hour limit to GitHub-hosted runners and up to 5 days on self-hosted runners, per [Actions limits](https://docs.github.com/en/actions/reference/limits). WarpBuild runners register as self-hosted, so set timeout-minutes deliberately. | | Per-plan concurrency caps | Removed | Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. For sustained high concurrency on macOS, contact support so capacity is staged ahead of the run. | | Spot capacity | BYOC only | Cloud spot runners were removed on 2026-06-08\. Spot instances remain available on BYOC. | | Static IPs | BYOC only | Available on AWS, GCP, and Azure BYOC deployments, per the [feature matrix](/docs/ci/feature-matrix). | | Snapshot runners, Action Debugger, MCP server | WarpBuild only | Linux snapshot runners, an SSH session into a paused job, and an MCP server for the WarpBuild API. | Unsupported rows are listed here rather than buried, because a surprise at migration time costs more than a row in a table. ## [Billing Differences](#billing-differences)Copy Anchor Link Both platforms bill per minute, and GitHub rounds each job up to the nearest whole minute, per the [minute multipliers page](https://docs.github.com/en/billing/reference/actions-minute-multipliers). All GitHub rates below are quoted from that page with GitHub's own billing SKU strings, checked on 2026-08-13\. WarpBuild rates come from the [pricing page](/pricing). | Shape | GitHub SKU | GitHub USD per minute | WarpBuild label | WarpBuild USD per minute | Difference | | ----------------------------------- | ------------------- | --------------------- | --------------------------- | ------------------------ | ---------------- | | Linux 2 vCPU, 8 GB, x64 | actions\_linux | 0.006 | warp-ubuntu-latest-x64-2x | 0.004 | 33 percent lower | | Linux 4 vCPU, 16 GB, x64 | linux\_4\_core | 0.012 | warp-ubuntu-latest-x64-4x | 0.008 | 33 percent lower | | Linux 8 vCPU, 32 GB, x64 | linux\_8\_core | 0.022 | warp-ubuntu-latest-x64-8x | 0.016 | 27 percent lower | | Linux 4 vCPU, 16 GB, arm64 | linux\_4\_core\_arm | 0.008 | warp-ubuntu-latest-arm64-4x | 0.006 | 25 percent lower | | Windows 4 vCPU, 16 GB | windows\_4\_core | 0.022 | warp-windows-latest-x64-4x | 0.016 | 27 percent lower | | Windows 8 vCPU, 32 GB | windows\_8\_core | 0.042 | warp-windows-latest-x64-8x | 0.032 | 24 percent lower | | macOS Apple Silicon, larger shape | macos\_xl | 0.102 | warp-macos-latest-arm64-6x | 0.080 | 22 percent lower | | macOS Apple Silicon, standard shape | actions\_macos | 0.062 | No smaller shape | 0.080 | GitHub lower | Source for every GitHub rate: [docs.github.com actions-minute-multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked 2026-08-13. Four billing facts change the arithmetic during a migration. 1. **Included minutes stop applying to the moved jobs.** GitHub's included allowance is 2,000 minutes a month on Free, 3,000 on Pro and Team, and 50,000 on Enterprise Cloud, per the [Actions billing page](https://docs.github.com/en/billing/concepts/product-billing/github-actions). Minutes you no longer run on GitHub-hosted standard runners simply go unused; they do not transfer. 2. **Larger runners never drew on those minutes anyway.** GitHub states larger runners are always charged, even on public repositories. If you were already on larger runners, the whole spend is comparable line for line. 3. **The parallel week costs both bills.** A duplicated job runs twice, so budget for one extra week of the duplicated job's minutes on each side. Keep the duplicate scoped to one workflow. 4. **The last macOS row goes GitHub's way.** GitHub's 3 vCPU standard macOS runner at 0.062 per minute is cheaper per minute than the smallest WarpBuild macOS runner at 0.080, and it draws on included minutes. The WarpBuild macOS price advantage applies against GitHub's larger Apple Silicon shape, where 0.080 against 0.102 is 0.022 lower per minute, 22 percent lower list price. WarpBuild add-ons are metered separately on the [pricing page](/pricing): cache storage, cache operations, snapshot restores, and snapshot storage. Networking add-ons such as Tailscale are $0\. On BYOC, runner minutes are $0.002 and the add-ons are included, because the compute bill goes to your own cloud account. Signup includes $10 free credits. ## [Rollback](#rollback)Copy Anchor Link Reverting is the same edit in reverse, and it is a same-day operation. Copy Text ``` jobs: build: - runs-on: warp-ubuntu-latest-x64-4x + runs-on: ubuntu-latest ``` 1. Change the `runs-on` label back to the GitHub-hosted label and merge. 2. If you swapped the cache action, change `WarpBuilds/cache@v1` back to `actions/cache@v4`. Your GitHub cache entries are still there, subject to GitHub's own retention. 3. The next workflow run picks up the reverted label. In-flight jobs finish where they started. Nothing else needs undoing. GitHub-hosted runners require no installation and no organization-level setup to return to, which is why the workflow diff is the entire migration in both directions. Leaving the WarpBuild GitHub app installed costs nothing, because WarpBuild pricing is purely usage based with no base subscription fee, no platform fee, and no seat fee. Reversibility is the reason a one-week parallel run is enough validation for most teams: the downside of a bad result is one commit. ## FAQ ### How long does the migration take? The workflow edit is one line per job. The account setup is a signup plus a GitHub app install from the WarpBuild dashboard, documented at https://www.warpbuild.com/docs/ci/quick-start. Most of the elapsed time in a migration goes to the parallel-run validation week rather than to the edits. ### What happens to my existing GitHub Actions caches? They stay in GitHub, keyed per repository, inside GitHub's included 10 GB per repository allowance documented at https://docs.github.com/en/billing/concepts/product-billing/github-actions. Jobs that keep using actions/cache keep reading them. Jobs that switch to WarpBuilds/cache@v1 start against an empty WarpBuild cache and warm on the first run. ### Do I need to change my secrets, permissions, or environments? No. Secrets, environments, and GITHUB\_TOKEN are GitHub features that are resolved before the job reaches a runner, so the label change does not touch them. GITHUB\_TOKEN keeps GitHub's published API rate limit of 1,000 requests per hour per repository, or 15,000 on GitHub Enterprise Cloud, per https://docs.github.com/en/actions/reference/limits. ### Can I move one workflow at a time? Yes. runs-on is set per job, so a repository can run some jobs on WarpBuild runners and the rest on GitHub-hosted runners for as long as you want. Running both for a week is the recommended validation path. ### How do I roll back? Set the runs-on label back to the GitHub-hosted label and merge. GitHub-hosted runners need no installation or org-level setup to return to, so a rollback is a same-day operation and the workflow diff is the only change. ### Does WarpBuild work with personal GitHub accounts? No. WarpBuild registers runners in the Default runner group of a GitHub organization, per https://www.warpbuild.com/docs/ci/public-repos, so the repositories you move need to live in an organization. --- # Migrate from Namespace to WarpBuild | WarpBuild URL: https://www.warpbuild.com/migrate/namespace-to-warpbuild Description: Move GitHub Actions workflows off Namespace: label mapping, cache equivalents, a full workflow example, parallel running, billing notes and same day rollback. # Migrate from Namespace to WarpBuild Move GitHub Actions workflows off Namespace: label mapping, cache equivalents, a full workflow example, parallel running, billing notes and same day rollback. Last updated: 2026-08-13 Moving GitHub Actions workflows from Namespace to WarpBuild is a label change plus the removal of the Namespace specific actions a workflow picks up over time. Namespace documents its own adoption as changing the `runs-on` field to a Namespace profile or label ([their GitHub Actions docs](https://namespace.so/docs/solutions/github-actions)), and the move back out has the same shape. This page covers the label map, the cache equivalents by name, a full workflow example, the compatibility table including what has no equivalent, how to run both providers at once, what happens to billing during the overlap, and how to roll back. Still deciding? [WarpBuild vs Namespace](/compare/namespace) has the dimension by dimension comparison, and [Namespace alternatives](/alternatives/namespace) covers the wider provider set. ## [Prerequisites](#prerequisites)Copy Anchor Link Three things before the first job moves. 1. **A WarpBuild account.** Sign up at [app.warpbuild.com](https://app.warpbuild.com), which includes $10 in free credits. Pricing is purely usage based with no base subscription fee, no platform fee and no seat fee ([pricing](/pricing)). 2. **The WarpBuild GitHub bot installed on the organization**, with access to the repositories you plan to move ([quick start](/docs/ci/quick-start)). If you are not a GitHub organization owner, an owner approves the install. This mirrors the Namespace GitHub App install, which their migration docs describe the same way ([their migration docs](https://namespace.so/docs/solutions/github-actions/migration)). 3. **A list of the Namespace features your workflows actually use.** Grep the repository for `nscloud`, `namespace-profile`, `namespacelabs/` and `NSC_` to get it. That list is the real switching surface, and the compatibility table below covers each item. Secrets, OIDC and `GITHUB_TOKEN` permissions carry over unchanged, because the workflows keep running on GitHub Actions against the same repository. Expect the first green build on the first push after the label change, once the bot has repository access. ## [The Workflow Change](#the-workflow-change)Copy Anchor Link A single job moves in one line. Copy Text ``` jobs: build: - runs-on: nscloud-ubuntu-24.04-amd64-8x16-with-cache + runs-on: warp-ubuntu-latest-x64-8x ``` A job on a Namespace runner profile changes the same way. Copy Text ``` jobs: integration: - runs-on: namespace-profile-large-builder + runs-on: warp-ubuntu-latest-x64-16x ``` ## [Label Map](#label-map)Copy Anchor Link Namespace machine labels take the form `nscloud-{os}-{arch}-{shape}` with optional `-with-cache`, `-with-features` and `-with-builders` suffixes, and only one `nscloud` label is allowed in a `runs-on` field ([runner configuration](https://namespace.so/docs/reference/github-actions/runner-configuration)). WarpBuild labels carry the size in the label itself ([runner catalog](/docs/ci/cloud-runners)). Match the vCPU count first, then read the RAM column. At the same vCPU count, the WarpBuild Linux and Windows machines carry more memory than the Namespace standard shapes. | Namespace label | Namespace machine | WarpBuild label | WarpBuild machine | | ----------------------------------- | ----------------- | ------------------------------------------------------------------------------------- | ---------------------------------------- | | nscloud-ubuntu-24.04-amd64-2x4 | 2 vCPU, 4 GB | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | | nscloud-ubuntu-24.04-amd64-4x8 | 4 vCPU, 8 GB | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | | nscloud-ubuntu-24.04-amd64-8x16 | 8 vCPU, 16 GB | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | | nscloud-ubuntu-24.04-amd64-16x32 | 16 vCPU, 32 GB | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | | nscloud-ubuntu-24.04-amd64-32x64 | 32 vCPU, 64 GB | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | | nscloud-ubuntu-24.04-arm64-8x16 | 8 vCPU, 16 GB | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB | | nscloud-ubuntu-22.04-amd64-8x16 | 8 vCPU, 16 GB | warp-ubuntu-2204-x64-8x | 8 vCPU, 32 GB | | nscloud-ubuntu-26.04-amd64-8x16 | 8 vCPU, 16 GB | warp-ubuntu-2604-x64-8x | 8 vCPU, 32 GB | | nscloud-ubuntu-20.04-amd64-8x16 | 8 vCPU, 16 GB | No Ubuntu 20.04 image | Move the job to 22.04 or newer | | nscloud-windows-2022-amd64-4x8 | 4 vCPU, 8 GB | warp-windows-latest-x64-4x | 4 vCPU, 16 GB | | nscloud-windows-2022-amd64-2x4 | 2 vCPU, 4 GB | warp-windows-latest-x64-4x | 4 vCPU, 16 GB, the smallest Windows size | | nscloud-macos-sonoma-arm64-6x14 | 6 vCPU, 14 GB | warp-macos-14-arm64-6x | 6 vCPU, 14 GB | | nscloud-macos-sequoia-arm64-6x14 | 6 vCPU, 14 GB | warp-macos-15-arm64-6x | 6 vCPU, 14 GB | | nscloud-macos-tahoe-arm64-6x14 | 6 vCPU, 14 GB | warp-macos-26-arm64-6x | 6 vCPU, 14 GB | | nscloud-macos-tahoe-arm64-12x28 | 12 vCPU, 28 GB | warp-macos-26-arm64-12x | 12 vCPU, 28 GB | | nscloud-macos-goldengate-arm64-6x14 | 6 vCPU, 14 GB | No macOS 27 host image. warp-macos-26-arm64-6x ships Xcode 27.0 and the macOS 27 SDKs | 6 vCPU, 14 GB | Namespace machine shape values are published in their [runner configuration reference](https://namespace.so/docs/reference/github-actions/runner-configuration) and [machine shapes docs](https://namespace.so/docs/architecture/compute/machine-shapes). WarpBuild labels, sizes and per minute rates are in the [runner catalog](/docs/ci/cloud-runners). The last macOS row needs a note. The `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` images carry Xcode 27.0 with the macOS 27 SDKs and the iOS 27.0, tvOS 27.0, watchOS 27.0 and visionOS 27.0 simulator runtimes ([runner catalog](/docs/ci/cloud-runners)), so a job pinned to a newer host image for the current Apple SDKs lands on macOS 26 here with the same toolchain. GitHub's upstream macOS 27 runner image is in beta, and a dedicated WarpBuild macOS 27 image follows once that image is released. Label suffixes and companion labels map like this: | Namespace label part | WarpBuild equivalent | | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | \-with-cache | Cache is enabled by default on WarpBuild Linux runners, so no label part is needed ([caching](/docs/ci/features/caching)) | | \-with-builders, nscloud-in-runner-builder | Remote Docker builders selected by builder profile in Warpbuilds/build-push-action ([Docker builders](/docs/ci/docker-builders)) | | nscloud-cache-tag- | The key and restore-keys inputs on WarpBuilds/cache@v1 | | nscloud-cache-size-gb | No sizing label. Cache is unlimited and billed per GB-month ([pricing](/pricing)) | | nscloud-cache-exp-do-not-commit | Use WarpBuilds/cache/restore@v1 without the matching save step | | namespace-features:tailscale.spec | network.name= on the runner label ([networking](/docs/ci/features/networking)) | | namespace-features:job.priority | No direct equivalent. Generally available WarpBuild Linux and Windows runners do not have plan-level concurrency caps. | | namespace-features:github.run-id | No equivalent. It exists to make scheduling deterministic under concurrency limits | | namespace-features:macos.channel=preview | No equivalent. WarpBuild publishes released macOS images only | ## [A Full Workflow](#a-full-workflow)Copy Anchor Link Before, on Namespace, using their checkout action, their cache action and a cached label: Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: test: runs-on: nscloud-ubuntu-24.04-amd64-8x16-with-cache steps: - uses: namespacelabs/nscloud-checkout-action@v8 - uses: namespacelabs/nscloud-cache-action@v1 # keep whichever version your repository pins with: cache: pnpm - uses: actions/setup-node@v4 with: node-version: 22 - run: pnpm install --frozen-lockfile - run: pnpm test ``` After, on WarpBuild: Copy Text ``` name: build on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test ``` The [cache enabled setup actions](/docs/ci/features/caching/setup-actions) accept the same inputs as their upstream counterparts and store dependencies in WarpBuild cache, which is why the separate cache step disappears. Workflows that cache arbitrary paths keep an explicit step and use `WarpBuilds/cache@v1`, a drop-in replacement for `actions/cache@v4`: Copy Text ``` - uses: WarpBuilds/cache@v1 with: path: | target ~/.cargo/registry key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo- ``` ## [Cache Migration](#cache-migration)Copy Anchor Link Cache contents do not move between vendors. Namespace Cache Volumes are local NVMe volumes attached to their machines ([cache volumes docs](https://namespace.so/docs/architecture/storage/cache-volumes)), and Namespace itself states that the volumes need workloads to run before they are filled and widely available ([pricing FAQ](https://namespace.so/pricing)). The same warm up applies on the way in to WarpBuild: the first run of each cache key is cold, and steady state runs are the ones worth comparing. Namespace names eleven cache surfaces in [their caching documentation](https://namespace.so/docs/solutions/github-actions/caching). Here is each one and what WarpBuild has. | Namespace cache feature | WarpBuild equivalent | Status | | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | | [Cache Volumes](https://namespace.so/docs/architecture/storage/cache-volumes) | WarpBuild cache, served through the WarpBuilds/cache action and enabled by default on Linux runners | Different architecture, same job | | [nscloud-cache-action](https://namespace.so/docs/reference/github-actions/nscloud-cache-action) | WarpBuilds/cache@v1, or a cache enabled WarpBuilds/setup-\* action for language toolchains | Equivalent | | [Namespace Artifacts](https://namespace.so/docs/architecture/storage/artifact-storage) | No named equivalent. Use actions/upload-artifact or your own object storage | No equivalent | | Container image caching | Container layer caching on WarpBuild runners, plus [remote Docker builders](/docs/ci/docker-builders) with a persistent layer cache | Equivalent | | [nscloud-checkout-action](https://namespace.so/docs/reference/github-actions/nscloud-checkout-action) | No repository mirror cache. Revert to actions/checkout@v4 | No equivalent | | Toolchain download caching | The [WarpBuilds/setup-\* actions](/docs/ci/features/caching/setup-actions) cache toolchains and package manager dependencies | Equivalent for the covered languages | | Action download caching | No named equivalent | No equivalent | | [Bazel remote cache](https://namespace.so/docs/bazel/cache) | No named Bazel product. Point Bazel at your own remote cache | No equivalent | | [Turborepo cache](https://namespace.so/docs/reference/github-actions/setup-turbocache) | No named Turborepo product. Store the Turborepo cache directory with WarpBuilds/cache@v1 | Partial | | Gradle cache | [WarpBuilds/gradle-actions](/docs/ci/features/caching/setup-actions), a drop-in replacement for gradle/actions | Equivalent | | [sccache](https://namespace.so/docs/integrations/sccache) | No named sccache product. Store the sccache directory with WarpBuilds/cache@v1 | Partial | Two more surfaces sit next to the cache list. Namespace Remote Builders map to [WarpBuild remote Docker builders](/docs/ci/docker-builders), which run outside the GitHub Actions runner with a persistent layer cache and are selected with a builder profile name in `Warpbuilds/build-push-action`. The [Namespace container registry](https://namespace.so/docs/architecture/storage/container-registry) has no WarpBuild equivalent, so images stored there move to your own registry before the last job leaves. ## [Compatibility Notes](#compatibility-notes)Copy Anchor Link | Namespace feature | WarpBuild equivalent | Status | Note | | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | runs-on label switch | warp- labels | Supported | One line per job, reversible in the same pull request | | Runner profiles (namespace-profile-) | Labels plus label modifiers, and custom runner configuration on BYOC | Partial | Profile only settings such as access level and swap have no label form on either side; configure the equivalent in the WarpBuild dashboard | | Linux x64 and ARM64 runners | Ubuntu 22.04, 24.04 and 26.04 on x64; 24.04 and 26.04 on ARM64 | Supported | Ubuntu 20.04 has no WarpBuild image | | Windows runners | Windows Server 2022 and 2025, from 4 vCPU up | Partial | No 2 vCPU Windows machine on WarpBuild | | macOS runners | macOS 14, 15 and 26 in 6 vCPU and 12 vCPU sizes, with Xcode 27.0, the macOS 27 SDKs and the iOS, tvOS, watchOS and visionOS 27.0 simulator runtimes | Partial | Current Apple SDK targets build unchanged. Namespace publishes more host macOS versions and machine shapes, and the preview image channel has no WarpBuild equivalent | | Linux on Apple Silicon | None | Unsupported | Jobs using it stay on Namespace or move to Linux ARM64 | | Breakpoints and SSH into a job | [Action Debugger](/docs/ci/tools/action-debugger) opens an SSH session from inside the workflow | Supported | Different mechanics, same outcome | | VNC remote display for macOS | None | Unsupported | macOS UI sessions over VNC have no WarpBuild equivalent | | Egress policies with allow and block rules | Not a WarpBuild product. Network policy lives in your own cloud on [BYOC](/docs/ci/byoc) | Partial | BYOC runners inherit your VPC egress controls | | Job metrics and insights | [CI observability](/docs/ci/features/observability) with OpenTelemetry metrics correlated to job logs | Supported | | | SAML single sign-on | [SAML 2.0 and OIDC](/docs/sso) with self-serve identity provider setup | Supported | Flat $250 per month, whatever the user count ([pricing](/pricing)) | | gRPC API and nsc CLI | [Automation API](/docs/ci/api-keys/automation) for documented runner and image operations, plus a hosted [MCP server](/docs/ci/mcp) for its supported tools | Supported | | | Devboxes | None | Unsupported | WarpBuild sells GitHub Actions infrastructure only | | Concurrency plan caps | No plan-level concurrency caps for generally available Linux and Windows runners | Supported | Review macOS and beta-runner quotas separately | ## [Run Both Platforms Side by Side](#run-both-platforms-side-by-side)Copy Anchor Link `runs-on` is evaluated per job, so a repository can run both providers at once with no coordination. The useful shape of a trial: 1. Pick the workflow with the longest queue plus run time. 2. Move one job in it to a `warp-` label and leave every other job on its Namespace label. 3. Let it run for a week so the cache warms and the comparison covers steady state rather than first run timings. 4. Compare the same job across providers on the same branch, then decide. Nothing in the Namespace documentation forbids running jobs on both platforms, verified on 2026-08-13\. Their runner configuration reference does forbid more than one `nscloud` label in a single `runs-on` field, which matters only while a job still points at them ([runner configuration](https://namespace.so/docs/reference/github-actions/runner-configuration)). ## [Billing During the Parallel Run](#billing-during-the-parallel-run)Copy Anchor Link Namespace bills a prepaid plan fee plus overage on a monthly cycle, and states that plan changes apply immediately with prepaid charges prorated ([billing and limits](https://namespace.so/docs/workspaces/billing-and-limits)). A team mid contract on a paid plan keeps paying that base fee for the rest of the period while the parallel run happens, so the overlap costs the Namespace plan fee plus the WarpBuild minutes. WarpBuild has no base subscription fee, no platform fee and no seat fee, so the parallel run costs only the minutes the moved jobs consume, and the $10 in signup credits covers the first ones. Per minute rates by runner label are published on the [pricing page](/pricing), and Namespace publishes their own rates on [their pricing page](https://namespace.so/pricing). ## [Rollback](#rollback)Copy Anchor Link Rollback is the same one line change in reverse, and it lands the same day. Copy Text ``` jobs: build: - runs-on: warp-ubuntu-latest-x64-8x + runs-on: nscloud-ubuntu-24.04-amd64-8x16-with-cache ``` Reverting the pull request restores the label and any Namespace specific actions in one operation. Two things to know before you revert: * **Namespace publishes no rollback guide.** Verified absent on 2026-08-13 on [their migration docs](https://namespace.so/docs/solutions/github-actions/migration) and [GitHub Actions docs](https://namespace.so/docs/solutions/github-actions). The mechanical symmetry comes from the forward move being a `runs-on` change in the first place. * **Caches restart cold on the way back too**, because Namespace cache volumes are theirs and WarpBuild cache entries are ours. Whichever direction you move, budget for one warm up cycle. Keeping the Namespace account active during the trial period is what makes the revert a same day operation instead of a re-onboarding. ## [After the Move](#after-the-move)Copy Anchor Link Once the last job leaves Namespace, retire the Namespace GitHub App install and any workload identity federation configured for their runners, and move images out of their container registry. From there, the WarpBuild surfaces worth adopting next are [snapshot runners](/docs/ci/features/snapshot-runners) for workflows with expensive setup, [remote Docker builders](/docs/ci/docker-builders) for container heavy pipelines, and [BYOC](/docs/ci/byoc) if the runners should sit inside your own AWS, GCP or Azure account. Slack support channels are available on demand, and the compliance posture is SOC 2 Type 2 with evidence at [trust.warpbuild.com](https://trust.warpbuild.com/). ## FAQ ### How much of my workflow changes? The runs-on line on every job, plus any Namespace specific actions. Namespace documents its own adoption as changing the runs-on field to a Namespace profile or label at namespace.so/docs/solutions/github-actions, checked 2026-08-13, so the reverse move is the same shape. Workflows that adopted nscloud-checkout-action, nscloud-cache-action, nscloud-setup, cache volume mounts or the Namespace container registry have more lines to revert. ### Do my caches transfer? No. Namespace Cache Volumes are local NVMe volumes attached to their machines, described at namespace.so/docs/architecture/storage/cache-volumes, checked 2026-08-13\. WarpBuild cache is served through the WarpBuilds/cache action and is enabled by default on Linux runners. Plan for cold cache runs on the first build of each key on WarpBuild, the same way Namespace documents a warm up period for their own volumes. ### Can I run both providers at once? Yes. runs-on is evaluated per job, so one job can point at a warp- label while every other job stays on a Namespace label. Nothing in the Namespace docs forbids it, verified 2026-08-13\. Billing continues on both sides during the overlap. ### What happens to my Namespace plan while I test? Namespace bills a prepaid plan fee plus overage monthly and states that plan changes apply immediately with prepaid charges prorated at namespace.so/docs/workspaces/billing-and-limits, checked 2026-08-13\. A team mid contract on a paid plan keeps paying the base fee during a parallel run. WarpBuild has no base subscription fee, so the parallel run costs only the minutes you use, and signup credits cover the first ones. ### How do I roll back? Revert the pull request. The runner label is the only required change, so rollback is a same day operation. Any Namespace specific actions you removed come back with the same revert. ### Does anything change for secrets and permissions? GitHub secrets, OIDC and GITHUB\_TOKEN permissions are unchanged, because the workflow still runs on GitHub Actions with the same repository configuration. The WarpBuild GitHub bot needs repository access, and any Namespace workload identity federation configured for their runners is retired once no jobs run there. --- # Pricing - GitHub Actions Runners | WarpBuild | WarpBuild URL: https://www.warpbuild.com/pricing Description: Transparent pricing for WarpBuild CI runners. 50% cheaper than GitHub Actions with 2x faster builds. Linux from $0.003/min, macOS M4 Pro from $0.08/min, Windows from $0.008/min. ## GitHub Actions CI Runners 2-10x faster builds, unlimited job concurrency, CI health reports - get started in 30 seconds. ### Cloud Runners #### Cloud Runners - Linux (x86-64) | Size | Price / min | | ----------------- | ----------- | | 2vCPU, 8GB RAM | $0.004 | | 4vCPU, 16GB RAM | $0.008 | | 8vCPU, 32GB RAM | $0.016 | | 16vCPU, 64GB RAM | $0.032 | | 32vCPU, 128GB RAM | $0.064 | #### Cloud Runners - Linux (arm64) | Size | Price / min | | ----------------- | ----------- | | 2vCPU, 8GB RAM | $0.003 | | 4vCPU, 16GB RAM | $0.006 | | 8vCPU, 32GB RAM | $0.012 | | 16vCPU, 64GB RAM | $0.024 | | 32vCPU, 128GB RAM | $0.048 | #### Cloud Runners - Linux (arm64 on Apple Silicon) | Size | Price / min | | ---------------- | ----------- | | 6vCPU, 14GB RAM | $0.08 | | 12vCPU, 28GB RAM | $0.16 | #### Cloud Runners - Windows (x86-64) | Size | Price / min | | ----------------- | ----------- | | 4vCPU, 16GB RAM | $0.016 | | 8vCPU, 32GB RAM | $0.032 | | 16vCPU, 64GB RAM | $0.064 | | 32vCPU, 128GB RAM | $0.128 | #### Cloud Runners - MacOS (M4 Pro) | Size | Price / min | | ---------------- | ----------- | | 6vCPU, 14GB RAM | $0.08 | | 12vCPU, 28GB RAM | $0.16 | ### Add-ons | Metric | Price | | ---------------------- | ------------------------ | | Networking (Tailscale) | Free | | Cache Storage | $0.20 per GB-month | | Cache Write / Restore | $0.0001 per operation | | Snapshot Restore | $0.04 per job | | Snapshot Storage | $0.025/hour per snapshot | | SAML SSO | $250/month | | Data Residency | $250/month | | Audit Logs | $400/month | ### Docker Builders Each builder profile is shared across jobs using that profile. There is no hard concurrency limit. | Size | Price / min | | ------------------------------ | ----------- | | 16vCPU, 32GB RAM, 100GB Disk | $0.06 | | 32vCPU, 64GB RAM, 200GB Disk | $0.12 | | 64vCPU, 128GB RAM, 200GB Disk | $0.24 | | 96vCPU, 192GB RAM, 600GB Disk | $0.36 | | 96vCPU, 192GB RAM, 2TB Disk | $0.52 | | 192vCPU, 384GB RAM, 600GB Disk | $0.72 | | 192vCPU, 384GB RAM, 2TB Disk | $0.88 | ### BYOC - AWS, GCP, or Azure This is the cheapest option, meant for scale. Caching and everything else is free. | Metric | Price | | ----------------------------- | ---------- | | Linux Runners | $0.002/min | | Windows Runners (AWS & Azure) | $0.002/min | | Cache & snapshots | Free | Enterprise 40% infra cost saving over actions-runner-controller on kubernetes - guaranteed. ![bring your own cloud](/images/byoc-pricing.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Built for your Enterprise Secure, customizable runners, battle tested at scale. Guaranteed cost savings compared to self-hosting. 40% cheaper than ARC guaranteed savings <10s cold start with warm pools 99.9% uptime SLA contractual 24/7 support dedicated Slack [Schedule a Call](https://cal.com/suryao/start)[View Trust Center](https://trust.warpbuild.com) ### Infrastructure Control BYOC\- Deploy to your AWS, GCP, or Azure VPCs Dedicated Enterprise Cloud\- Zero maintenance WarpBuild cloud in any region Custom AMIs\- Bring your own images with pre-installed deps Region-Specific Infrastructure\- Your data stays in your chosen region, zero cloud ingress/egress costs (excl. macOS) ### Security & Compliance SOC2 Type II\- Annual audits + penetration testing SAML SSO\- Okta, Azure AD, Google Workspace ($250/month) SCIM Provisioning\- Automated user lifecycle management ### Performance & Scale ∞ Concurrency\- No job queue limits, instant provisioning 99.9% SLA\- Backed by credits, contractual guarantee Warm Pools\- Jobs start in <10s ### Developer Experience SSH Debug\- ssh into failed jobs instantly Observability\- Correlate system metrics and job logs GitHub Enterprise\- GHES (self-hosted) + GHEC data residency support ## Frequently Asked Questions Do I need a credit card to sign up? No, you can sign up with your email and start using WarpBuild immediately. You will be need to add a payment method when you hit the free usage or credit limits. What is the billing granularity? CI runners are billed on a per-minute basis Does WarpBuild access my code if I use Github Actions runners? No, we do not access your code. We only access the GitHub events to trigger the CI jobs. Are you SOC2 compliant? WarpBuild is SOC2 Type 2 compliant. You can view the SOC2 report here - Can my CI data stay in a specific cloud region? Yes. On enterprise plans, WarpBuild offers region-specific infrastructure: any data you choose remains in the region you choose, and all ingress and egress costs from your cloud are eliminated (not applicable to macOS instances). Learn more at When do I get billed? By default, the billing happens on the last day of each month. However, each account has a threshold based on the usage and history. Once that threshold is crossed, the payment method on file may be charged mid-cycle. How do you store the payment details? We do not store payment information. The details are stored securely in Stripe. I love going Warp! How do I spread the word? The best way is to refer others to use WarpBuild too! You can share the love on X, Linkedin, Reddit or anywhere else. We also have an affiliate program: Thank you for the love! ## Super fast builds, 2-10x cheaper. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # ARM64 Runners - AWS Graviton 5 Powered CI/CD | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/arm64-runners Description: Ultra-fast ARM64 runners powered by AWS Graviton 5 processors. 40% better price-performance for CI/CD workloads. Native ARM64 builds. # ARM64 Runners Powered by Graviton 5 Ultra-fast ARM64 CI/CD with AWS Graviton 5 processors. 40% better price-performance for native ARM64 builds. [Start Building](https://app.warpbuild.com)[View Documentation](/docs/ci/cloud-runners#linux-arm64) //Features ## Next-generation ARM64 performance for modern CI/CD Harness the power of AWS Graviton 5 for faster, more efficient builds ### AWS Graviton 5 Powered Latest generation ARM64 processors delivering exceptional performance and energy efficiency for CI/CD workloads. ### 40% Better Price-Performance Superior cost efficiency compared to x86 runners while maintaining high performance for your builds. ### Native ARM64 Builds Build ARM64 applications natively without emulation for Apple Silicon, Linux ARM64, and mobile targets. ### Enterprise Security SOC2 Type 2 compliant with hardware-level security features and encrypted storage volumes. ### Energy Efficient Reduce your carbon footprint with energy-efficient ARM64 architecture without compromising performance. ### Full Toolchain Support Complete development toolchain including Docker, Kubernetes, Node.js, Python, Go, and more. //Use Cases ## Perfect for ARM64-first development Optimize your CI/CD for the ARM64 ecosystem 🍎 ### Apple Silicon Development Build and test applications for Apple Silicon Macs with native ARM64 performance and compatibility. \*Native performance \*No emulation overhead \*Faster builds \*Better compatibility 🐳 ### Docker Multi-Arch Builds Create multi-architecture Docker images for ARM64 and x86 platforms with native build performance. \*Multi-arch support \*Native builds \*Faster pushes \*Smaller images 📱 ### Mobile App Development Develop and build mobile applications targeting ARM64 devices with optimized performance. \*Mobile targets \*Cross-compilation \*Fast builds \*Native testing ☁️ ### Cloud-Native Applications Build cloud-native applications optimized for ARM64 Kubernetes clusters and serverless functions. \*Kubernetes ready \*Serverless optimized \*Cost effective \*Scalable //Ubuntu Support ## Ubuntu ARM64 Compatibility Important differences between Ubuntu versions on ARM64 ### Ubuntu 22.04 ARM64 Compatibility: Basic Support Default User: root Preinstalled Tools: Minimal preinstalled software GitHub Compatibility: Requires manual tool installation Use for simple builds or custom environments ### Ubuntu 24.04 ARM64 Recommended Compatibility: Full GitHub Compatibility Default User: runner Preinstalled Tools: Same preinstalled software as GitHub GitHub Compatibility: 100% compatible with GitHub ARM64 runners Recommended for all new projects ## Graviton 5 Performance Advantages 40% Better price-performance vs x86 60% Lower energy consumption 2.5x Faster than emulated builds 30% Faster container builds //Quick Setup ## Start using ARM64 runners today Simple configuration to enable Graviton 5 powered builds [View ARM64 Setup Guide](/docs/ci/cloud-runners#linux-arm64) ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # BYOC - Bring Your Own Cloud CI/CD | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/byoc Description: Deploy WarpBuild runners in your own AWS, GCP, or Azure cloud. Complete data control, enhanced security, and compliance for enterprise CI/CD. # Bring Your Own Cloud Complete Control Deploy WarpBuild runners in your own cloud infrastructure. Maximum security, compliance, and control. [Get Started](https://app.warpbuild.com)[View Documentation](/docs/ci/byoc) //Features ## Enterprise-grade security meets high performance All the speed of WarpBuild with the security and compliance of your own cloud ### Complete Data Control Your code and data never leave your cloud perimeter. Full compliance with data sovereignty requirements. ### Multi-Cloud Support Deploy on AWS, Google Cloud Platform, or Microsoft Azure with identical performance and features. ### Enhanced Security Leverage your existing cloud security controls, VPCs, and access management systems. ### Same Performance Identical 2x faster performance as managed cloud runners with the security of your own infrastructure. ### Enterprise Compliance Meet the strictest compliance requirements with deployments in your controlled environment. ### Global Regions Deploy in any region where your cloud provider operates for optimal latency and compliance. //Multi-Cloud ## Deploy on Your Preferred Cloud Provider Consistent WarpBuild experience across all major cloud platforms ![Amazon Web Services logo](/images/byoc-aws.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Amazon Web Services Setup time: 15 minutes * Graviton 4 ARM64 support * EC2 instance types * VPC integration * IAM role management * EBS storage optimization * Multi-AZ deployment [View Setup Guide](/docs/ci/byoc/aws) ![Google Cloud Platform logo](/images/byoc-gcp.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Google Cloud Platform Setup time: 20 minutes * Compute Engine instances * VPC native networking * IAM integration * Persistent disk storage * Regional deployment * Custom machine types [View Setup Guide](/docs/ci/byoc/gcp) ![Microsoft Azure logo](/images/byoc-azure.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Microsoft Azure Setup time: 25 minutes * Virtual Machine Scale Sets * Virtual Network integration * Azure AD authentication * Premium SSD storage * Availability zones * Managed identities [View Setup Guide](/docs/ci/byoc/azure) //Security ## Maximum Security with Your Cloud Controls Leverage your existing security infrastructure and policies 1 ### Data Never Leaves Your Perimeter All build data, artifacts, and logs remain within your cloud environment, ensuring complete data sovereignty. 2 ### Use Your Existing Security Controls Integrate with your VPCs, security groups, IAM roles, and monitoring systems seamlessly. 3 ### Audit and Compliance Ready Complete audit trails and compliance reports using your cloud provider's native logging and monitoring. ![BYOC Security Architecture](/images/secure-byoc.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) //Quick Setup ## Get started with BYOC in under 30 minutes Simple deployment process with infrastructure as code [Schedule Demo](https://cal.com/suryao/start) ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # BYOC AWS Support - Deploy WarpBuild on Amazon Web Services | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/byoc/aws Description: Deploy WarpBuild runners in your own AWS account. Graviton 4 support, VPC integration, IAM roles, and complete AWS ecosystem integration. # BYOC AWS Support Native AWS Integration Deploy WarpBuild runners in your AWS account with Graviton 4 support, VPC integration, and complete AWS ecosystem integration. [Get Started](https://app.warpbuild.com)[View AWS Guide](/docs/ci/byoc/aws) //AWS Features ## Deep AWS ecosystem integration Leverage the full power of AWS services with WarpBuild's optimized runners ### AWS Graviton 4 Support Native support for latest generation AWS Graviton 4 processors for optimal price-performance. ### Complete VPC Integration Deploy in your existing VPCs with custom subnets, security groups, and network configurations. ### IAM Role Management Leverage AWS IAM roles and policies for secure, granular access control and service integration. ### Multi-AZ Deployment High availability deployments across multiple availability zones for maximum uptime. ### AWS Security Integration Native integration with AWS security services including CloudTrail, Config, and GuardDuty. ### Global Region Support Deploy in any AWS region worldwide for compliance and latency optimization. ## AWS Services Integration ☁️ ### Amazon EC2 Optimized instance selection and auto-scaling Linux & Windows runners Instance optimization Auto-scaling groups Spot instances Custom AMIs EBS optimization 🔒 ### Amazon VPC Secure network isolation and connectivity Private subnets Security groups NACLs VPC endpoints NAT gateways Direct Connect 🔑 ### AWS IAM Identity and access management integration Role-based access Cross-account roles STS tokens Resource policies OIDC integration Permission boundaries 💾 ### Amazon EBS High-performance storage optimization gp3 volumes io2 volumes Snapshot management Encryption Volume optimization Burst credits //Setup Process ## Simple 3-Step Setup Process Get your AWS BYOC runners running in under 20 minutes 1 ### Connect Your AWS Account Setup time: 5 minutesEasy Set up IAM role with required permissions for WarpBuild to manage resources in your AWS account. Cross-account role IAM permissions Resource access Secure connection One-time setup Policy management 2 ### Setup WarpBuild Stack Setup time: 10 minutesEasy Configure region, VPC, and networking settings as context for your runners. Region selection VPC configuration Subnet setup Security groups Resource tagging Stack management 3 ### Create Custom Runners Setup time: 5 minutesEasy Configure instance types, disk types, and networking for your specific workload requirements. Instance selection EBS optimization Network configuration Auto-scaling Custom AMIs Cost optimization //Cost Optimization ## Maximize AWS Cost Efficiency Built-in cost optimization features to minimize your AWS spending ### Spot Instance Support Up to 90% Reduce costs by up to 90% using EC2 Spot instances for non-critical workloads ### Auto-scaling 30-60% Automatic scaling based on demand to minimize idle resource costs ### Reserved Instances 40-60% Long-term commitments for predictable workloads with significant savings ### Instance Right-sizing 20-40% Intelligent instance type selection based on workload requirements //Quick Setup ## Deploy on AWS in 15 minutes Simple Terraform deployment with complete AWS integration [View AWS Setup Guide](/docs/ci/byoc/aws/config) ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # BYOC Azure Support - Deploy WarpBuild on Microsoft Azure | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/byoc/azure Description: Deploy WarpBuild runners in your own Azure subscription. Linux and Windows runners, Virtual Machine optimization, VNet integration, Azure AD integration, and complete Azure ecosystem support. # BYOC Azure Support Native Azure Integration Deploy WarpBuild runners in your Azure subscription with RBAC integration, VNet support, and complete Azure ecosystem integration. [Get Started](https://app.warpbuild.com)[View Azure Guide](/docs/ci/byoc/azure) //Azure Features ## Deep Microsoft Azure ecosystem integration Leverage the full power of Azure services with WarpBuild's optimized runners ### Optimized VM Series Native support for Dv5, Fv2, and Ev5 VM series with AMD EPYC and Intel processors for optimal price-performance. ### VNet Integration Deploy in your existing Virtual Networks with custom subnets, Network Security Groups, and private endpoints. ### Azure AD Integration Leverage Azure Active Directory, Managed Identity, and RBAC for secure, granular access control. ### Availability Zone Support High availability deployments across multiple availability zones and regions for maximum uptime. ### Azure Security Integration Native integration with Azure Security Center, Azure Sentinel, and Azure Policy for comprehensive security. ### Global Region Support Deploy in any Azure region worldwide for compliance and latency optimization. ## Microsoft Azure Services Integration ⚡ ### Virtual Machines Optimized VM selection and auto-scaling Linux & Windows runners VM size optimization Scale sets Spot instances Custom images Premium SSD 🔒 ### Virtual Network Secure network isolation and connectivity Private subnets NSG rules VNet peering NAT gateway Private endpoints ExpressRoute 🔑 ### Azure AD Identity and access management integration Managed identity RBAC Service principals Conditional access OIDC integration PIM 💾 ### Azure Storage High-performance storage optimization Premium SSD Standard SSD Disk encryption Managed disks Storage optimization Backup //Setup Process ## Simple 3-Step Setup Process Get your Azure BYOC runners running in under 20 minutes 1 ### Connect Your Azure Account Setup time: 5 minutesEasy Set up IAM role with required permissions for WarpBuild to manage resources in your Azure subscription. Service principal creation RBAC configuration Resource group access Subscription permissions One-time setup Secure connection 2 ### Setup WarpBuild Stack Setup time: 10 minutesEasy Configure region, virtual network, and networking settings as context for your runners. Region selection VNet configuration Subnet setup Network security Resource tagging Stack management 3 ### Create Custom Runners Setup time: 5 minutesEasy Configure VM sizes, disk types, and networking for your specific workload requirements. VM size selection Storage optimization Network configuration Scale settings Custom images Cost optimization //Cost Optimization ## Maximize Azure Cost Efficiency Built-in cost optimization features to minimize your Azure spending ### Spot Instance Support Up to 90% Reduce costs by up to 90% using Azure Spot VMs for non-critical workloads ### Auto-scaling 30-60% Automatic scaling based on demand to minimize idle resource costs ### Reserved Instances 40-72% Long-term commitments for predictable workloads with significant savings ### VM Right-sizing 20-40% Intelligent VM size selection based on workload requirements //Quick Setup ## Deploy on Azure in 15 minutes Simple Terraform deployment with complete Azure integration [View Azure Setup Guide](/docs/ci/byoc/azure/config) ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # BYOC GCP Support - Deploy WarpBuild on Google Cloud Platform | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/byoc/gcp Description: Deploy WarpBuild runners in your own GCP project. Compute Engine optimization, VPC integration, IAM policies, and complete Google Cloud ecosystem integration. # BYOC GCP Support Native GCP Integration Deploy WarpBuild runners in your GCP project with IAM integration, VPC-native networking, and complete Google Cloud ecosystem integration. [Get Started](https://app.warpbuild.com)[View GCP Guide](/docs/ci/byoc/gcp) //GCP Features ## Deep Google Cloud ecosystem integration Leverage the full power of Google Cloud services with WarpBuild's optimized runners ### Optimized Machine Types Native support for Tau T2D, C3, and N4 machine types for optimal price-performance on Google Cloud. ### VPC Network Integration Deploy in your existing VPC networks with custom subnets, firewall rules, and private Google access. ### IAM Policy Management Leverage Google Cloud IAM policies and service accounts for secure, granular access control. ### Multi-Zone Deployment High availability deployments across multiple zones and regions for maximum uptime. ### GCP Security Integration Native integration with Google Cloud security services including Cloud Audit Logs and Security Command Center. ### Global Region Support Deploy in any Google Cloud region worldwide for compliance and latency optimization. ## Google Cloud Services Integration ⚡ ### Compute Engine Optimized VM selection and auto-scaling Machine type optimization Instance groups Preemptible instances Committed use discounts Custom images SSD optimization 🔒 ### VPC Network Secure network isolation and connectivity Private subnets Firewall rules VPC peering Cloud NAT Private Google access Cloud VPN 🔑 ### Cloud IAM Identity and access management integration Service accounts IAM policies Workload identity Resource hierarchy OIDC integration Conditional access 💾 ### Persistent Disk High-performance storage optimization SSD persistent disks Balanced persistent disks Snapshot management Encryption Regional disks Performance optimization //Setup Process ## Simple 3-Step Setup Process Get your GCP BYOC runners running in under 20 minutes 1 ### Connect Your GCP Account Setup time: 5 minutesEasy Set up service account with required permissions for WarpBuild to manage resources in your GCP project. Service account creation IAM role binding Project permissions API enablement One-time setup Secure connection 2 ### Setup WarpBuild Stack Setup time: 10 minutesEasy Configure region, VPC, and networking settings as context for your runners. Region selection VPC configuration Subnet setup Firewall rules Resource labeling Stack management 3 ### Create Custom Runners Setup time: 5 minutesEasy Configure machine types, disk types, and networking for your specific workload requirements. Machine type selection Disk optimization Network configuration Auto-scaling Custom images Cost optimization //Cost Optimization ## Maximize Google Cloud Cost Efficiency Built-in cost optimization features to minimize your GCP spending ### Preemptible Instance Support Up to 80% Reduce costs by up to 80% using preemptible instances for non-critical workloads ### Auto-scaling 30-60% Automatic scaling based on demand to minimize idle resource costs ### Committed Use Discounts 40-57% Long-term commitments for predictable workloads with significant savings ### Machine Type Right-sizing 20-40% Intelligent machine type selection based on workload requirements //Quick Setup ## Deploy on Google Cloud in 15 minutes Simple Terraform deployment with complete GCP integration [View GCP Setup Guide](/docs/ci/byoc/gcp/config) ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # High-Performance Unlimited Caching | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/cache Description: Supercharge CI/CD with unlimited high-performance caching. 4x faster cache operations, unlimited storage, and intelligent cache optimization. # High-Performance Unlimited Caching Accelerate your CI/CD with intelligent caching that's 4x faster than standard solutions. Unlimited storage, maximum performance. [Get Started](https://app.warpbuild.com)[View Documentation](/docs/ci/features/caching) //Features ## Next-generation caching for modern CI/CD Intelligent, fast, and unlimited caching that adapts to your build patterns ### 4x Faster Operations Lightning-fast cache save and restore operations compared to standard GitHub Actions cache. ### Unlimited Storage No size limits or storage quotas. Cache as much as you need for optimal build performance. ### Global Edge Network Distributed caching infrastructure for reduced latency and improved performance worldwide. ### Intelligent Optimization Smart cache algorithms that optimize for your specific build patterns and dependencies. ### Secure & Isolated Encrypted cache storage with complete isolation between projects and organizations. ### Advanced Analytics Detailed cache hit ratios, performance metrics, and optimization recommendations. ## Performance Comparison vs GitHub Actions Cache ### Cache Save Time GitHub Actions45 seconds WarpBuild12 seconds 73% faster ### Cache Restore Time GitHub Actions30 seconds WarpBuild8 seconds 73% faster ### Throughput GitHub Actions50 MB/s WarpBuild200 MB/s 4x faster ### Storage Limit GitHub Actions10 GB WarpBuildUnlimited No limits //Cache Types ## Optimize Every Part of Your Build Pipeline Intelligent caching for all types of build artifacts and dependencies 🏗️ ### Build Artifacts 60% faster Cache compiled binaries, generated assets, and build outputs for faster subsequent builds. Compiled binaries Generated assets Build outputs Bundled files 📦 ### Dependencies 80% faster Cache package manager downloads and dependencies to eliminate redundant network requests. npm packages Maven artifacts Python wheels Go modules 🐳 ### Container Layers 75% faster Accelerate Docker builds with intelligent layer caching and reuse across builds. Docker layers Base images Multi-stage builds Registry pulls 🧪 ### Test Data 70% faster Cache test fixtures, datasets, and generated test data for faster test execution. Test fixtures Mock data Generated datasets Test databases //How It Works ## Simple Integration, Maximum Performance Drop-in replacement for existing cache actions with massive performance gains ### 1\. Smart Upload Intelligent compression and deduplication during cache saves for optimal storage efficiency. ### 2\. Global Distribution Cache data is distributed across global edge locations for minimal latency access. ### 3\. Lightning Restore Parallel downloads and smart restoration for maximum speed during cache retrieval. //Quick Setup ## Enable high-performance caching in minutes Simple action replacement with immediate performance benefits [View Cache Action](https://github.com/marketplace/actions/warpcache) ![Cache integration example](/images/cache-md.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # Cloud Runners - 2x Faster GitHub Actions | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/cloud-runners Description: Supercharge your CI/CD with WarpBuild's cloud runners. 2x faster builds, 50% cheaper than standard GitHub Actions runners. Get started in 30 seconds. # Cloud Runners 2x Faster Builds Supercharge your CI/CD pipeline with high-performance cloud runners. 2x faster, 50% cheaper than standard GitHub Actions. [Start Building](https://app.warpbuild.com)[View Documentation](/docs/ci/cloud-runners) //Features ## Powerful cloud infrastructure optimized for CI/CD Everything you need for fast, reliable, and cost-effective continuous integration ### 2x Faster Builds Reduce build times by 50% with optimized cloud infrastructure and intelligent caching systems. ### Managed Cloud Infrastructure Focus on code, not infrastructure. Fully managed cloud runners with automatic scaling and maintenance. ### Global Edge Locations Deploy across multiple regions for reduced latency and improved performance worldwide. ### High-Performance CPUs Latest generation processors with optimized performance for build workloads and parallel execution. ### Enterprise Security SOC2 Type 2 compliant with encrypted storage, network isolation, and comprehensive audit logs. ### Real-time Analytics Monitor performance, costs, and usage patterns with detailed analytics and insights dashboard. ## Why Choose WarpBuild Cloud Runners? 💰 ### Cost Effective Save up to 50% on runner costs compared to standard GitHub Actions runners with better performance. ⚡ ### Zero Configuration Drop-in replacement for GitHub Actions runners. Change one line in your workflow file to get started. 📈 ### Automatic Scaling Handle any workload size with automatic scaling. From single builds to thousands of parallel jobs. 🛡️ ### 99.9% Uptime Enterprise-grade reliability with built-in redundancy and failover mechanisms across multiple zones. //Quick Setup ## Get started in under 30 seconds Simple one-line change to your existing GitHub Actions workflow [View Quickstart Guide](/docs/ci/quick-start) ![One line change to enable cloud runners](/images/line-change.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # Remote Docker Builders - 40x Faster Container Builds | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/docker-builders Description: 40x faster container builders with local disks and complete caching. # ⚡ Remote Docker Builders Your container builds have never been faster ![posthog](/images/posthog-logo.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### [posthog](https://github.com/test) 40.0xFaster ![WarpBuild](/images/warp-logo.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) 6m 15s 250m 0s ![dispatch](/images/dispatch-logo.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### [dispatch](https://github.com/test) 3.1xFaster ![WarpBuild](/images/warp-logo.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) 1m 57s 6m 0s [Get Started](https://app.warpbuild.com)[View Docs](/docs/ci/docker-builders) //Features ## World's fastest container builders ### Local Disks Build containers with local NVMe disks with high IOPS, high throughput and low latency. ### Complete caching Everything is cached for builds that feel like local builds. ### Parallel builds Run multiple builds in parallel and prevent bottlenecks. ### Run anywhere, not just in GitHub Actions Get fast builds on any platform, even your local machine. ### Multi-arch builds (coming soon) Build containers for linux on arm64 and x86-64, natively. Eliminate the need for emulation. ### Builder API (coming soon) Create and manage builders with the Builder API, a simple and easy to use API. //Easy Setup ## Quickstart with our GitHub Action You can configure WarpBuild's remote builders anywhere, not just in GitHub Actions. [Docs](/docs/ci/docker-builders)[GitHub](https://github.com/WarpBuilds/docker-configure) ![one-line change](/images/builder-change.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) //Enterprise ## Built for your Enterprise Leverage the security and flexibility of your cloud with WarpBuild's BYOC solution. ![Secure by design](/images/secure-builder.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Secure by design Runners have thier own encrypted storage volumes. ![SOC2 Type2 Compliant](/images/soc2-ci.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### SOC2 Type2 Compliant WarpBuild is SOC2 Type 2 compliant ![No code access](/images/no-code.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### No code access Ephemeral VMs, complete isolation. ![Tested at scale](/images/scale-builder.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Tested at scale Unlimited job concurrency, fast local caching. ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # Docker Layer Caching - Accelerate Container Builds | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/docker-layer-caching Description: Build Docker images up to 40x faster with intelligent layer caching, automatic layer detection, multi-stage optimization, and global cache distribution. # Docker Layer Caching Up to 40x Faster Builds Accelerate your container builds with intelligent layer caching and optimization for builds that are up to 40x faster. [Enable Caching](https://app.warpbuild.com)[View Documentation](/docs/ci/features/caching) //Layer Caching ## Intelligent Docker layer optimization Advanced caching strategies that understand your Docker build patterns ### Intelligent Layer Detection Automatically identifies and caches individual Docker layers for maximum reuse across builds. ### Up to 40x Faster Builds Build Docker images up to 40x faster by reusing cached layers from previous builds and other branches. ### Global Cache Distribution Distributed layer cache across multiple regions for optimal performance worldwide. ### Multi-Stage Build Support Optimized caching for multi-stage Dockerfiles with intelligent intermediate layer reuse. ### Secure & Isolated Encrypted layer storage with complete isolation between projects and organizations. ### Cache Analytics Detailed cache hit ratios, layer statistics, and build performance metrics. ## Build Optimizations ### Base Image Caching Cache popular base images locally for instant access Eliminates base image download time ### Dependency Layer Reuse Reuse dependency installation layers across different builds Skip package installation when dependencies unchanged ### Cross-Branch Sharing Share cached layers between different branches and pull requests Faster builds for feature branches ### Multi-Stage Optimization Intelligent caching for each stage in multi-stage builds Maximize reuse in complex build processes //Strategies ## Multiple Caching Strategies Choose the optimal caching approach for your build workflow ### Layer-by-Layer Caching 5-10x faster Each Docker layer is cached individually based on content hash Best for: Standard Docker builds with sequential layers ### Multi-Stage Caching 8-15x faster Optimized caching for multi-stage Dockerfiles with intermediate images Best for: Complex builds with build and runtime stages ### Registry Cache Pull-Through 3-5x faster Cache external registry pulls for instant subsequent access Best for: Builds using multiple external base images //Best Practices ## Dockerfile Optimization Tips Maximize cache efficiency with these proven optimization techniques ### Optimize Layer Order Reorder Dockerfile instructions to maximize cache hits Example: Move package.json copy before source code copy ### Use .dockerignore Exclude unnecessary files to improve layer cache efficiency Example: Ignore node\_modules, .git, and temporary files ### Minimize Layer Changes Group related commands to reduce layer invalidation Example: Combine RUN commands for package installation ### Leverage Build Arguments Use build args for dynamic values without cache invalidation Example: Use ARG for version numbers and environment settings ## Dramatic Build Time Improvements Up to 40x Faster Builds 90%+ Cache Hit Rate 80% Cost Reduction //Quick Setup ## Enable Docker layer caching in 2 minutes Simple integration with your existing Docker builds [View Setup Guide](/docs/ci/features/caching) ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # macOS Runners - Fast Apple Silicon CI/CD for iOS & macOS | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/macos-runners Description: Build iOS, macOS apps faster with WarpBuild's high-performance macOS runners powered by M4 Pro. 3x faster than GitHub Actions. # macOS Runners Apple Silicon M4 Pro Build iOS and macOS apps faster with native Apple Silicon performance. 3x faster builds than standard runners. [Start Building](https://app.warpbuild.com)[View Documentation](/docs/ci/cloud-runners#macos-m4-pro-on-arm64) //Features ## Native Apple Silicon performance for iOS & macOS development Everything you need for professional iOS and macOS app development ### Apple Silicon M4 Pro Native performance on Apple Silicon with M4 Pro processors for lightning-fast iOS and macOS builds. ### iOS & macOS Development Complete toolchain for iOS, macOS, tvOS, and watchOS development with latest Xcode versions. ### 3x Faster Than GitHub Significantly faster build times compared to standard GitHub Actions macOS runners. ### Secure & Isolated Each runner instance is completely isolated with encrypted storage and secure networking. ### Detailed Analytics Monitor build performance, resource usage, and costs with comprehensive analytics dashboard. ### Pre-installed Tools Xcode, iOS Simulator, Fastlane, CocoaPods, and other essential iOS development tools ready to use. ## Perfect for Every iOS & macOS Development Need ### iOS App Development Build, test, and deploy iOS applications with native Apple Silicon performance and latest Xcode versions. XcodeiOS SimulatorFastlaneTestFlight ### macOS App Development Develop and distribute macOS applications with full access to macOS APIs and development frameworks. XcodeAppKitSwiftUINotarization ### Flutter & React Native Build cross-platform mobile apps with Flutter and React Native on high-performance macOS infrastructure. FlutterReact NativeAndroid StudioCocoaPods ### CI/CD Automation Automate your entire iOS/macOS development workflow from code to App Store with powerful automation tools. GitHub ActionsFastlaneBitriseJenkins //Performance ## Benchmark Results: 3x Faster Than GitHub Actions Real-world performance improvements across different iOS and macOS build scenarios 25 min GitHub Actions iOS App Build Time 8 min WarpBuild macOS iOS App Build Time 68% Time Saved Average Improvement //Quick Setup ## Start building iOS apps in minutes Simple configuration changes to use WarpBuild macOS runners in your workflow [MacOS runners for iOS](/docs/ci/cloud-runners#macos-m4-pro-on-arm64) //iOS & macOS Development ## Complete Apple ecosystem support Everything you need for iOS and macOS app development 📱 ### Xcode Integration Latest Xcode versions with iOS simulators, testing frameworks, and complete development toolchain. Xcode iOS Simulator Swift Objective-C 🏪 ### App Store Distribution Build, sign, and distribute iOS apps with App Store Connect integration and automated workflows. Fastlane App Store Connect Code Signing TestFlight ⚛️ ### React Native & Flutter Cross-platform mobile development with React Native and Flutter framework support. React Native Flutter Metro Dart 🔄 ### CI/CD Integration Seamless integration with GitHub Actions, custom workflows, and automated testing pipelines. GitHub Actions Fastlane XCTest Test Plans //Deployment ## macOS Runner Availability Understanding macOS deployment options and Apple licensing 🍎 ### WarpBuild Cloud Only macOS runners are exclusively available through WarpBuild Cloud due to Apple's licensing requirements. M4 Pro Apple Silicon processors macOS 14, and 15 support Same preinstalled software as GitHub Instant provisioning and scaling No setup or maintenance required ℹ️ #### Important Note BYOC (Bring Your Own Cloud) deployment is not available for macOS runners due to Apple's software licensing restrictions. ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # CI Runners - 10x Faster GitHub Actions Runners | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/runners Description: Ship faster with 10x faster, 2-10x cheaper GitHub actions runners. # CI Runners Blazing Fast Github Actions Runners. 2x faster, 50-90% cheaper. [Get Started](https://app.warpbuild.com)[View Docs](/docs/ci/cloud-runners) //Features ## Powerful tools optimized for developers ### Snapshots (Cloud Only) Enable incremental builds and speed up workflows by 10x. Available in WarpBuild Cloud. ### Container layer caching Build containers fast with layer caching ### Unlimited concurrency We take care of the scaling so you can focus on shipping ### Unlimited cache sizes Blazing fast, colocated caches with consistent high performance ### CI analytics Monitor CI run times, failures, and trends with intuitive dashboards ### Multiple OSes and Architectures Support for linux on arm64 and x86-64, and MacOS on apple silicon. GPU support coming soon. ### Multiple OSes and Architectures Support for linux on arm64 and x86-64, and MacOS on apple silicon. GPU support coming soon. //Bring Your Own Cloud ## Bring Your Own Cloud AWS, GCP, Azure Maximize customization and eliminate maintenance with WarpBuild managed runners. Connect your cloud in 1-click. [BYOC Runners](/docs/ci/byoc)[Schedule a call](https://cal.com/suryao/start) ![BYOC with WarpBuild](/images/multi-byoc.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) #### Static IPs (BYOC Only) Setup static IPs for security sensitive runner allowlists. Available with Bring Your Own Cloud deployment. #### Custom machine images (BYOC Only) Pre-install dependencies for even faster builds. Available with Bring Your Own Cloud deployments. #### Fully customizable Pick any instance type, disk, and network config for your workloads //Cache ## Unlimited Github Actions cache Supercharge artifact caching and container layering caching with a 35% improvement in restores and 75% improvement in save times. [View Docs](/docs/ci/features/caching)[View Action in Marketplace](https://github.com/marketplace/actions/warpcache) #### Lightning Fast Cache save and restore times are 4x faster compared to Github - in your cloud or ours. #### Container Layer Cache Reuse unchanged layers in subsequent builds, making your runs faster and efficient. #### Built for Scale Battle-tested for consistent performance, at scale with 1000s of concurrent jobs. ![fast cache](/images/cache-md.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) //Enterprise ## Built for your Enterprise Leverage the security and flexibility of your cloud with WarpBuild's BYOC solution. ![Secure by design](/images/secure-byoc.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Secure by design Runners have thier own encrypted storage volumes that are created on demand and destroyed after each build. ![SOC2 Type2 Compliant](/images/soc2-ci.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### SOC2 Type2 Compliant WarpBuild is SOC2 Type 2 compliant ![No code access](/images/no-code.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### No code access Ephemeral VMs, complete isolation. ![Fully customizable](/images/custom.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Fully customizable Pick any instance type, disk, and network config for your workloads. Customizable images and high CPU, disk performance. ![Tested at scale](/images/scale.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ### Tested at scale Unlimited job concurrency, fast local caching, and tools designed to support collaboration for 1000s of developers. ![Tech background left](/images/tech-left-half.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Tech background right](/images/tech-right-half.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) //CI Analysis ## Seamless Integration with Your Workflow Effortlessly incorporate WarpBuild Cloud into your existing development process ![Green-purple gradient](/images/green-purple-gradient.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![CI analytics and insights](/_next/image?url=%2Fimages%2Finsights-sc.png&w=3840&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) //Easy Setup ![one line change to your workflow](/images/linesm.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ## Change one line to get started Fully compatible with Github Actions with all the tools preinstalled. Save even more time with customizable base images. [Learn More](/docs/ci/quick-start) ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # Windows Runners - Fast .NET & C++ CI/CD | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/windows-runners Description: Accelerate Windows app development with WarpBuild's high-performance Windows runners. .NET, C++, PowerShell support. 2x faster builds. # Windows Runners .NET & C++ Optimized Build Windows applications faster with optimized .NET and C++ development environments. 3x faster builds than standard runners. [Start Building](https://app.warpbuild.com)[View Documentation](/docs/ci/cloud-runners#windows-x86-64) //Features ## Optimized Windows environment for enterprise development Everything you need for professional Windows application development ### High-Performance CPUs Latest Intel and AMD processors optimized for Windows workloads with fast NVMe storage. ### .NET & C++ Support Complete toolchain for .NET, C++, Visual Studio, MSBuild, and PowerShell development. ### 2x Faster Builds Significantly faster build times compared to standard GitHub Actions Windows runners. ### Secure Windows Environment Isolated Windows Server instances with encrypted storage and secure networking. ### Build Analytics Monitor Windows build performance, resource usage, and optimization opportunities. ### Pre-configured Tools Visual Studio, .NET SDK, Git, PowerShell, and other essential Windows development tools. //Development Stacks ## Complete Windows development ecosystem Everything you need for Windows application development 🔷 ### .NET Applications Build and deploy .NET Framework, .NET Core, and .NET 6+ applications with full Visual Studio support. Visual Studio .NET SDK MSBuild NuGet ⚡ ### C++ Development Compile native Windows applications with Visual C++, CMake, and other C++ development tools. Visual C++ CMake MSVC Windows SDK 💙 ### PowerShell Automation Run PowerShell scripts, modules, and automation workflows in high-performance Windows environment. PowerShell Azure CLI AWS CLI Pester 🖥️ ### Desktop Applications Build WPF, WinForms, UWP, and Win UI applications with complete Windows development stack. WPF WinForms UWP Win UI //Deployment Options ## Windows Runner Deployment Availability Choose the deployment option that fits your requirements ### WarpBuild Cloud Available Now Fully managed Windows Server 2022 and 2025 runners Instant setup Auto-scaling Managed updates 99.9% uptime ### BYOC: AWS Available Now Deploy Windows runners in your AWS account EC2 integration VPC deployment Cost optimization IAM roles ### BYOC: Google Cloud Coming Soon Deploy Windows runners in your GCP project Compute Engine VPC native IAM integration Regional deployment ### BYOC: Microsoft Azure Available Now Deploy Windows runners in your Azure subscription Azure VMs VNet integration Azure AD RBAC policies //Performance ## Windows Build Performance Comparison Real-world performance improvements for Windows development workflows 18 min GitHub Actions .NET Build Time 9 min WarpBuild Windows .NET Build Time 50% Time Saved Average Improvement //Quick Setup ## Start building Windows apps faster Simple workflow changes to use WarpBuild Windows runners [View Windows Setup Guide](/docs/ci/cloud-runners#windows-x86-64) ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # High-Performance x86-64 Runners - Baremetal NVMe | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/ci/x86-runners Description: Ultimate performance with baremetal x86-64 runners featuring high-performance CPUs and directly attached NVMe storage. 3x faster CI/CD builds. # High-Performance x86-64 Runners Maximize performance for compute-intensive workloads with optimized x86-64 architecture. Up to 64 cores, ultra-fast storage. [Start Building](https://app.warpbuild.com)[View Documentation](/docs/ci/cloud-runners#linux-x86-64) //Features ## Maximum performance with baremetal infrastructure Dedicated hardware resources for compute-intensive CI/CD workloads ### Baremetal Performance Direct access to high-performance hardware without virtualization overhead for maximum throughput. ### Latest Generation CPUs Intel Xeon and AMD EPYC processors with high core counts and advanced instruction sets for build optimization. ### NVMe Storage Directly attached NVMe SSDs delivering extreme I/O performance for fast dependency downloads and builds. ### Isolated Execution Complete hardware isolation with encrypted storage and secure networking for enterprise security requirements. ### Performance Monitoring Real-time CPU, memory, disk, and network monitoring to optimize your build performance. ### Optimized Toolchains Pre-configured compilers, build tools, and runtimes optimized for x86-64 performance and compatibility. ## Enterprise-Grade Hardware Specifications 🚀 ### CPU Performance * Up to 32 cores per runner * High single-core performance * Advanced vector instructions * Turbo boost enabled ⚡ ### Storage Performance * NVMe SSD storage * Up to 100,000 IOPS * Sub-millisecond latency * Direct PCIe attachment 🏗️ ### Network and Caching * 10Gbps networking * Low-latency connectivity * Unlimited caching //Performance ## Optimized for Every CI/CD Workload Specialized optimizations for different types of build and test workloads ### Compilation Workloads 5x faster Optimized for C++, Rust, Go, and other compiled languages with parallel compilation support. Parallel builds Fast linking Incremental compilation Cache optimization ### Container Builds 3x faster Accelerated Docker builds with fast layer caching and multi-stage build optimization. Layer caching Parallel stages Build kit optimization Registry acceleration ### Test Execution 4x faster Fast test execution with parallel test runners and optimized test data loading. Parallel execution Fast I/O Memory caching Test isolation ### Package Management 6x faster Accelerated dependency resolution and package installation with intelligent caching. Dependency caching Parallel downloads Local mirrors Smart resolution //Quick Setup ## Deploy high-performance runners instantly Simple configuration to access baremetal x86-64 performance [View Performance Guide](/compare/github) ## 2x faster. ## 50-90% cheaper. ## Go Warp today. [Schedule a Call](https://cal.com/suryao/start)[Get Started](https://app.warpbuild.com) --- # Dedicated Enterprise Cloud | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/dedicated-cloud Description: Single-tenant GitHub Actions infrastructure in your preferred AWS or GCP region. Private networking, zero egress costs, simplified IAM. Fully managed by WarpBuild. Dedicated Enterprise Cloud # Your region. Our ops. Zero maintenance. Single-tenant WarpBuild infrastructure deployed in your preferred AWS or GCP region. Private networking. Zero egress. Zero maintenance. [Talk to Engineering](https://cal.com/suryao/start)[Compare Options](/products/enterprise) ### How it works We provision dedicated infrastructure in your chosen region. You configure VPC peering to your existing infrastructure. Runners access your internal services directly - no public internet, no egress fees, no hassle. ## Why Dedicated Cloud? The security of self-hosted. The simplicity of managed. The best of both. ### Zero Egress Costs Deploy in the same region as your infrastructure. Artifacts, dependencies, and data stay local. * Same-region deployment with your services * S3/GCS buckets accessible without egress * ECR/GAR pulls without cross-region fees * Typical savings: $10k-100k/month ### Zero Operation Overhead WarpBuild manages everything. Patching, scaling, monitoring. You just run jobs. * Fully managed by WarpBuild * Automatic security patching * 24/7 monitoring and alerting * Capacity planning handled ### Private Networking VPC peering and PrivateLink. Your network, your rules. * VPC peering to your infrastructure * PrivateLink for AWS services * Security group integration ### Simplified IAM No cross-account trust policies. Runners assume roles in your existing AWS/GCP accounts seamlessly. * Direct IAM role assumption * No complex trust relationships * Works with your existing policies ## Dedicated Cloud vs alternatives ### Self-hosted (ARC) * •Kubernetes expertise required * •Security patching burden * •Scaling complexity * •On-call overhead ### BYOC * •You manage cloud costs * •IAM setup complexity * •Some ops overhead * •Multi-account management ### Dedicated Cloud * Zero ops burden * Simple IAM integration * Predictable pricing * WarpBuild manages all ## Technical specifications ### Compute Instance types All x86-64 and arm64 High-performance caching Enabled Instant Job Start <10s Concurrency Unlimited ### Networking VPC peering Supported PrivateLink AWS supported Custom rules Full traffic control Egress Your region, no costs ### Storage & Cache Cache backend S3/GCS in your region Artifact storage Configurable Warm pools Included Snapshots Available ### Security Ephemeral runners No code access Zero data retention Supported SSO/SCIM Included SOC2 Type II Certified ## Available regions Deploy where your infrastructure lives. Any AWS or GCP region. ### AWS us-east-1us-west-2eu-west-1eu-central-1ap-southeast-1ap-northeast-1...any region ### GCP us-central1us-east1europe-west1europe-west4asia-east1asia-southeast1...any region ### Predictable pricing Volume-based pricing with committed use discounts. No surprise cloud bills - we handle the infrastructure costs. You pay one predictable fee. Annual contracts availableVolume discountsPrice match guarantee ## Ready for dedicated infrastructure? Let's architect it. [View Enterprise](/products/enterprise)[Talk to Engineering](https://cal.com/suryao/start) --- # Enterprise | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/enterprise Description: Enterprise-grade GitHub Actions runners with SOC2 compliance, BYOC deployment, SSO, and 99.9% SLA. 40% cheaper than self-hosting on Kubernetes. Enterprise # GitHub Actions for Enterprise SOC2 compliant. BYOC or dedicated cloud. SSO with SCIM. 40% cheaper than self-hosting on Kubernetes. [Get Started](https://app.warpbuild.com)[Talk to Engineering](https://cal.com/suryao/start) 40% cheaper than ARC guaranteed <10s cold start warm pools 99.9% uptime SLA contractual 24/7 support dedicated Slack ## Choose your deployment Both options include full enterprise features. Pick what fits your security requirements. ### Bring Your Own Cloud Deploy runners in your existing AWS, GCP, or Azure accounts. Full control over infrastructure. $0.002/minorchestration fee only * Runners in your VPCs * You pay cloud costs directly * Caching & snapshots free * Unlimited accounts [Learn more](/products/ci/byoc) ### Dedicated Enterprise Cloud WarpBuild-managed infrastructure in your preferred region. Zero ops, maximum security. Customvolume-based pricing * Single-tenant infrastructure * Any AWS/GCP region * Private networking included * Managed by WarpBuild [Learn more](/products/dedicated-cloud) ## Enterprise capabilities Everything you need to run GitHub Actions at scale, securely. ### Infrastructure Control BYOC\- Deploy to your AWS, GCP, or Azure VPCs Dedicated Enterprise Cloud\- Zero maintenance WarpBuild cloud in any region Custom AMIs\- Bring your own images with pre-installed deps Private Networking\- VPC peering, PrivateLink, no public IPs ### Security & Compliance SOC2 Type II\- Annual audits + penetration testing SAML SSO\- Okta, Azure AD, Google Workspace SCIM Provisioning\- Automated user lifecycle management Audit Logs\- Full API audit trail, exportable ### Performance & Scale ∞ Concurrency\- No job queue limits, instant provisioning 99.9% SLA\- Backed by credits, contractual guarantee Warm Pools\- Jobs start in <10s Global Regions\- Run close to your infrastructure ### Developer Experience SSH Debug\- ssh into failed jobs instantly Observability\- Correlate system metrics and job logs GitHub Enterprise\- Full GHES + GHEC support Terraform Provider\- Infrastructure as code 🔒 ### SOC2 Type II Certified Independently audited. Annual penetration testing. Full audit report available. Security controls verifiedContinuous monitoring [View Trust Center](https://trust.warpbuild.com) ## Ready to scale your CI? Let's talk. [Get Started](https://app.warpbuild.com)[Schedule a Call](https://cal.com/suryao/start) --- # Region-Specific Infrastructure | WarpBuild | WarpBuild URL: https://www.warpbuild.com/products/region-specific-infrastructure Description: Pin your CI data to a specific cloud region for data residency, and eliminate all ingress and egress costs from your cloud. Available on enterprise plans. Enterprise # Your data stays in your region. Region-specific infrastructure pins any data you choose to the region you choose - and eliminates all ingress and egress costs from your cloud. Data residency and a smaller cloud bill, in one move. [Talk to Engineering](https://cal.com/suryao/start)[View Enterprise](/products/enterprise) ### How it works WarpBuild provisions runner infrastructure in the region you select. Any data you choose - caches, artifacts, logs - is stored and processed in that region only. Because runners sit next to your registries, buckets, and services, your cloud no longer bills you for data moving in or out. ## Why region-specific infrastructure? Compliance teams get residency. Finance gets a smaller cloud bill. Engineering changes one runner label. ### Data Residency Choose a region, and any data you choose stays in that region. Source code, caches, artifacts, and logs never leave it. * Pin data to any AWS or GCP region * Caches and artifacts stored in-region * Meet GDPR and regional compliance requirements * Pairs with GitHub Enterprise data residency ### Zero Ingress / Egress Costs Runners live in the same region as your data, so cross-region and internet data transfer charges from your cloud disappear entirely. * ECR / GAR image pulls without transfer fees * S3 / GCS reads and writes stay in-region * No NAT gateway processing charges for build traffic * Not applicable to macOS instances ### Compliance Without Ops Get the residency guarantees auditors ask for without running your own runner fleet. * SOC2 Type II certified infrastructure * Single-tenant, ephemeral runners * Fully managed by WarpBuild ### Same WarpBuild Performance Region pinning doesn't slow anything down. If anything, builds get faster because data is closer. * Warm pools and <10s job starts * Unlimited concurrency * Lower latency to your in-region services ## Where the savings come from When builds run outside your cloud region, every image pull, artifact download, and dependency fetch is billed as data transfer. In-region, those line items go to zero. ECR / container registry pullsCross-region transfer fees on every image pullFree, in-region S3 / GCS artifacts and cachesEgress charges on every downloadFree, in-region Internal services and databasesInternet or cross-region egressFree, in-region Zero ingress/egress applies to Linux and Windows runners. It is not applicable to macOS instances, which run on dedicated Apple Silicon hardware outside your cloud. ### Available on enterprise plans Region-specific infrastructure is part of the WarpBuild enterprise plan, alongside [Dedicated Cloud](/products/dedicated-cloud) and [BYOC](/products/ci/byoc). Tell us your region and we'll handle the rest. Any AWS or GCP regionFully managed by WarpBuildNo workflow changes beyond the runner label ## Keep your data home. Drop the transfer bill. [View Pricing](/pricing)[Schedule a Call](https://cal.com/suryao/start) --- # WarpBuild Runners | WarpBuild URL: https://www.warpbuild.com/runners Description: The WarpBuild runner catalog: Linux x64 and ARM64, macOS, Windows, snapshot runners, and remote Docker builders. # WarpBuild Runners Runner catalogs, images, sizes, and pricing for Linux, macOS, Windows, snapshot runners, and Docker builders. * [Remote Docker Builders: Sizes and PricingWarpBuild remote Docker builder profiles run from 16 vCPU at $0.06 per minute to 192 vCPU at $0.88 per minute, billed per session. Full catalog and rates.](/runners/docker-builders) * [Multi Platform Remote Docker BuildersMulti platform builder profiles assign one instance per platform, joined into one Buildx instance. Sizes, per session rates, and the emulation check.](/runners/docker-builders/multi-platform) * [Native ARM64 Runners for GitHub ActionsWarpBuild provides native Linux ARM64 GitHub Actions runners from 2 to 32 vCPU at $0.003 to $0.048 per minute. Labels, prices, images, and setup.](/runners/linux-arm64) * [Linux x64 Runners for GitHub ActionsWarpBuild runs Linux x64 GitHub Actions runners at 2 to 32 vCPU for $0.004 to $0.064 per minute on Ubuntu 24.04, 26.04, and 22.04\. Labels and prices.](/runners/linux-x64) * [Ubuntu Versions on Linux x64 GitHub Actions RunnersWarpBuild runs Ubuntu 22.04, 24.04, and 26.04 on Linux x64 GitHub Actions runners at $0.004 to $0.064 per minute. Labels, alias behavior, and migration.](/runners/linux-x64/ubuntu-versions) * [GitHub Actions Runners with Local SSD StorageWarpBuild BYOC Linux runners detect local NVMe SSDs at boot, RAID-0 them, and mount them to the work directory. Support matrix, instance types, and rates.](/runners/local-ssd) * [macOS Runners for GitHub Actions: Sizes and PricingWarpBuild macOS runners for GitHub Actions ship in 6 vCPU and 12 vCPU sizes across macOS 26, 15, and 14 at $0.08 and $0.16 a minute. Labels, specs, rates.](/runners/macos) * [iOS Simulator Runtimes on macOS GitHub Actions RunnersSee which iOS, tvOS, watchOS, and visionOS simulator runtimes each WarpBuild macOS image carries, per-minute prices, and how to assert a runtime in a job.](/runners/macos/simulator-runtimes) * [macOS Runner Sizes and Configurations for GitHub ActionsWarpBuild macOS runners for GitHub Actions come in 6 vCPU and 12 vCPU sizes at $0.08 and $0.16 a minute. Specs, list prices, and which size fits a job.](/runners/macos/sizes) * [Xcode Image Coverage on macOS GitHub Actions RunnersWarpBuild macOS runners ship three macOS images across five labels. See the Xcode versions and simulator runtimes on each image, prices, and how to pin one.](/runners/macos/xcode-images) * [GitHub Actions Runners with KVM and Nested VirtualizationNested virtualization on WarpBuild Linux x64 GitHub Actions runners exposes /dev/kvm via one runs-on label. Support matrix, rates, and Android emulator setup.](/runners/nested-virtualization) * [Preinstalled Software on WarpBuild RunnersWarpBuild runner images carry the same tooling as the GitHub-hosted image each one is built from. Per-image software lists, version checks, and rates.](/runners/preinstalled-software) * [Snapshot Runners for GitHub ActionsSnapshot runners boot a GitHub Actions job from a saved WarpBuild Ubuntu runner disk, so dependencies, working trees and pulled images are already there.](/runners/snapshot) * [Windows Runners for GitHub Actions: Sizes and PricingWarpBuild runs Windows Server 2022 and 2025 GitHub Actions runners at 4 to 32 vCPU for $0.016 to $0.128 per minute. Labels, images, and cost models.](/runners/windows) * [Visual Studio Images on Windows GitHub Actions RunnersVisual Studio 2026 runs on the warp-windows-2025-vs2026-x64 labels. The Windows Server 2022 and 2025 labels carry Visual Studio 2022\. Labels, rates, config.](/runners/windows/visual-studio-images) --- # Remote Docker Builders: Sizes and Pricing | WarpBuild URL: https://www.warpbuild.com/runners/docker-builders Description: WarpBuild remote Docker builder profiles run from 16 vCPU at $0.06 per minute to 192 vCPU at $0.88 per minute, billed per session. Full catalog and rates. # Remote Docker Builders: Sizes and Pricing WarpBuild remote Docker builder profiles run from 16 vCPU at $0.06 per minute to 192 vCPU at $0.88 per minute, billed per session. Full catalog and rates. Last verified: 2026-08-13 WarpBuild remote Docker builder profiles come in seven sizes, from 16 vCPU with 32GB memory and 100GB disk at $0.06 per minute up to 192 vCPU with 384GB memory and 2TB disk at $0.88 per minute. Each profile maps to one dedicated builder VM with a persistent layer cache, and it bills per session, measured from the first job start to the last job completion on that profile. Two rules shape every size and price decision below. Architecture caps the catalog: arm64 and multi-arch profiles stop at 64 vCPU. Billing is per session rather than per job, so several jobs sharing a profile share one billable stretch of time. ## [Catalog](#catalog)Copy Anchor Link A builder profile is created once in the dashboard or through the API, then referenced from workflows by name. Every size in the catalog, with the architectures each size supports: | Profile size | vCPU | Memory | Disk | Architectures | | ---------------------- | ---- | ------ | ----- | ------------------- | | 16 vCPU, 32GB, 100GB | 16 | 32GB | 100GB | amd64, arm64, multi | | 32 vCPU, 64GB, 200GB | 32 | 64GB | 200GB | amd64, arm64, multi | | 64 vCPU, 128GB, 200GB | 64 | 128GB | 200GB | amd64, arm64, multi | | 96 vCPU, 192GB, 600GB | 96 | 192GB | 600GB | amd64 only | | 96 vCPU, 192GB, 2TB | 96 | 192GB | 2TB | amd64 only | | 192 vCPU, 384GB, 600GB | 192 | 384GB | 600GB | amd64 only | | 192 vCPU, 384GB, 2TB | 192 | 384GB | 2TB | amd64 only | Sizes and rates come from the [WarpBuild Docker builders documentation](/docs/ci/docker-builders) and were verified on 2026-08-13. ### [The architecture limit](#the-architecture-limit)Copy Anchor Link The architecture selected on a builder profile decides which half of the catalog you can reach. arm64 profiles and multi-arch profiles cap at 64 vCPU. The 96 vCPU and 192 vCPU sizes are amd64 only, in both the 600GB and the 2TB disk variants. | Profile architecture | Sizes you can select | Largest profile | Rate at the top size | | ----------------------- | ------------------------ | --------------------- | -------------------- | | amd64 only | 16, 32, 64, 96, 192 vCPU | 192 vCPU, 384GB, 2TB | $0.88 per minute | | arm64 | 16, 32, 64 vCPU | 64 vCPU, 128GB, 200GB | $0.24 per minute | | multi (amd64 and arm64) | 16, 32, 64 vCPU | 64 vCPU, 128GB, 200GB | $0.24 per minute | This limit has a workaround that also changes the bill. Instead of one multi-arch profile, keep two profiles: an amd64-only profile that can grow past 64 vCPU and a separate arm64 profile at 64 vCPU or below. The amd64 image then gets the larger machine, and each profile keeps its own layer cache. Teams building at scale on both architectures already run this shape. Samaya AI runs tens of Docker builds per pull request across ARM64 and x64 on WarpBuild remote Docker builders, serving 10,000 or more seats. ### [Disk is the cache budget](#disk-is-the-cache-budget)Copy Anchor Link The disk column is the size of the persistent layer cache the profile can hold. A profile that caches layers for one service fits comfortably in 100GB. A profile serving a monorepo where a dozen images share base layers and each final image is several gigabytes is what the 600GB and 2TB variants exist for. When the disk fills, older layers are evicted and the builds that needed them run cold again. That cache is the reason the machine is remote in the first place. The cache has a lifetime. A builder profile that is not used for 10 days has its cache reset automatically. Low-traffic profiles, for example one attached to a release workflow that runs monthly, will keep starting cold. Point those workflows at a profile that a busier workflow also uses, so the cache stays warm between releases. ### [Concurrency inside one profile](#concurrency-inside-one-profile)Copy Anchor Link One builder profile is one VM, and several jobs pointing at that profile build on it in parallel. Generally available Linux and Windows runners do not have plan-level concurrency caps; the builder profile is instead constrained by its own machine size. The documented sizing guidance is roughly 8 vCPU and 16GB memory per concurrent build job, which is where the vCPU column turns into a capacity number: a 64 vCPU profile sizes for around eight builds at once, a 192 vCPU profile for around twenty-four. Exceeding that guidance does not fail the build. The builds share cores and each one takes longer, which shows up as a longer session and a larger bill. ## [Pricing](#pricing)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Builder time is billed per minute of session, and the table below turns the per-minute rate into the three numbers teams usually want next. | Profile size | Per minute | Per hour | A 5-minute session | Minutes covered by $10 signup credits | | ---------------------- | ---------- | -------- | ------------------ | ------------------------------------- | | 16 vCPU, 32GB, 100GB | $0.06 | $3.60 | $0.30 | 166 | | 32 vCPU, 64GB, 200GB | $0.12 | $7.20 | $0.60 | 83 | | 64 vCPU, 128GB, 200GB | $0.24 | $14.40 | $1.20 | 41 | | 96 vCPU, 192GB, 600GB | $0.36 | $21.60 | $1.80 | 27 | | 96 vCPU, 192GB, 2TB | $0.52 | $31.20 | $2.60 | 19 | | 192 vCPU, 384GB, 600GB | $0.72 | $43.20 | $3.60 | 13 | | 192 vCPU, 384GB, 2TB | $0.88 | $52.80 | $4.40 | 11 | Signup includes $10 free credits, which is the last column: enough session time on a 16 vCPU profile to measure a real workload before you commit to a size. ### [What counts as a session](#what-counts-as-a-session)Copy Anchor Link A session starts when the builder action starts and ends when the job completes. Multiple concurrent jobs on the same builder profile share one session, billed from the first job start to the last job completion. Take two jobs on the same amd64 profile with overlapping runtimes. Job A takes the builder at minute 0 and finishes at minute 8\. Job B takes the same builder at minute 3 and finishes at minute 12\. That bills as one session of 12 minutes rather than as 8 minutes plus 9 minutes. On a 32 vCPU profile at $0.12 per minute the session costs $1.44. The practical consequence is that a step placed after the build keeps the session open. A push to a slow registry, a scan, or a long post-action step all extend the last job completion time and therefore the session. Move work that does not need the builder out of the window where the builder is held. ### [Two resources, two lines on the bill](#two-resources-two-lines-on-the-bill)Copy Anchor Link The runner and the builder are separate, independent resources, and both are charged. The runner bills per minute at its own catalog rate. The builder bills per session at the profile rate. Worked example: a pull request builds four service images in parallel on one 32 vCPU profile, with each job running on `warp-ubuntu-latest-x64-4x`. | Line item | Rate | Quantity | Cost | | --------------------------- | ----------------- | ---------- | ------ | | Runner jobs, 4 x 7 minutes | $0.008 per minute | 28 minutes | $0.224 | | One shared builder session | $0.12 per minute | 11 minutes | $1.320 | | Total per pull request | $1.544 | | | | 300 pull requests per month | $463.20 | | | Only the runner line has a GitHub-hosted equivalent, since GitHub-hosted runners have no remote builder product to price against. Every WarpBuild rate on this page comes from the [WarpBuild pricing page](/pricing) and the docs, checked on 2026-08-13. ### [Multi-arch billing](#multi-arch-billing)Copy Anchor Link A multi-arch build runs each architecture on its own builder instance, which produces one session per architecture on the same profile. The two sessions are billed independently, and each stays alive until its post-action steps complete. On a 64 vCPU multi-arch profile at $0.24 per minute with a 6-minute build per architecture, that is 2 sessions x 6 minutes x $0.24, or $2.88 of builder time per pull request, plus runner minutes. Budget multi-arch at double the single-architecture session cost, then compare it against splitting the work across two single-architecture profiles where the amd64 side can use a larger machine. ### [Structure of the bill](#structure-of-the-bill)Copy Anchor Link Nothing here requires a plan change or a seat count. Docker builder sessions appear on their own tab under Reports, with each row showing one session, its profile, its architecture, its duration, and its cost, so the profile running long sessions for short builds is easy to spot. SSO is available for a flat $250 per month, whatever the user count. ## [Configuration](#configuration)Copy Anchor Link The builder profile is selected by name from the workflow. Swap `docker/build-push-action` for `Warpbuilds/build-push-action@v6` and pass `profile-name`. Copy Text ``` name: build-image on: push jobs: image: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: registry.example.com/app:${{ github.sha }} profile-name: "app-images-32x" timeout: 600000 ``` Remove any `docker/setup-buildx-action` step that exists only to create a builder, and drop `cache-from` and `cache-to`. A builder profile caches layers on its own disk and reuses them on the next build without cache flags. Multi-arch uses the same action with a `platforms` input, against a profile that has both architectures enabled: Copy Text ``` - name: Build and push multi-arch uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: registry.example.com/app:${{ github.sha }} profile-name: "app-images-multi-64x" ``` ### [Builder size is independent of runner size](#builder-size-is-independent-of-runner-size)Copy Anchor Link The size of the builder profile has no relationship to the size of the GitHub Actions runner that drives it. The runner in the example above is a 4 vCPU machine that checks out the repository and calls the action, while the build itself runs on the 32 vCPU builder. Picking a larger runner will not make the image build faster, and picking a smaller one will not make it cheaper beyond the runner line. The two resources are billed separately, so size each against its own job. Size the runner for checkout, test steps, and whatever else the job does. Size the builder for the images, using the concurrency guidance above. Builders reach beyond WarpBuild Cloud runners. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and a builder profile can also be driven from a GitHub-hosted runner or a BYOC runner by adding an `api-key` input, since BYOC runs on AWS, GCP, and Azure. For the full setup walkthrough, including the bake action and running builds outside GitHub Actions, read the [remote Docker builders guide](/guides/remote-docker-builders). Language-agnostic build patterns and matrix layouts are covered on [Docker builds on GitHub Actions](/solutions/docker-builds-github-actions). If you are still deciding between a registry cache and a remote builder, start with [choosing a Buildx cache backend on GitHub Actions](/guides/buildx-cache-backends-github-actions), and for the concept on its own see [what is a remote Docker builder](/answers/what-is-a-remote-docker-builder). Runner labels and their own rates are in the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners). ## [FAQ](#faq)Copy Anchor Link ### [What is the largest builder profile available for arm64 or multi-arch builds?](#what-is-the-largest-builder-profile-available-for-arm64-or-multi-arch-builds)Copy Anchor Link 64 vCPU with 128GB memory and 200GB disk, at $0.24 per minute. arm64 and multi-arch builder profiles cap at 64 vCPU. The 96 vCPU and 192 vCPU sizes are available for amd64-only profiles. ### [How is a builder profile billed when several jobs build on it at the same time?](#how-is-a-builder-profile-billed-when-several-jobs-build-on-it-at-the-same-time)Copy Anchor Link As one session. A builder session runs from the first job start to the last job completion on that profile, so a fan-out of parallel jobs sharing a profile produces a single billable stretch of time rather than one per job. ### [Does the builder layer cache expire?](#does-the-builder-layer-cache-expire)Copy Anchor Link Yes. A builder profile that goes unused for 10 days has its cache reset automatically. The next build after a reset runs cold and repopulates the cache on the profile disk. ### [Do I pay for the GitHub Actions runner and the builder separately?](#do-i-pay-for-the-github-actions-runner-and-the-builder-separately)Copy Anchor Link Yes. The runner and the builder are two separate resources billed on two lines. The builder profile size is chosen independently of the runner size, so a 4 vCPU runner can drive a 192 vCPU builder profile. --- # Multi Platform Remote Docker Builders | WarpBuild URL: https://www.warpbuild.com/runners/docker-builders/multi-platform Description: Multi platform builder profiles assign one instance per platform, joined into one Buildx instance. Sizes, per session rates, and the emulation check. # Multi Platform Remote Docker Builders Multi platform builder profiles assign one instance per platform, joined into one Buildx instance. Sizes, per session rates, and the emulation check. Last verified: 2026-08-13 A multi platform remote Docker builder profile assigns one builder instance per platform and joins those instances into a single Buildx instance, so `linux/amd64` layers build on an amd64 machine and `linux/arm64` layers build on an arm64 machine with no instruction translation anywhere in the build ([Docker builders documentation](/docs/ci/docker-builders), checked on 2026-08-13). Multi architecture profiles run in three sizes, 16, 32, and 64 vCPU at $0.06, $0.12, and $0.24 per minute, and a two platform build opens one billable session per architecture on the profile. This page is the catalog and price reference for that shape. It lists the sizes a multi architecture profile can select, prices a two platform build at each size, shows the documented two builder join, and gives the signature that appears in a log when one platform quietly falls back to emulation. ## [Catalog](#catalog)Copy Anchor Link A builder profile is created once in the dashboard or through the API, has its architectures selected there, and is then referenced from workflows by name. Architecture selection decides which half of the size catalog the profile can reach. These are the sizes a profile with both amd64 and arm64 enabled can use: | Profile size | vCPU | Memory | Disk | Per minute per session | | --------------------- | ---- | ------ | ----- | ---------------------- | | 16 vCPU, 32GB, 100GB | 16 | 32GB | 100GB | $0.06 | | 32 vCPU, 64GB, 200GB | 32 | 64GB | 200GB | $0.12 | | 64 vCPU, 128GB, 200GB | 64 | 128GB | 200GB | $0.24 | The 96 vCPU and 192 vCPU sizes, in both the 600GB and 2TB disk variants, are available on amd64-only profiles. A multi architecture profile therefore stops at 64 vCPU, 128GB memory, and 200GB disk. Sizes and rates come from the [Docker builders documentation](/docs/ci/docker-builders) and were re-checked on 2026-08-13. ### [One instance per platform](#one-instance-per-platform)Copy Anchor Link The size in that table applies per instance. A 32 vCPU multi architecture profile driving a two platform build gets a 32 vCPU amd64 instance and a 32 vCPU arm64 instance, each with its own TLS certificates, its own disk, and its own persistent layer cache. Buildx sees them as two nodes of one builder, and BuildKit routes each platform's build graph to the node whose native architecture matches. Two consequences follow from separate disks. The amd64 cache never warms an arm64 build, so each architecture pays its own cold start the first time a base image or a dependency layer changes. And the 10 day cache reset applies per profile, so a release-only multi platform workflow that fires monthly starts cold on both instances every time. Rates for every size are on the [pricing page](/pricing). ### [Sizing for two platforms](#sizing-for-two-platforms)Copy Anchor Link The documented sizing guidance is roughly 8 vCPU and 16GB memory per concurrent build job. Because each architecture lands on its own instance, the two halves of a single image do not contend with each other, and the guidance applies to concurrency within one architecture. A 32 vCPU multi profile sizes for about four concurrent amd64 builds and four concurrent arm64 builds at the same time. Fan-out is where the ceiling bites. A pull request that builds six service images for both platforms puts six builds on each instance, which is past the guidance on a 32 vCPU profile. Nothing fails. The builds share cores, both sessions run longer, and the bill grows with the session length. ## [Pricing](#pricing)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Builder time is billed per minute of session, and a two platform build runs two sessions in parallel, so the effective rate while both are alive is double the profile rate. | Profile size | Per minute per session | Both platforms, per minute | 6 minute build on both platforms | Two platform minutes covered by $10 credits | | --------------------- | ---------------------- | -------------------------- | -------------------------------- | ------------------------------------------- | | 16 vCPU, 32GB, 100GB | $0.06 | $0.12 | $0.72 | 83 | | 32 vCPU, 64GB, 200GB | $0.12 | $0.24 | $1.44 | 41 | | 64 vCPU, 128GB, 200GB | $0.24 | $0.48 | $2.88 | 20 | Signup includes $10 free credits, which is the last column: enough two platform build time on a 16 vCPU profile to measure a real image before committing to a size. ### [A pull request priced end to end](#a-pull-request-priced-end-to-end)Copy Anchor Link The runner and the builder are separate resources billed on separate lines. Take one image built for both platforms on every pull request, with a 9 minute job on `warp-ubuntu-latest-x64-4x` driving a 32 vCPU multi architecture profile, and a 7 minute build per architecture. | Line item | Rate | Quantity | Cost | | --------------------------- | ----------------- | --------- | ------ | | Runner job | $0.008 per minute | 9 minutes | $0.072 | | amd64 builder session | $0.12 per minute | 7 minutes | $0.840 | | arm64 builder session | $0.12 per minute | 7 minutes | $0.840 | | Total per pull request | $1.752 | | | | 300 pull requests per month | $525.60 | | | Every WarpBuild rate here comes from the [pricing page](/pricing) and the docs, checked on 2026-08-13\. Only the runner line has a GitHub-hosted equivalent, since GitHub-hosted runners carry no remote builder product to price against. ### [One multi profile or two single architecture profiles](#one-multi-profile-or-two-single-architecture-profiles)Copy Anchor Link The bill is close to identical either way, because either shape runs two instances and two sessions. The difference is the size ceiling and the cache. Two single architecture profiles let the amd64 side select 96 or 192 vCPU while the arm64 side stays at 64 vCPU or below, and each profile keeps its own cache lifetime. One multi profile keeps the workflow to a single `profile-name` and one dashboard entry. Pick the split when the amd64 image is the slow one and wants a machine larger than 64 vCPU. ## [Configuration](#configuration)Copy Anchor Link Enable both architectures on the builder profile first. A profile with one architecture enabled is what produces the failure modes at the end of this section. In GitHub Actions, one step builds both platforms: Copy Text ``` name: image on: push: branches: [main] jobs: build: runs-on: warp-ubuntu-latest-x64-4x permissions: contents: read packages: write steps: - uses: actions/checkout@v5 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push both platforms uses: Warpbuilds/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: ghcr.io/acme/app:${{ github.sha }} profile-name: "app-images-multi-32x" timeout: 600000 ``` Drop `docker/setup-buildx-action` and `docker/setup-qemu-action` from that job. Neither is needed once each platform has a native instance, and a stale `setup-qemu-action` is what turns a misconfigured profile into a slow build instead of a failed one. Drop `cache-from` and `cache-to` as well, since each instance caches layers on its own disk and reuses them on the next build. ### [Joining the two builders by hand](#joining-the-two-builders-by-hand)Copy Anchor Link Outside GitHub Actions, the assignment API returns one builder instance per platform and you join them yourself. The first instance creates the Buildx instance and the second is appended as a node, which is how the single builder ends up carrying both platforms: Copy Text ``` docker buildx create --name "$BUILDER_NAME" \ --node "$BUILDER_ID" \ --driver remote \ --driver-opt "cacert=$CERT_DIR/ca.pem" \ --driver-opt "cert=$CERT_DIR/cert.pem" \ --driver-opt "key=$CERT_DIR/key.pem" \ --use \ tcp://$HOST docker buildx create --name "$BUILDER_NAME" \ --append \ --node "$BUILDER_ID_2" \ --driver remote \ --driver-opt "cacert=$CERT_DIR_2/ca.pem" \ --driver-opt "cert=$CERT_DIR_2/cert.pem" \ --driver-opt "key=$CERT_DIR_2/key.pem" \ --use \ tcp://$HOST_2 ``` Each instance needs its own certificate directory, taken from that instance's details response. Billing runs until the session is completed through the API, so pair every assignment with a completion call ([Docker builders documentation](/docs/ci/docker-builders)). ### [When a platform falls back to emulation](#when-a-platform-falls-back-to-emulation)Copy Anchor Link A profile with only amd64 enabled has no aarch64 instance to route the arm64 graph to. What happens next depends on whether a binfmt handler is registered in the job. With no handler, the first `RUN` step of the arm64 stage fails with `exec format error`, which the WarpBuild docs list as the signature of a profile missing an architecture. With a handler registered, usually by a leftover `docker/setup-qemu-action` step, the arm64 stage runs under QEMU user mode emulation on the amd64 node, the build succeeds, and the only symptom is a long arm64 stage ([Docker multi-platform build documentation](https://docs.docker.com/build/building/multi-platform/)). Three checks separate the two cases: * `docker buildx ls` lists the platforms of each node, and platforms reachable only through emulation carry a trailing asterisk ([Docker multi-platform build documentation](https://docs.docker.com/build/building/multi-platform/)). * The Docker builder sessions tab under Reports shows one session per architecture for a native two platform build. One session means one instance, which means one architecture is being emulated. * The arm64 stage taking several times the amd64 stage on the same Dockerfile points at emulation rather than at the Dockerfile. For the full size and rate catalog across all seven builder profiles, see [remote Docker builder sizes and pricing](/runners/docker-builders). The native-runner alternative, where each architecture builds on a runner of its own architecture, is covered in [multi platform Docker builds on native runners](/guides/multi-platform-docker-builds), and the tag joining step is in [how do I push a manifest list from GitHub Actions](/answers/how-do-i-push-a-manifest-list-from-github-actions). The short version of this page is [do remote Docker builders support multi platform builds](/answers/do-remote-docker-builders-support-multi-platform-builds). WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and their labels and rates are in the [cloud runners documentation](/docs/ci/cloud-runners). ## [FAQ](#faq)Copy Anchor Link ### [What sizes can a multi platform builder profile use?](#what-sizes-can-a-multi-platform-builder-profile-use)Copy Anchor Link Three: 16 vCPU with 32GB memory and 100GB disk at $0.06 per minute, 32 vCPU with 64GB memory and 200GB disk at $0.12 per minute, and 64 vCPU with 128GB memory and 200GB disk at $0.24 per minute. The 96 vCPU and 192 vCPU sizes are amd64 only, so a multi architecture profile caps at 64 vCPU. ### [How is a two platform build billed?](#how-is-a-two-platform-build-billed)Copy Anchor Link As two sessions on the same profile, one per architecture, each billed at the profile rate until its post-action steps finish. A 6 minute build on both platforms against a 32 vCPU profile is 2 sessions x 6 minutes x $0.12, or $1.44 of builder time, plus the runner minutes on a separate line. ### [How do I tell whether the arm64 half really built natively?](#how-do-i-tell-whether-the-arm64-half-really-built-natively)Copy Anchor Link Check the session count and the platform list. A native two platform build opens two sessions on the profile, one per architecture, and `docker buildx ls` shows both platforms without a trailing asterisk. One session, an asterisk on the second platform, or an `exec format error` in a RUN step all mean the profile has one architecture enabled. --- # Native ARM64 Runners for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/runners/linux-arm64 Description: WarpBuild provides native Linux ARM64 GitHub Actions runners from 2 to 32 vCPU at $0.003 to $0.048 per minute. Labels, prices, images, and setup. # Native ARM64 Runners for GitHub Actions WarpBuild provides native Linux ARM64 GitHub Actions runners from 2 to 32 vCPU at $0.003 to $0.048 per minute. Labels, prices, images, and setup. Last verified: 2026-08-13 WarpBuild runs native Linux ARM64 runners for GitHub Actions in five sizes, from 2 vCPU to 32 vCPU, on Ubuntu 24.04 and Ubuntu 26.04 images. A job moves onto one by changing its `runs-on` label to a `warp-ubuntu-latest-arm64-` value, and the runner bills at $0.003 to $0.048 per minute depending on the size you pick. Native means the ARM64 instructions execute on ARM64 silicon. There is no QEMU translation layer between your compiler and the CPU, so `go build`, `cargo build`, `docker build --platform linux/arm64`, and an ARM64 test suite all run the way they run on an ARM64 production host. This page lists every Linux ARM64 label WarpBuild registers, the per-minute rate for each one, the two image differences worth knowing about before you switch, and three worked cost models. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so a repository that needs both architectures uses one provider and two labels. ## [Catalog](#catalog)Copy Anchor Link Ten Linux ARM64 labels are live. Five carry Ubuntu 24.04, five carry Ubuntu 26.04, and the `latest` labels resolve to the Ubuntu 24.04 line. | Label | OS | vCPU | Memory | Storage | Price | Aliases | | ---------------------------- | ------------ | ---- | ------ | --------- | ------------- | -------------------------- | | warp-ubuntu-latest-arm64-2x | Ubuntu 24.04 | 2 | 8GB | 150GB SSD | $0.003/minute | warp-ubuntu-2404-arm64-2x | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16GB | 150GB SSD | $0.006/minute | warp-ubuntu-2404-arm64-4x | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32GB | 150GB SSD | $0.012/minute | warp-ubuntu-2404-arm64-8x | | warp-ubuntu-latest-arm64-16x | Ubuntu 24.04 | 16 | 64GB | 150GB SSD | $0.024/minute | warp-ubuntu-2404-arm64-16x | | warp-ubuntu-latest-arm64-32x | Ubuntu 24.04 | 32 | 128GB | 150GB SSD | $0.048/minute | warp-ubuntu-2404-arm64-32x | | warp-ubuntu-2604-arm64-2x | Ubuntu 26.04 | 2 | 8GB | 150GB SSD | $0.003/minute | none | | warp-ubuntu-2604-arm64-4x | Ubuntu 26.04 | 4 | 16GB | 150GB SSD | $0.006/minute | none | | warp-ubuntu-2604-arm64-8x | Ubuntu 26.04 | 8 | 32GB | 150GB SSD | $0.012/minute | none | | warp-ubuntu-2604-arm64-16x | Ubuntu 26.04 | 16 | 64GB | 150GB SSD | $0.024/minute | none | | warp-ubuntu-2604-arm64-32x | Ubuntu 26.04 | 32 | 128GB | 150GB SSD | $0.048/minute | none | Memory scales at 4GB per vCPU across the whole ladder, and storage stays at 150GB SSD at every size. Runner storage is ephemeral and is deleted when the runner terminates, so anything you want to keep goes to an artifact, a cache entry, or a registry before the job ends. The full matrix, including the x64, macOS, and Windows lines, lives in the [cloud runners documentation](/docs/ci/cloud-runners). ### [Image contents](#image-contents)Copy Anchor Link The Ubuntu 24.04 ARM64 image is compatible with GitHub's Ubuntu 24.04 ARM64 runner image, and GitHub publishes the tool inventory for that image in the [partner runner images repository](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md). The Ubuntu 26.04 ARM64 image tracks GitHub's Ubuntu 26.04 ARM64 image the same way. Node, Python, Go, Rust, Docker, the AWS CLI, and the rest of the standard inventory are present, so `actions/setup-*` steps resolve against preinstalled versions instead of downloading them. The per-image tool lists are indexed in the [preinstalled software documentation](/docs/ci/preinstalled-software). Tailscale ships on every runner image. The daemon stays stopped until networking is configured for the runner, so an ARM64 job that never touches networking pays no startup cost for it. ### [Two image caveats](#two-image-caveats)Copy Anchor Link **The work dir differs from GitHub's.** The Ubuntu 24.04 ARM64 images set the work dir to `/runner/_work`. GitHub-hosted ARM64 runners use `/home/runner/work/` for the same image. Any workflow, script, or Dockerfile that hardcodes `/home/runner/work/...` breaks on this image. Read `GITHUB_WORKSPACE`, `RUNNER_WORKSPACE`, or `${{ github.workspace }}` and the same workflow runs on GitHub-hosted runners and on WarpBuild runners without a branch in the logic. **Ubuntu 22.04 ARM64 is gone.** The ARM64 images for Ubuntu 22.04 were deprecated on March 31, 2025\. There is no `warp-ubuntu-2204-arm64-` label to fall back to. Jobs that were pinned to Ubuntu 22.04 on ARM64 move to `warp-ubuntu-latest-arm64-` for Ubuntu 24.04 or to `warp-ubuntu-2604-arm64-` for Ubuntu 26.04\. The Ubuntu 22.04 x64 labels still exist, so a mixed repository can hold its x64 jobs on 22.04 while the ARM64 jobs move forward. ### [What runs on ARM64 and what does not](#what-runs-on-arm64-and-what-does-not)Copy Anchor Link Cache is enabled by default on Linux runners, ARM64 included, so `actions/cache` and the dependency caching built into `actions/setup-*` work with no extra configuration. Snapshot runners cover Linux x64 and Linux ARM64, which means an ARM64 job can boot from a captured VM state instead of reinstalling a heavy toolchain on every run. Nested virtualization is the exception. Workloads that need `/dev/kvm`, such as Android emulator jobs, are not currently supported on ARM64 runners. Those jobs belong on the x86-64 runners, where the `nested-virtualization.enabled=true` dynamic label grants KVM access. If you need nested virtualization on ARM64, contact support before you plan the migration. ## [Pricing](#pricing)Copy Anchor Link ARM64 costs less per minute than x64 at every size in the WarpBuild catalog. The table below puts the two ladders side by side so the delta at each size is visible. | vCPU | ARM64 label | ARM64 $/minute | x64 label | x64 $/minute | Delta per minute | | ---- | ---------------------------- | -------------- | -------------------------- | ------------ | ---------------- | | 2 | warp-ubuntu-latest-arm64-2x | $0.003 | warp-ubuntu-latest-x64-2x | $0.004 | $0.001 | | 4 | warp-ubuntu-latest-arm64-4x | $0.006 | warp-ubuntu-latest-x64-4x | $0.008 | $0.002 | | 8 | warp-ubuntu-latest-arm64-8x | $0.012 | warp-ubuntu-latest-x64-8x | $0.016 | $0.004 | | 16 | warp-ubuntu-latest-arm64-16x | $0.024 | warp-ubuntu-latest-x64-16x | $0.032 | $0.008 | | 32 | warp-ubuntu-latest-arm64-32x | $0.048 | warp-ubuntu-latest-x64-32x | $0.064 | $0.016 | Memory and storage are identical between the two ladders at each size, so the delta is architecture pricing rather than a smaller machine. ### [Against GitHub's published arm64 rates](#against-githubs-published-arm64-rates)Copy Anchor Link GitHub publishes its own per-minute rates for arm64 runners on the [GitHub Actions per-minute rate list](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The rates below were checked on 2026-08-13. | vCPU | WarpBuild $/minute | GitHub-hosted arm64 $/minute | Difference per minute | Difference per 10,000 minutes | | ---- | ------------------ | ----------------------------- | --------------------- | ----------------------------- | | 2 | $0.003 | $0.005 (actions\_linux\_arm) | $0.002 | $20.00 | | 4 | $0.006 | $0.008 (linux\_4\_core\_arm) | $0.002 | $20.00 | | 8 | $0.012 | $0.014 (linux\_8\_core\_arm) | $0.002 | $20.00 | | 16 | $0.024 | $0.026 (linux\_16\_core\_arm) | $0.002 | $20.00 | | 32 | $0.048 | $0.050 (linux\_32\_core\_arm) | $0.002 | $20.00 | The gap holds at $0.002 per minute across the ladder, which works out to $20 for every 10,000 runner minutes at any size. As a share of GitHub's rate that is 40 percent lower list price at 2 vCPU, 25 percent at 4, 14 percent at 8, 8 percent at 16, and 4 percent at 32\. Two billing details from GitHub's [billing concepts page](https://docs.github.com/en/billing/concepts/product-billing/github-actions) shape the real bill: a plan's included minutes stop applying once a job lands on one of GitHub's larger runners, and GitHub charges for larger runner minutes on public repositories as well. Minutes count alongside the rate. WarpBuild pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. The full rate card, including cache and snapshot line items, is on the [pricing page](/pricing). ### [Worked model: a Go service test suite](#worked-model-a-go-service-test-suite)Copy Anchor Link A backend team runs 1,800 jobs a month at roughly 7 minutes each, which is 12,600 runner minutes. The suite is architecture agnostic and the service already deploys to ARM64 hosts. * `warp-ubuntu-latest-arm64-4x` at $0.006 per minute: 12,600 x $0.006 = **$75.60 per month** * `warp-ubuntu-latest-x64-4x` at $0.008 per minute: 12,600 x $0.008 = **$100.80 per month** * GitHub-hosted Linux 4-core arm64 at $0.008 per minute: 12,600 x $0.008 = **$100.80 per month** (GitHub pricing, checked 2026-08-13) Moving the label from x64 to ARM64 takes the monthly runner line from $100.80 to $75.60, a difference of $25.20 per month and $302.40 over a year on the same job count and the same minutes. Signup includes $10 free credits, which covers 1,667 minutes on `warp-ubuntu-latest-arm64-4x` or 3,333 minutes on `warp-ubuntu-latest-arm64-2x` before a card is charged. ### [Worked model: a multi-arch container image](#worked-model-a-multi-arch-container-image)Copy Anchor Link A release pipeline publishes `linux/amd64` and `linux/arm64` images on every push to `main`, at 700 pushes a month. Each architecture leg takes about 9 minutes on an 8 vCPU runner, and a short manifest job stitches the two digests together. * amd64 leg on `warp-ubuntu-latest-x64-8x`: 9 x $0.016 = $0.144 * arm64 leg on `warp-ubuntu-latest-arm64-8x`: 9 x $0.012 = $0.108 * manifest job on `warp-ubuntu-latest-arm64-2x`: 1 x $0.003 = $0.003 * per push: $0.255, or **$178.50 per month** across 700 pushes Now price the emulated alternative, where both platforms build on one `warp-ubuntu-latest-x64-8x` at $0.016 per minute and the arm64 layers go through QEMU. The native arm64 leg costs $0.108\. An emulated arm64 leg on the x64 runner reaches $0.108 at 6.75 minutes. So the emulated build costs more than the native build from 6 minutes 45 seconds onward, before counting the extra wall-clock time the pipeline holds open. That break-even sits below the 9 minutes the leg already takes natively. ### [Worked model: the size ladder at fixed volume](#worked-model-the-size-ladder-at-fixed-volume)Copy Anchor Link The same 10,000 minutes a month, priced at each ARM64 size, with GitHub's published arm64 rate beside it for reference (GitHub pricing, checked 2026-08-13). | Runner | WarpBuild monthly | GitHub-hosted arm64 monthly | Monthly difference | | ------- | ----------------- | --------------------------- | ------------------ | | 2 vCPU | $30.00 | $50.00 | $20.00 | | 4 vCPU | $60.00 | $80.00 | $20.00 | | 8 vCPU | $120.00 | $140.00 | $20.00 | | 16 vCPU | $240.00 | $260.00 | $20.00 | | 32 vCPU | $480.00 | $500.00 | $20.00 | Sizing up multiplies the rate but keeps the same $20 per 10,000 minutes gap, so the decision about which size to run is a wall-clock decision rather than a pricing one. If a 16 vCPU runner finishes a job in fewer than half the minutes an 8 vCPU runner needs, the larger label costs less per job. Time that on your own workload before committing a size across the repository. ## [Configuration](#configuration)Copy Anchor Link Switching a job to ARM64 is a `runs-on` change. Nothing else in the workflow file has to move. Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-arm64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.24" cache: true - run: go build ./... - run: go test -race ./... ``` Use the versioned label when you want the OS pinned. `warp-ubuntu-2604-arm64-8x` stays on Ubuntu 26.04 through image updates, while `warp-ubuntu-latest-arm64-8x` follows the Ubuntu 24.04 line and its alias `warp-ubuntu-2404-arm64-8x` says the same thing explicitly. Teams with reproducibility requirements pin the versioned label and schedule their own upgrade. ### [Building both architectures natively](#building-both-architectures-natively)Copy Anchor Link A matrix over runner labels gives each architecture its own native machine and keeps the build definition in one job. Copy Text ``` name: images on: push: branches: [main] jobs: build: strategy: fail-fast: false matrix: include: - arch: amd64 runner: warp-ubuntu-latest-x64-8x - arch: arm64 runner: warp-ubuntu-latest-arm64-8x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v6 with: context: . platforms: linux/${{ matrix.arch }} push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }}-${{ matrix.arch }} manifest: needs: build runs-on: warp-ubuntu-latest-arm64-2x steps: - uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - run: | docker buildx imagetools create \ --tag ghcr.io/${{ github.repository }}:${{ github.sha }} \ ghcr.io/${{ github.repository }}:${{ github.sha }}-amd64 \ ghcr.io/${{ github.repository }}:${{ github.sha }}-arm64 ``` The manifest job does registry API work and no compilation, so the 2 vCPU label at $0.003 per minute is the right size for it. The [multi-platform Docker build guide](/guides/multi-platform-docker-builds) covers cache scoping and digest-based manifests for this pattern in more depth. ### [Handling the work dir difference](#handling-the-work-dir-difference)Copy Anchor Link The workspace path is the one thing that surprises people on their first ARM64 run. Ask the runner for the path instead of assuming it. Copy Text ``` jobs: package: runs-on: warp-ubuntu-latest-arm64-8x steps: - uses: actions/checkout@v4 - name: Report workspace paths run: | echo "GITHUB_WORKSPACE=$GITHUB_WORKSPACE" echo "RUNNER_WORKSPACE=$RUNNER_WORKSPACE" - run: make dist - uses: actions/upload-artifact@v4 with: name: dist-arm64 path: ${{ github.workspace }}/dist ``` On the Ubuntu 24.04 ARM64 image `GITHUB_WORKSPACE` resolves under `/runner/_work`, and on a GitHub-hosted runner it resolves under `/home/runner/work/`. A workflow that reads the variable produces the same artifact on both. Grep your repository for `/home/runner/work` before the switch: Dockerfiles with absolute `COPY` paths, cache keys built from absolute paths, and shell scripts that `cd` into a fixed directory are the usual offenders. ### [Native builds against emulated builds](#native-builds-against-emulated-builds)Copy Anchor Link Emulation is how a single x64 runner produces an ARM64 artifact. QEMU registers with `binfmt_misc`, and every ARM64 instruction the build emits is translated to x64 at run time. Compilers, linkers, and test suites are instruction heavy by nature, which is why an emulated build spends its time in the translation layer rather than in the toolchain. Native ARM64 runners remove that layer: the toolchain issues ARM64 instructions and the CPU executes them. Correctness is the second reason teams move. An emulated build can hide architecture-specific behavior around atomics, page size, unaligned access, and CPU feature detection, and those defects then surface in production on ARM64 hosts. A native ARM64 runner exercises the same instruction set your servers run. The multi-arch cost model above shows where the pricing crossover between a native leg and an emulated leg sits. The [ARM64 migration guide](/guides/arm64-github-actions-runners) walks through the move job by job, including how to keep an x64 job and an ARM64 job green in the same workflow while you cut over. ### [Beyond WarpBuild-hosted ARM64](#beyond-warpbuild-hosted-arm64)Copy Anchor Link If the runners have to live in your own cloud account, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. The label contract is the same, so a workflow that already points at ARM64 labels does not change shape when the capacity moves into your account. ### [Where to go next](#where-to-go-next)Copy Anchor Link * [Migrate a GitHub Actions workflow to ARM64 runners](/guides/arm64-github-actions-runners): the job-by-job cutover, including toolchain gaps to check first. * [Build multi-platform Docker images on native runners](/guides/multi-platform-docker-builds): matrix builds, cache scoping, and manifest assembly. * [Run Go builds and tests on GitHub Actions](/solutions/go-github-actions): sizing and caching for Go repositories on both architectures. * [Model the cost of larger GitHub Actions runners](/guides/github-actions-larger-runners-cost): how size choices change the monthly bill. * [WarpBuild pricing](/pricing): the full per-minute rate card and the $10 signup credits. ## [FAQ](#faq)Copy Anchor Link **What do WarpBuild ARM64 runners cost per minute?** Linux ARM64 runners cost $0.003 per minute at 2 vCPU, $0.006 at 4 vCPU, $0.012 at 8 vCPU, $0.024 at 16 vCPU, and $0.048 at 32 vCPU. Every size is $0.002 per minute below the rate GitHub publishes for its own arm64 runner at the same core count, checked on 2026-08-13. **Do WarpBuild ARM64 runners use the same work directory as GitHub-hosted runners?** No. The Ubuntu 24.04 ARM64 images set the work dir to `/runner/_work`. GitHub-hosted ARM64 runners use `/home/runner/work/` for the same image. Read the path from `GITHUB_WORKSPACE` or `RUNNER_WORKSPACE` instead of hardcoding it, and the workflow runs unchanged on both. **Can I still run Ubuntu 22.04 ARM64 images?** No. The ARM64 images for Ubuntu 22.04 were deprecated on March 31, 2025\. Move those jobs to `warp-ubuntu-latest-arm64-` for Ubuntu 24.04 or `warp-ubuntu-2604-arm64-` for Ubuntu 26.04\. The Ubuntu 22.04 x64 labels are unaffected. **Does nested virtualization work on ARM64 runners?** Workloads that need nested virtualization, such as Android emulator jobs, are not currently supported on ARM64 runners. Run those jobs on the x86-64 runners with the `nested-virtualization.enabled=true` label, and contact support if you need nested virtualization on ARM64. --- # Linux x64 Runners for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/runners/linux-x64 Description: WarpBuild runs Linux x64 GitHub Actions runners at 2 to 32 vCPU for $0.004 to $0.064 per minute on Ubuntu 24.04, 26.04, and 22.04. Labels and prices. # Linux x64 Runners for GitHub Actions WarpBuild runs Linux x64 GitHub Actions runners at 2 to 32 vCPU for $0.004 to $0.064 per minute on Ubuntu 24.04, 26.04, and 22.04\. Labels and prices. Last verified: 2026-08-13 WarpBuild runs Linux x64 runners for GitHub Actions in five sizes, 2, 4, 8, 16, and 32 vCPU, priced from $0.004 to $0.064 per minute. A job moves onto one by setting `runs-on` to a `warp-ubuntu-latest-x64-` label, and three Ubuntu lines are live at every size: 24.04, 26.04, and 22.04. Memory scales at 4GB per vCPU across the whole ladder, and storage stays at 150GB SSD from the smallest label to the largest. The x64 images carry the same tooling as GitHub-hosted runners, so a workflow usually changes one line and keeps every step it already had. This page lists all 15 Linux x64 labels with their shapes and prices, puts each size against GitHub's published rate for the same shape, maps common workloads onto rows in the ladder, and shows the dynamic label modifiers that Linux x64 runners accept. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so a repository that spans platforms uses one provider and a set of labels. ## [Catalog](#catalog)Copy Anchor Link Fifteen Linux x64 labels are live: five on Ubuntu 24.04, five on Ubuntu 26.04, and five on Ubuntu 22.04\. The `latest` labels resolve to the Ubuntu 24.04 line. | Label | OS | vCPU | Memory | Storage | Price | Aliases | | -------------------------- | ------------ | ---- | ------ | --------- | ------------- | ------------------------ | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8GB | 150GB SSD | $0.004/minute | warp-ubuntu-2404-x64-2x | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16GB | 150GB SSD | $0.008/minute | warp-ubuntu-2404-x64-4x | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32GB | 150GB SSD | $0.016/minute | warp-ubuntu-2404-x64-8x | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64GB | 150GB SSD | $0.032/minute | warp-ubuntu-2404-x64-16x | | warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128GB | 150GB SSD | $0.064/minute | warp-ubuntu-2404-x64-32x | | warp-ubuntu-2604-x64-2x | Ubuntu 26.04 | 2 | 8GB | 150GB SSD | $0.004/minute | none | | warp-ubuntu-2604-x64-4x | Ubuntu 26.04 | 4 | 16GB | 150GB SSD | $0.008/minute | none | | warp-ubuntu-2604-x64-8x | Ubuntu 26.04 | 8 | 32GB | 150GB SSD | $0.016/minute | none | | warp-ubuntu-2604-x64-16x | Ubuntu 26.04 | 16 | 64GB | 150GB SSD | $0.032/minute | none | | warp-ubuntu-2604-x64-32x | Ubuntu 26.04 | 32 | 128GB | 150GB SSD | $0.064/minute | none | | warp-ubuntu-2204-x64-2x | Ubuntu 22.04 | 2 | 8GB | 150GB SSD | $0.004/minute | none | | warp-ubuntu-2204-x64-4x | Ubuntu 22.04 | 4 | 16GB | 150GB SSD | $0.008/minute | none | | warp-ubuntu-2204-x64-8x | Ubuntu 22.04 | 8 | 32GB | 150GB SSD | $0.016/minute | none | | warp-ubuntu-2204-x64-16x | Ubuntu 22.04 | 16 | 64GB | 150GB SSD | $0.032/minute | none | | warp-ubuntu-2204-x64-32x | Ubuntu 22.04 | 32 | 128GB | 150GB SSD | $0.064/minute | none | The price for a given size is the same on all three Ubuntu lines, so choosing 22.04 over 24.04 for an old toolchain costs nothing extra. The full matrix, including the ARM64, macOS, and Windows lines, is in the [cloud runners documentation](/docs/ci/cloud-runners). Cloud spot labels were removed on June 8, 2026, so there is no spot row in this catalog. Teams that want spot capacity run BYOC, which runs on AWS, GCP, and Azure and bills the compute through their own cloud account. Terraform support exists for BYOC on AWS. ### [Image contents](#image-contents)Copy Anchor Link The Linux x86-64 runner images have the same tooling installed as GitHub-hosted runners, which is why `actions/setup-node`, `actions/setup-python`, `actions/setup-go`, and the rest resolve against preinstalled versions instead of downloading a toolchain on every job. GitHub publishes the per-image inventories for Ubuntu 22.04, 24.04, and 26.04, and those lists are indexed from the [preinstalled software documentation](/docs/ci/preinstalled-software). Two additions sit on top of the GitHub inventory. Container images are cached on the runner for faster access, and Tailscale is preinstalled on every image with the daemon left stopped until networking is configured for that runner. Runner storage is ephemeral and is deleted when the runner terminates. Anything a later job needs goes to an artifact, a cache entry, a registry, or a snapshot before the job ends. ### [Sizing by workload shape](#sizing-by-workload-shape)Copy Anchor Link Memory and disk are fixed functions of the size you pick, so sizing is a decision about vCPU and wall clock. The table below maps common GitHub Actions workloads onto rows in the ladder. | Workload | Label | Why this row | | --------------------------------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------ | | Lint, formatting, small unit suites, release tag and manifest jobs | warp-ubuntu-latest-x64-2x | 2 vCPU and 8GB finish the work; the job is dominated by checkout and setup | | Service test suites, Node and Go builds, a single Docker image build | warp-ubuntu-latest-x64-4x | 16GB holds a test database and a language server without swapping | | Monorepo test shards, integration tests with service containers, Rust and C++ compiles | warp-ubuntu-latest-x64-8x | 8 parallel workers with 4GB each, which matches most default test runner concurrency | | Heavy parallel suites, large compiles, Android emulator jobs with nested virtualization | warp-ubuntu-latest-x64-16x | 64GB absorbs emulator and JVM memory alongside 16 workers | | Full monorepo builds in one job, link-heavy native builds | warp-ubuntu-latest-x64-32x | 128GB and 32 vCPU replace a fan-out matrix with one long job | Every row carries 150GB SSD, so disk pressure alone never forces a size bump. When a build fills the disk, the fix is usually pruning Docker layers or scoping the cache rather than moving up the ladder. The rate doubles at every step of the ladder while the vCPU count doubles too, which makes the arithmetic simple: moving from `warp-ubuntu-latest-x64-4x` to `warp-ubuntu-latest-x64-8x` costs the same per job when the larger runner halves the wall clock, and costs less per job when it does better than half. Time one real job at two sizes before standardizing on a size across the repository. The [runner size answer page](/answers/how-do-i-choose-a-github-actions-runner-size) walks through that measurement. ## [Pricing](#pricing)Copy Anchor Link Every Linux x64 size is priced below GitHub's published rate for the same vCPU and memory shape. The rates in the table were read from GitHub's [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and checked on 2026-08-13. | vCPU | WarpBuild label | WarpBuild $/minute | GitHub-hosted $/minute (SKU) | Lower list price | Cost per 10,000 minutes | | ---- | -------------------------- | ------------------ | ---------------------------- | ---------------- | ----------------------- | | 2 | warp-ubuntu-latest-x64-2x | $0.004 | $0.006 (actions\_linux) | 33 percent | $40.00 against $60.00 | | 4 | warp-ubuntu-latest-x64-4x | $0.008 | $0.012 (linux\_4\_core) | 33 percent | $80.00 against $120.00 | | 8 | warp-ubuntu-latest-x64-8x | $0.016 | $0.022 (linux\_8\_core) | 27 percent | $160.00 against $220.00 | | 16 | warp-ubuntu-latest-x64-16x | $0.032 | $0.042 (linux\_16\_core) | 24 percent | $320.00 against $420.00 | | 32 | warp-ubuntu-latest-x64-32x | $0.064 | $0.082 (linux\_32\_core) | 22 percent | $640.00 against $820.00 | The arithmetic behind the fourth column is the difference over the GitHub rate. At 8 vCPU that is (0.022 - 0.016) / 0.022 = 27 percent lower list price; at 32 vCPU it is (0.082 - 0.064) / 0.082 = 22 percent lower list price. The 2 vCPU row compares against GitHub's private-repository `ubuntu-latest` shape of 2 vCPU and 8GB, which is the shape paying teams run. GitHub gives public repositories a larger free shape, so the 2 vCPU row is not the right comparison for an open source repository. Two GitHub billing details change the real bill beyond the per-minute number. A plan's included minutes stop applying once a job lands on one of GitHub's larger runners, and larger runner minutes are charged on public repositories as well. Both are documented on GitHub's [Actions billing concepts page](https://docs.github.com/en/billing/concepts/product-billing/github-actions), checked on 2026-08-13. Minutes matter as much as the rate. The complete rate card, including cache and snapshot line items, is on the [pricing page](/pricing). ### [Worked model: a monorepo test suite](#worked-model-a-monorepo-test-suite)Copy Anchor Link A platform team runs 2,500 jobs a month at about 9 minutes each on an 8 vCPU runner, which is 22,500 runner minutes. * `warp-ubuntu-latest-x64-8x` at $0.016 per minute: 22,500 x $0.016 = **$360.00 per month** * GitHub-hosted 8-core Linux larger runner at $0.022 per minute: 22,500 x $0.022 = **$495.00 per month** (GitHub pricing, checked 2026-08-13) The monthly difference is $135.00 on identical minutes, or $1,620.00 across a year, before any change in how long the jobs take. If the team then moves the same suite to `warp-ubuntu-latest-x64-16x` and the wall clock drops from 9 minutes to 4.5, the bill lands at 11,250 x $0.032 = $360.00, unchanged. Below 4.5 minutes the larger label is the cheaper one per job, and the pipeline returns results sooner. The [larger runner cost guide](/guides/github-actions-larger-runners-cost) works this crossover through several job mixes. ### [What the bill does and does not include](#what-the-bill-does-and-does-not-include)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits, which covers 2,500 minutes on `warp-ubuntu-latest-x64-2x` or 625 minutes on `warp-ubuntu-latest-x64-8x` before a card is charged. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a merge queue that fans out to 200 jobs does not queue behind a seat or a runner group limit. ## [Configuration](#configuration)Copy Anchor Link Moving a job onto a Linux x64 runner is a `runs-on` change. Nothing else in the workflow file has to move. Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "22" cache: npm - run: npm ci - run: npm run test -- --maxWorkers=8 ``` ### [How the latest labels behave](#how-the-latest-labels-behave)Copy Anchor Link `warp-ubuntu-latest-x64-` follows the Ubuntu 24.04 line, and `warp-ubuntu-2404-x64-` is its explicit alias. The two labels schedule the same machine at the same rate, so a repository can use whichever reads better in review. The `latest` pointer moves when the Ubuntu line behind it moves. Teams with reproducibility requirements pin the versioned label instead: `warp-ubuntu-2604-x64-16x` stays on Ubuntu 26.04 and `warp-ubuntu-2204-x64-16x` stays on Ubuntu 22.04 through image updates, which turns an OS upgrade into a pull request rather than a surprise on a Tuesday morning. A practical pattern for a mixed repository is to pin the jobs with native dependencies and leave everything else on `latest`. That way a base image change can only break the jobs you already know are sensitive to it. ### [Dynamic label modifiers](#dynamic-label-modifiers)Copy Anchor Link Linux x64 runners accept modifiers appended to the label with a semicolon. Three are available on this platform. Copy Text ``` jobs: android-tests: runs-on: warp-ubuntu-latest-x64-16x;nested-virtualization.enabled=true api-tests: runs-on: warp-ubuntu-latest-x64-8x;snapshot.key=api-deps deploy-smoke: runs-on: warp-ubuntu-latest-x64-4x;network.name=production-tailnet;snapshot.enabled=true ``` `nested-virtualization.enabled=true` provisions the job on hardware that exposes `/dev/kvm`, which Android emulator and QEMU workloads need. Android emulator jobs also need a permissions step before the emulator action, because the default device permissions keep the `runner` user from opening the device: Copy Text ``` - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm ``` `snapshot.enabled=true` boots the runner from the base image and lets the `snapshot-save` action capture the VM at a chosen point in the workflow. `snapshot.key=` boots from an existing snapshot for that alias when one exists and falls back to the base image when it does not. The usual shape is `snapshot.enabled=true` on `main` to refresh the snapshot and `snapshot.key=` on pull requests to boot from it. Details are on the [snapshot runners page](/runners/snapshot). `network.name=` attaches the runner to a network addon configuration, which is how a job reaches a private service over Tailscale. Modifiers combine on one label, as the `deploy-smoke` job above shows. A modifier that a runner type does not support is silently ignored, so a shared workflow can carry a modifier that only some jobs use. ### [Around the runners](#around-the-runners)Copy Anchor Link Cache is enabled by default on Linux runners, so `actions/cache` and the caching built into the `actions/setup-*` family work with no extra configuration. If the runners have to live in your own cloud account, BYOC runs on AWS, GCP, and Azure with the same label contract, and Terraform support exists for BYOC on AWS. A workflow that already points at `warp-ubuntu-latest-x64-` keeps its shape when the capacity moves into your account. ### [Where to go next](#where-to-go-next)Copy Anchor Link * [Native Linux ARM64 runners for GitHub Actions](/runners/linux-arm64): the same five sizes on ARM64 silicon, priced from $0.003 to $0.048 per minute. * [Snapshot runners that boot from a captured VM state](/runners/snapshot): how the `snapshot.key` label removes repeated toolchain installs. * [Model the cost of larger GitHub Actions runners](/guides/github-actions-larger-runners-cost): the wall-clock crossover between sizes, worked through several job mixes. * [How to choose a GitHub Actions runner size](/answers/how-do-i-choose-a-github-actions-runner-size): a short decision procedure with the measurements to take first. * [WarpBuild pricing](/pricing): the full per-minute rate card and the $10 signup credits. ## [FAQ](#faq)Copy Anchor Link **What Linux x64 runner sizes does WarpBuild offer?** Five sizes on each Ubuntu line. 2 vCPU with 8GB at $0.004 per minute, 4 vCPU with 16GB at $0.008, 8 vCPU with 32GB at $0.016, 16 vCPU with 64GB at $0.032, and 32 vCPU with 128GB at $0.064\. Every size carries 150GB SSD. **What does `warp-ubuntu-latest-x64-8x` resolve to?** Ubuntu 24.04 with 8 vCPU and 32GB of memory. Its explicit alias is `warp-ubuntu-2404-x64-8x`, and the two labels schedule the same machine. Use `warp-ubuntu-2604-x64-8x` or `warp-ubuntu-2204-x64-8x` when you want the OS version pinned through image updates. **How much disk does a Linux x64 runner have, and does it persist?** Every Linux x64 label carries 150GB SSD, from the 2 vCPU size to the 32 vCPU size. Runner storage is ephemeral and is deleted when the runner terminates, so anything you need after the job ends goes to an artifact, a cache entry, a registry, or a snapshot. **How do I get `/dev/kvm` for Android emulator jobs?** Add `nested-virtualization.enabled=true` to the `runs-on` label on a Linux x64 runner, then add the KVM group permissions step before the emulator action. Nested virtualization is available on Linux x64 cloud runners and on BYOC instance types that support it. --- # Ubuntu Versions on Linux x64 GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/runners/linux-x64/ubuntu-versions Description: WarpBuild runs Ubuntu 22.04, 24.04, and 26.04 on Linux x64 GitHub Actions runners at $0.004 to $0.064 per minute. Labels, alias behavior, and migration. # Ubuntu Versions on Linux x64 GitHub Actions Runners WarpBuild runs Ubuntu 22.04, 24.04, and 26.04 on Linux x64 GitHub Actions runners at $0.004 to $0.064 per minute. Labels, alias behavior, and migration. Last verified: 2026-08-13 WarpBuild runs three Ubuntu lines on its Linux x64 GitHub Actions runners, 22.04, 24.04, and 26.04, and every line is available in all five machine sizes from 2 to 32 vCPU. The `warp-ubuntu-latest-x64-` labels resolve to Ubuntu 24.04 today, while the dated `warp-ubuntu-2204-x64-` and `warp-ubuntu-2604-x64-` labels hold a version in place when that default moves. This page lists all 15 Linux x64 labels grouped by Ubuntu line, explains what the `latest` selector tracks and when pinning is the safer choice, prices two sizes against GitHub's published list prices for the same shape, and gives the checklist for moving a job from one Ubuntu line to another. Catalog rows, image versions, and rates were checked on 2026-08-13. ## [Catalog](#catalog)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. On Linux x64, one set of five machine shapes is published across three Ubuntu lines, so a label selects an Ubuntu version and a size independently. | Size | vCPU | Memory | Ubuntu 24.04 label | Ubuntu 26.04 label | Ubuntu 22.04 label | Per minute | | ---- | ---- | ------ | -------------------------- | ------------------------ | ------------------------ | ---------- | | 2x | 2 | 8GB | warp-ubuntu-latest-x64-2x | warp-ubuntu-2604-x64-2x | warp-ubuntu-2204-x64-2x | $0.004 | | 4x | 4 | 16GB | warp-ubuntu-latest-x64-4x | warp-ubuntu-2604-x64-4x | warp-ubuntu-2204-x64-4x | $0.008 | | 8x | 8 | 32GB | warp-ubuntu-latest-x64-8x | warp-ubuntu-2604-x64-8x | warp-ubuntu-2204-x64-8x | $0.016 | | 16x | 16 | 64GB | warp-ubuntu-latest-x64-16x | warp-ubuntu-2604-x64-16x | warp-ubuntu-2204-x64-16x | $0.032 | | 32x | 32 | 128GB | warp-ubuntu-latest-x64-32x | warp-ubuntu-2604-x64-32x | warp-ubuntu-2204-x64-32x | $0.064 | Source: [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. Every row carries 150GB SSD regardless of Ubuntu line or size, memory scales at 4GB per vCPU, and runner storage is ephemeral and is deleted when the runner terminates. ### [What the latest selector resolves to](#what-the-latest-selector-resolves-to)Copy Anchor Link Each Ubuntu 24.04 label has a dated twin. `warp-ubuntu-latest-x64-8x` and `warp-ubuntu-2404-x64-8x` schedule the same machine, and the same pairing holds at 2x, 4x, 16x, and 32x. The Ubuntu 26.04 and Ubuntu 22.04 labels carry no alias, because a dated label already names its version exactly. The `latest` selector follows GitHub's default for `ubuntu-latest` rather than moving to the newest published Ubuntu on its own. The WarpBuild changelog recorded that behavior when Ubuntu 24.04 first shipped: the `warp-ubuntu-latest-x64-` labels continued to point at Ubuntu 22.04 until GitHub changed the default runner version for `ubuntu-latest`. That pointer has since moved to Ubuntu 24.04, which is the practical reason to treat `latest` as a value that changes under a workflow rather than a fixed target. ### [Why release jobs pin a version](#why-release-jobs-pin-a-version)Copy Anchor Link A pull request lane on `latest` is usually the right default, because it keeps the fleet aligned with the GitHub-hosted baseline your contributors already assume. A release lane is a different job. Anything that signs an artifact, links against system libraries, or produces a binary you support for months should name `warp-ubuntu-2404-x64-` or `warp-ubuntu-2604-x64-` directly, so the Ubuntu version that produced the artifact is readable from the workflow file and survives a default switch that lands without a code change. ### [Image versions and update cadence](#image-versions-and-update-cadence)Copy Anchor Link Each Ubuntu line is rebuilt from the upstream GitHub runner image releases, and every rebuild is recorded in the [WarpBuild changelog](/docs/ci/changelog). The August 12, 2026 entry lists `ubuntu22/20260810.260` for Ubuntu 22.04, `ubuntu24/20260810.271` for Ubuntu 24.04, and `ubuntu26/20260810.99` for Ubuntu 26.04. Those strings are the unit to quote in a bug report. A job that passed last week and fails today on an unchanged label usually crossed one of those rebuilds, which turns the question into a diff between two published image manifests instead of an argument about whether Ubuntu changed. ## [Pricing](#pricing)Copy Anchor Link The per-minute rate on a Linux x64 runner depends only on the machine shape. Ubuntu 22.04, 24.04, and 26.04 cost the same at every size, so choosing an Ubuntu line is a compatibility decision with no effect on the bill. | Size | vCPU | Memory | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | Lower list price | Minutes on $10 credits | | ---- | ---- | ------ | -------------------- | ------------------------------------------- | ----------------- | ---------------- | ---------------------- | | 4x | 4 | 16GB | $0.008 | 4-core Linux larger runner (4 vCPU, 16GB) | $0.012 | 33 percent | 1,250 | | 16x | 16 | 64GB | $0.032 | 16-core Linux larger runner (16 vCPU, 64GB) | $0.042 | 24 percent | 312 | GitHub list prices come from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the shapes from the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), both checked on 2026-08-13\. Each row pairs a WarpBuild size with the GitHub shape that has the same vCPU count and the same memory, so the comparison is rate against rate at equal hardware. Rates for every size and platform are listed on the [WarpBuild pricing page](/pricing). Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so keeping a second Ubuntu line alive as a validation lane costs the minutes it runs and nothing else. Signup includes $10 free credits, which covers 1,250 minutes on the 4 vCPU shape at $0.008 per minute. ### [Worked model: a nightly three-line matrix](#worked-model-a-nightly-three-line-matrix)Copy Anchor Link The cheapest way to know whether your repository already builds on the next Ubuntu line is one scheduled matrix across all three. At 14 minutes per leg on the 8 vCPU shape at $0.016 per minute: * Per night: 3 legs x 14 minutes = 42 minutes, and 42 x $0.016 = $0.672 * Per 30-day month: 30 x $0.672 = $20.16 That buys a daily signal on Ubuntu 22.04, 24.04, and 26.04, and it catches an image rebuild that breaks a build within a day of the rebuild shipping. ### [Worked model: a pinned release lane](#worked-model-a-pinned-release-lane)Copy Anchor Link Take 40 release runs per month at 20 minutes each on a pinned `warp-ubuntu-2404-x64-16x`: * Minutes per month: 40 x 20 = 800 * At $0.032 per minute: 800 x $0.032 = $25.60 * On the 16-core GitHub-hosted Linux larger runner at $0.042 per minute: 800 x $0.042 = $33.60 * Monthly difference at identical shape and identical minutes: $8.00 Substitute your own release cadence and job duration; the structure holds. ## [Configuration](#configuration)Copy Anchor Link Selecting an Ubuntu version is one string in `runs-on`. This job pins Ubuntu 24.04 at 16 vCPU and records the toolchain that produced the artifact: Copy Text ``` name: release on: push: tags: ["v*"] jobs: build: runs-on: warp-ubuntu-2404-x64-16x steps: - uses: actions/checkout@v4 - name: Record the image baseline run: | . /etc/os-release && echo "$PRETTY_NAME" ldd --version | head -1 gcc --version | head -1 - uses: actions/setup-node@v4 with: node-version: "22" - name: Cache the package store uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ env.UBUNTU_LINE }}-${{ hashFiles('**/package-lock.json') }} env: UBUNTU_LINE: ubuntu-2404 - run: npm ci - run: npm run build ``` The baseline step is worth keeping permanently. It writes the Ubuntu release, the C library version, and the compiler version into the job log, so every archived run records the image that produced its artifacts. ### [Keep the Ubuntu line in the cache key](#keep-the-ubuntu-line-in-the-cache-key)Copy Anchor Link `runner.os` evaluates to `Linux` on all three Ubuntu lines, so a cache key built from `runner.os` alone lets a 22.04 job restore an entry written by a 26.04 job. Compiled dependencies and native modules restored across that boundary fail at link time or at import time, often several steps later. Put the Ubuntu line into the key directly, as the `UBUNTU_LINE` value above does, and the two lines keep separate entries. ### [Validate a move before you make it](#validate-a-move-before-you-make-it)Copy Anchor Link A scheduled matrix runs the candidate lines beside the current one and gives you the evidence for the move without touching the pull request lane: Copy Text ``` name: ubuntu-line-compat on: schedule: - cron: "0 5 * * *" workflow_dispatch: jobs: compat: strategy: fail-fast: false matrix: image: - warp-ubuntu-2204-x64-8x - warp-ubuntu-2404-x64-8x - warp-ubuntu-2604-x64-8x runs-on: ${{ matrix.image }} steps: - uses: actions/checkout@v4 - name: Toolchain defaults on this image run: | . /etc/os-release && echo "$PRETTY_NAME" python3 --version node --version go version || true java -version 2>&1 | head -1 openssl version - run: make test ``` `fail-fast: false` keeps the other legs running when one image fails, which is the point of the matrix. ### [Migration checklist between Ubuntu lines](#migration-checklist-between-ubuntu-lines)Copy Anchor Link Work through these in order before switching a lane: 1. **Diff the published software lists.** Each line has its own inventory: [Ubuntu 22.04](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2204-Readme.md), [Ubuntu 24.04](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md), and [Ubuntu 26.04](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2604-Readme.md). The [preinstalled software documentation](/docs/ci/preinstalled-software) states that the tooling on each WarpBuild Ubuntu x86-64 image is the same as the GitHub-hosted runner for that Ubuntu version, so the upstream document is the accurate diff. [Preinstalled software on WarpBuild runner images](/runners/preinstalled-software) maps the lists to the labels. 2. **Print the toolchain defaults on both lines.** The compat matrix above does this. Default `python3`, `java`, and `gcc` versions move with the Ubuntu release, and a step that relied on the image default rather than a `setup-*` action is where the first failure usually appears. 3. **Pin the language runtimes you care about.** Replace a bare `python3` or `node` with `actions/setup-python` or `actions/setup-node` at an explicit version. That removes the Ubuntu line as a variable for everything except compiled dependencies. 4. **Check the system libraries you link against.** Compare `ldd --version`, `openssl version`, and the specific `dpkg -l` entries your build needs. Anything compiled on one line and cached for another has to be rebuilt. 5. **Split the caches.** Any key using `runner.os` alone needs the Ubuntu line added, as above. 6. **Reinstall rather than assume for apt packages.** A package present by default on one line may need an explicit `apt-get install` on another; make the install step unconditional so it works on all three. 7. **Run the candidate line on a schedule for a week** before moving the pull request lane, then move the release lane last. If the checklist ends with a dependency the stock image cannot carry, [building a custom runner image for GitHub Actions](/guides/custom-runner-images-github-actions) covers baking it in instead of installing it on every run. ### [Where to go next](#where-to-go-next)Copy Anchor Link * [Linux x64 runners for GitHub Actions, sizes and pricing](/runners/linux-x64) covers the full x64 fleet, its shapes, and its dynamic label modifiers. * [Preinstalled software on WarpBuild runner images](/runners/preinstalled-software) lists what ships on each image. * [What operating systems can GitHub Actions run](/answers/what-operating-systems-can-github-actions-run) covers the platform choice above the Ubuntu choice. * The [cloud runners documentation](/docs/ci/cloud-runners) is the source of record for every label above. ## [FAQ](#faq)Copy Anchor Link **Which Ubuntu versions run on WarpBuild Linux x64 GitHub Actions runners?** Ubuntu 24.04, Ubuntu 26.04, and Ubuntu 22.04, each in all five sizes: 2, 4, 8, 16, and 32 vCPU. That is 15 Linux x64 labels in total, all on 150GB SSD, and the rate depends on the size rather than the Ubuntu version. **What does `warp-ubuntu-latest-x64-8x` resolve to?** Ubuntu 24.04 with 8 vCPU and 32GB of memory. Its explicit alias is `warp-ubuntu-2404-x64-8x`, and both labels schedule the same machine. The `latest` selector follows GitHub's default for `ubuntu-latest`, so it has moved before and will move again. **Should release jobs use a latest label or a dated label?** Use a dated label such as `warp-ubuntu-2404-x64-16x` or `warp-ubuntu-2604-x64-16x` on any job that signs, publishes, or produces a binary you support. The dated label keeps the Ubuntu version fixed through an upstream default switch, so a release artifact stays reproducible from the workflow file alone. --- # GitHub Actions Runners with Local SSD Storage | WarpBuild URL: https://www.warpbuild.com/runners/local-ssd Description: WarpBuild BYOC Linux runners detect local NVMe SSDs at boot, RAID-0 them, and mount them to the work directory. Support matrix, instance types, and rates. # GitHub Actions Runners with Local SSD Storage WarpBuild BYOC Linux runners detect local NVMe SSDs at boot, RAID-0 them, and mount them to the work directory. Support matrix, instance types, and rates. A GitHub Actions runner with local SSD storage runs its job on a machine that has NVMe disks attached to the instance itself, with the runner work directory mounted on those disks rather than on a network attached volume. On WarpBuild this is a BYOC behavior: choose an instance type that bundles local NVMe, and the runner detects, formats, and mounts the disks during boot with no label, no action, and no workflow change. This page covers the three things that decide whether a job gets local SSD at all: which placements support the auto-mount, which instance families carry the disks, and what the arrangement bills. Mechanics come from the [WarpBuild local SSD documentation](/docs/ci/features/local-ssd) and were re-checked on 2026-08-13. ## [Catalog](#catalog)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. BYOC runs on AWS, GCP, and Azure, and the local SSD auto-mount is documented on the Linux images of all three. | Placement | Local SSD auto-mount | | -------------------------- | -------------------- | | BYOC on AWS, Linux image | Supported | | BYOC on GCP, Linux image | Supported | | BYOC on Azure, Linux image | Supported | | Windows runners | Not supported | | macOS runners | Not supported | Auto-mount is available on Linux based images only. A Windows or macOS runner set keeps its normal storage layout whatever instance type sits underneath it. ### [What the auto-mount changes for a build](#what-the-auto-mount-changes-for-a-build)Copy Anchor Link During VM boot the runner detects every local NVMe device on the instance, creates a RAID-0 array when it finds more than one, formats the device or array with ext4, and mounts it to the runner work directory, for example `/home/runner/work`. The whole sequence finishes before any job starts. Four consequences follow from that. * One filesystem, whatever the disk count. Several local SSDs are striped into a single RAID-0 volume with the combined capacity of the members. RAID-0 carries no redundancy, which is the right trade for storage that is destroyed at the end of every job anyway. * Only the work directory moves. Paths outside the work directory stay on the boot volume, so a build that writes its heavy output into `$HOME` or `/tmp` sees none of the change. * The storage is ephemeral. Local SSDs are tied to the instance lifecycle and every byte is lost when the runner terminates. * Custom images are handled. For BYOC custom VM images the `mdadm` package that RAID-0 needs is installed automatically when it is missing, across Ubuntu, Amazon Linux, and other common distributions. WarpBuild Cloud runners take the other approach: the disk is fixed by the label and comes with the size in the catalog. Rates below are WarpBuild list prices per minute, verified on 2026-08-13 and published on the [pricing page](/pricing). | Label family | Image | Storage | Per minute | | ------------------------------------ | ------------------- | --------- | ---------------- | | warp-ubuntu-latest-x64-2x to \-32x | Ubuntu 24.04 | 150GB SSD | $0.004 to $0.064 | | warp-ubuntu-latest-arm64-2x to \-32x | Ubuntu 24.04 | 150GB SSD | $0.003 to $0.048 | | warp-windows-latest-x64-4x to \-32x | Windows Server 2022 | 256GB SSD | $0.016 to $0.128 | | warp-macos-26-arm64-6x | macOS 26 | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | macOS 26 | 270GB SSD | $0.16 | ### [Instance families that carry local NVMe](#instance-families-that-carry-local-nvme)Copy Anchor Link Local SSDs exist only on specific instance types, so the runner set configuration is where this feature is switched on in practice. | Cloud provider | Instance types with bundled local NVMe | | -------------- | ----------------------------------------------------------------------------------- | | AWS | c5d, m5d, r5d, i3, c6id, m6id, r6id families | | GCP | machine types ending in \-lssd, such as c3-standard-88-lssd and c4a-standard-4-lssd | | Azure | VM sizes with local NVMe temp storage, such as the Lsv3 and Lasv3 families | A runner set accepts several instance types in priority order, and the runner picks one by availability at launch. Put local NVMe types in every slot of that list. A fallback type without local disks still runs the job, and the only visible effect is a job duration that moves around for a reason the workflow file does not explain. ## [Pricing](#pricing)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Local SSD has no line item of its own, because the disks ship with the instance your own cloud account is already paying for. | Line item | Billed by | Rate | | ------------------------------------------- | ------------------- | ----------------------------------------- | | BYOC Linux runner minutes | WarpBuild | $0.002 per minute | | BYOC Windows runner minutes (AWS and Azure) | WarpBuild | $0.002 per minute | | BYOC add-ons, including cache | WarpBuild | Free | | Instance time and its bundled local NVMe | Your cloud provider | Provider list price for the instance type | Two figures fall out of that split. The WarpBuild fee on BYOC is flat at $0.002 per runner minute regardless of instance size, so moving from a small local-NVMe shape to a large one changes only the provider half of the bill. And a local-NVMe instance family usually lists above the general purpose family of the same core count, so the comparison to run is the provider price delta against the minutes the job stops spending on disk work, measured in your own account. Every WarpBuild rate quoted here comes from the [pricing page](/pricing) and was verified on 2026-08-13. Signup includes $10 free credits, which is 5,000 BYOC runner minutes at $0.002 per minute while you measure a shape against your workloads. ## [Configuration](#configuration)Copy Anchor Link There is nothing to configure in the workflow. The setup happens once, on the runner set. 1. Create the runner set on your stack and choose instance types from the families above, listing several in priority order so a job does not sit in queue when one family is short on capacity in a region. 2. Set the disk configuration. The documented minimum is 100GB and the documented best practice is 150GB. This is the boot volume, and it still holds everything outside the work directory. 3. Reference the runner by its Runner ID, which is the full name with the `warp-custom-` prefix. Copy Text ``` name: build on: push jobs: build: runs-on: warp-custom-linux-lssd-16x steps: - name: Show where the work directory lives run: | df -h "$GITHUB_WORKSPACE" lsblk -o NAME,SIZE,TYPE,MOUNTPOINT cat /proc/mdstat || true - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Build run: ./gradlew build --no-daemon ``` Run that first step once on a new runner set. `lsblk` shows the NVMe devices and where they are mounted, `/proc/mdstat` shows the RAID-0 array when the instance carries more than one disk, and `df -h` on `$GITHUB_WORKSPACE` shows the capacity the job actually has. If the work directory reports the boot volume size, the instance type in that runner set has no local NVMe. On GCP the boot disk type is a separate decision from local SSD, and the runner creation UI exposes it. `pd-balanced` is the default, `hyperdisk-balanced` takes provisioned IOPS from 3,000 to 160,000 and throughput from 140 to 2,400 MiB/s, and `hyperdisk-extreme` takes provisioned IOPS from 300 to 350,000\. Full field list and quota notes are in the [BYOC GCP configuration documentation](/docs/ci/byoc/gcp/config), and the choice between them is worked through on [disk types for BYOC runners on GCP](/byoc/gcp/disk-types). ### [Which jobs gain the most](#which-jobs-gain-the-most)Copy Anchor Link The work directory is the mount point, so the jobs that benefit are the ones whose disk work happens inside the workspace. | Workload shape | Disk work inside the job | Signal to look for first | | --------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------- | | Full-history checkout of a large repository | Writing the object database and materializing the working tree | Checkout time that scales with repository size after the fetch has finished | | Container image builds and pulls | Unpacking and writing layers into the local image store | Long gaps between layer progress lines with the network idle | | Cache and artifact restore | Expanding a compressed archive into the workspace | Restore time longer than the download it followed | | Test suites with large fixtures or heavy temporary output | Many small writes under the workspace | IO wait visible in the job system metrics | Checkout-heavy repositories have other levers worth pulling first, and those are covered in the [large repository GitHub Actions guide](/guides/large-repository-github-actions). To confirm that storage is the constraint before changing instance families, start from [why does my job show high IO wait](/answers/why-does-my-job-show-high-io-wait). The term itself is defined on [local SSD](/glossary/local-ssd). ## [FAQ](#faq)Copy Anchor Link **Do I need a runner label or a workflow change to use local SSD storage?** No. Local SSD auto-mount is decided by the instance type in your BYOC runner set. If the instance type carries local NVMe, the runner detects, formats, and mounts it to the work directory during boot, before the first step runs. The workflow file stays the same. **What happens if the instance type does not have local SSDs?** The runner uses the regular boot disk as usual. There is no error and no warning, so the only symptom is a job that takes as long as it did before. Check the instance families in your runner set, including the fallback types, when a change to disk behavior does not show up. **Does anything written to a local SSD survive to the next job?** No. Local SSDs are tied to the instance lifecycle and all data is lost when the runner terminates, which is expected because WarpBuild runners are ephemeral. State that has to cross jobs belongs in the cache or in object storage. --- # macOS Runners for GitHub Actions: Sizes and Pricing | WarpBuild URL: https://www.warpbuild.com/runners/macos Description: WarpBuild macOS runners for GitHub Actions ship in 6 vCPU and 12 vCPU sizes across macOS 26, 15, and 14 at $0.08 and $0.16 a minute. Labels, specs, rates. # macOS Runners for GitHub Actions: Sizes and Pricing WarpBuild macOS runners for GitHub Actions ship in 6 vCPU and 12 vCPU sizes across macOS 26, 15, and 14 at $0.08 and $0.16 a minute. Labels, specs, rates. Last verified: 2026-08-13 WarpBuild runs macOS runners for GitHub Actions on Apple Silicon in two sizes: 6 vCPU with 14GB of memory and a 120GB SSD at $0.08 per minute, and 12 vCPU with 28GB of memory and a 270GB SSD at $0.16 per minute. Five labels cover macOS 26, macOS 15, and macOS 14, and a job moves onto them by changing one `runs-on` value. This page is the catalog and price reference for those labels: every label with its specifications, the per-minute rates set against GitHub's published macOS list prices, and the workflow configuration that puts an Xcode job on them. Rates and specifications here are taken from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners) and re-checked on 2026-08-13. ## [Catalog](#catalog)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The macOS fleet is Apple Silicon on ARM64, and it registers five labels against your GitHub organization. | Runner label | OS | vCPU | Memory | Storage | Rate | Alias | | ----------------------- | -------- | ---- | ------ | --------- | ------------ | --------------------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08/minute | none | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16/minute | none | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08/minute | warp-macos-latest-arm64-6x | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16/minute | warp-macos-latest-arm64-12x | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08/minute | none | The label grammar is `warp-macos--arm64-x`. The OS segment is the macOS major version or the word `latest`, the architecture segment is always `arm64` on macOS, and the size segment is the vCPU count. Read a label left to right and you know the image and the machine without opening the runner page. ### [Size and configuration matrix per chip](#size-and-configuration-matrix-per-chip)Copy Anchor Link WarpBuild offers multiple sizes and configurations per chip. Two configurations exist on macOS, and they scale memory and disk with the core count rather than holding either fixed: | Size | vCPU | Memory | Storage | Rate | Labels carrying this size | | ---- | ---- | ------ | --------- | ------------ | ---------------------------------------------------------------------- | | 6x | 6 | 14GB | 120GB SSD | $0.08/minute | warp-macos-26-arm64-6x, warp-macos-15-arm64-6x, warp-macos-14-arm64-6x | | 12x | 12 | 28GB | 270GB SSD | $0.16/minute | warp-macos-26-arm64-12x, warp-macos-15-arm64-12x | The 12x configuration doubles the cores, doubles the memory to 28GB, and takes disk from 120GB to 270GB. Disk is the specification that decides most macOS build failures: a derived data directory, a resolved Swift package checkout, several simulator runtimes, and an archive of a large app add up quickly, and a runner that fills its disk halfway through `xcodebuild` fails in a way that reads like a compiler problem. If a job currently spends steps deleting simulator runtimes or clearing derived data to stay under a disk ceiling, the 12x size removes the reason for those steps. macOS 14 carries the 6x size only. Every other macOS image carries both. ### [Image and Xcode coverage](#image-and-xcode-coverage)Copy Anchor Link The `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` runners ship Xcode 27.0 (build 27A266a) in addition to the Xcode versions present in the upstream GitHub macOS 26 image, while GitHub's upstream macOS 27 runner image is in beta. Xcode 27.0 arrives with its simulator runtimes bundled: | Platform | Simulator runtime | | -------- | ----------------- | | iOS | 27.0 (24A434) | | tvOS | 27.0 (24J360) | | watchOS | 27.0 (24R362) | | visionOS | 27.0 (24M362) | Xcode 27.0 is a WarpBuild addition on top of the upstream image and is maintained until the official macOS 27 images are released. For the full per-image Xcode list and the simulator runtimes on macOS 15 and macOS 14, see [Xcode versions and simulator runtimes on WarpBuild macOS images](/runners/macos/xcode-images). Outside that addition, the images track upstream. The tooling installed on WarpBuild macOS runners matches the GitHub-hosted equivalents, image by image, which is what makes the label swap a one-line change; the [preinstalled software reference](/docs/ci/preinstalled-software) links the upstream readme for each macOS image so you can diff a tool version before you migrate. macOS 13 runners were removed on June 8, 2026\. Workflows still referencing a macOS 13 label need to move to macOS 14, macOS 15, or macOS 26. ### [What macOS runners do not carry](#what-macos-runners-do-not-carry)Copy Anchor Link Three constraints are worth knowing before you plan a pipeline around these machines. macOS runners do not support nested virtualization and cannot run Docker. Container builds belong on Linux runners or on remote Docker builders, so a repository that produces both an iOS app and a service image ends up with two jobs on two runner families rather than one job doing both. Snapshot runners and the WarpBuild cache are Linux runner features. On macOS, keep dependency caching on `actions/cache` with a key derived from `Package.resolved` or your `Podfile.lock`. High concurrency on macOS is a capacity conversation. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. That said, macOS capacity is planned rather than elastic in the way Linux capacity is, so a team that needs a large number of simultaneous macOS jobs should raise it with support before the release week that depends on it. Every runner is an ephemeral virtual machine. It is allocated when a job starts, and it is destroyed when the job finishes, so nothing leaks from one build into the next. ## [Pricing](#pricing)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. A macOS job bills runner minutes and nothing else. | WarpBuild macOS runner | vCPU | Memory | Storage | Rate | | -------------------------------------- | ---- | ------ | --------- | ------------ | | warp-macos-latest-arm64-6x (macOS 15) | 6 | 14GB | 120GB SSD | $0.08/minute | | warp-macos-latest-arm64-12x (macOS 15) | 12 | 28GB | 270GB SSD | $0.16/minute | | warp-macos-26-arm64-6x | 6 | 14GB | 120GB SSD | $0.08/minute | | warp-macos-26-arm64-12x | 12 | 28GB | 270GB SSD | $0.16/minute | | warp-macos-14-arm64-6x | 6 | 14GB | 120GB SSD | $0.08/minute | ### [Against GitHub-hosted macOS list prices](#against-github-hosted-macos-list-prices)Copy Anchor Link GitHub publishes its per-minute rates for private repositories on the [Actions runner pricing reference](https://docs.github.com/en/billing/reference/actions-runner-pricing) and on the [GitHub pricing page](https://github.com/pricing). The macOS rows below were checked on 2026-08-13. | Runner | Architecture | vCPU | Memory | Storage | Rate | | ------------------------------------------- | ------------ | ---- | ------ | --------- | ------------- | | warp-macos-latest-arm64-6x (WarpBuild) | ARM64 | 6 | 14GB | 120GB SSD | $0.08/minute | | warp-macos-latest-arm64-12x (WarpBuild) | ARM64 | 12 | 28GB | 270GB SSD | $0.16/minute | | macos-latest (GitHub-hosted, standard) | ARM64 | 3 | 7GB | 14GB | $0.062/minute | | macos-latest-xlarge (GitHub-hosted, larger) | ARM64 | 5 | 14GB | 14GB | $0.102/minute | | macos-latest-large (GitHub-hosted, larger) | Intel x64 | 12 | 30GB | 14GB | $0.077/minute | The arithmetic, one comparison at a time. Against `macos-latest-xlarge`, the closest GitHub-hosted Apple Silicon machine, WarpBuild's 6 vCPU runner is $0.08 per minute against $0.102: a difference of $0.022 per minute, or 22 percent lower list price. The WarpBuild machine also carries 6 vCPU against 5, 14GB of memory against 14GB, and a 120GB SSD against 14GB. Normalize by core and the gap widens. WarpBuild's 6 vCPU runner is $0.08 divided by 6, or $0.0133 per vCPU-minute. The 12 vCPU runner is $0.16 divided by 12, which is the same $0.0133 per vCPU-minute, so the size choice is a throughput decision rather than a discount decision. `macos-latest-xlarge` is $0.102 divided by 5, or $0.0204 per vCPU-minute: WarpBuild is 35 percent lower on that basis. Standard `macos-latest` is $0.062 divided by 3, or $0.0207 per vCPU-minute, so WarpBuild is 35 percent lower there. One row runs the other way, and it is worth stating plainly. `macos-latest-large` is $0.077 for 12 Intel cores, which is $0.0064 per vCPU-minute, the lowest number in the table. It is an Intel machine, so it applies to workloads that target Intel rather than Apple Silicon. ### [A worked monthly model](#a-worked-monthly-model)Copy Anchor Link Take an iOS team with 60 pull request builds on a weekday and a 14-minute build-and-test job. * 60 jobs x 14 minutes = 840 minutes per weekday. * 840 minutes x 21 working days = 17,640 macOS minutes per month. * On `warp-macos-latest-arm64-6x`: 17,640 x $0.08 = $1,411.20 per month. * On `macos-latest-xlarge` at GitHub's list rate: 17,640 x $0.102 = $1,799.28 per month. * Difference: $388.08 per month, or $4,656.96 over twelve months at the same volume. Both figures assume the same wall-clock minutes on both machines, which is the conservative way to model it, because the WarpBuild runner brings one more core and the same memory to the same job. To model your own repository against its real job durations, work through [modeling the monthly cost of macOS runners for GitHub Actions](/guides/github-actions-macos-runner-cost). Signup includes $10 free credits, which buys 125 minutes on a 6 vCPU macOS runner or 62 minutes on a 12 vCPU macOS runner before a card is involved. That is enough to run the model above against your own workflow for a day and compare the bill rather than the brochure. Two more line items to know. SSO is a flat $250 per month, whatever the user count, listed on the [WarpBuild pricing page](/pricing) alongside the per-minute rates for every runner family. Cache storage, cache operations, and snapshot line items are Linux runner features and never appear on a macOS-only bill. ## [Configuration](#configuration)Copy Anchor Link Point `runs-on` at a WarpBuild macOS label and the rest of the workflow file stays where it is. Copy Text ``` name: ios-pull-request on: pull_request: branches: [main] jobs: build-and-test: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v4 - name: Pin the toolchain run: | sudo xcode-select -s /Applications/Xcode.app/Contents/Developer xcodebuild -version - name: Cache SwiftPM uses: actions/cache@v4 with: path: ~/Library/Caches/org.swift.swiftpm key: spm-${{ hashFiles('**/Package.resolved') }} - name: Resolve packages run: xcodebuild -resolvePackageDependencies -project App.xcodeproj - name: Build and test run: | xcodebuild test \ -project App.xcodeproj \ -scheme App \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ -resultBundlePath TestResults.xcresult - uses: actions/upload-artifact@v4 if: always() with: name: xcresult path: TestResults.xcresult ``` ### [Alias behaviour of the latest labels](#alias-behaviour-of-the-latest-labels)Copy Anchor Link `warp-macos-latest-arm64-6x` resolves to `warp-macos-15-arm64-6x`, and `warp-macos-latest-arm64-12x` resolves to `warp-macos-15-arm64-12x`. The latest tag tracks macOS 15 in sync with GitHub's own `macos-latest` tag, so the two ecosystems point at the same major version and a repository that mixes hosted and WarpBuild jobs stays on one image family. An alias follows the tag when the tag moves. That is convenient for a repository that wants the current image without editing YAML, and it is a hazard for a release pipeline that signs and notarizes against a fixed toolchain. Use the alias on pull request jobs where a moving image is acceptable, and pin the explicit version on release jobs: Copy Text ``` jobs: pr-checks: runs-on: warp-macos-latest-arm64-6x # follows macOS 15 today release-build: runs-on: warp-macos-26-arm64-12x # pinned, moves only when you edit this line ``` ### [Migrating an existing workflow](#migrating-an-existing-workflow)Copy Anchor Link The migration from GitHub-hosted macOS runners is a label edit: Copy Text ``` jobs: build: - runs-on: macos-latest-xlarge + runs-on: warp-macos-latest-arm64-6x ``` Nothing else in the job changes. The image carries the same tooling as the GitHub-hosted equivalent, `sudo` is passwordless, and the working directory layout matches, so the checkout step, the Xcode selection step, and the simulator destination string all behave the same way. Repository-level detail for Swift and iOS pipelines lives on [iOS builds and tests on GitHub Actions](/solutions/ios-github-actions) and [Swift package builds on GitHub Actions](/solutions/swift-github-actions). ### [Testing several macOS versions in one workflow](#testing-several-macos-versions-in-one-workflow)Copy Anchor Link A matrix over labels is the cheapest way to find out whether a new macOS image breaks a build before the alias moves under you: Copy Text ``` jobs: os-compatibility: strategy: fail-fast: false matrix: runner: - warp-macos-26-arm64-12x - warp-macos-15-arm64-12x - warp-macos-14-arm64-6x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - run: sw_vers - run: xcodebuild -version - run: swift build -c release - run: swift test ``` Run that weekly on a schedule and a macOS 26 regression shows up on a cron job instead of on the morning you planned to ship. ### [Picking a size](#picking-a-size)Copy Anchor Link Start on 6x. Move a job to 12x when one of three things is true: the job is disk bound at 120GB, the job is memory bound at 14GB, or the job parallelizes cleanly across cores, which `xcodebuild` test plans and `swift test` both do. Because both sizes cost the same $0.0133 per vCPU-minute, a job that genuinely uses 12 cores costs roughly the same on 12x as on 6x while finishing in less wall-clock time; a job that uses 4 cores costs twice as much on 12x for no benefit. Measure before you upgrade. ### [Tooling around the runner](#tooling-around-the-runner)Copy Anchor Link CI observability reports OpenTelemetry system metrics from the runner agent next to the GitHub Actions job logs, which is how you tell a memory-bound Xcode job apart from a slow one. The Action Debugger opens an SSH session on a paused runner when a macOS-only failure will not reproduce locally. ## [FAQ](#faq)Copy Anchor Link **Which macOS runner labels does WarpBuild register for GitHub Actions?** Five labels. `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` on macOS 26, `warp-macos-15-arm64-6x` and `warp-macos-15-arm64-12x` on macOS 15, and `warp-macos-14-arm64-6x` on macOS 14\. The macOS 15 labels also answer to the aliases `warp-macos-latest-arm64-6x` and `warp-macos-latest-arm64-12x`. **What do WarpBuild macOS runners cost against GitHub-hosted macOS runners?** The 6 vCPU runner is $0.08 per minute and the 12 vCPU runner is $0.16 per minute. GitHub lists its arm64 macOS 5-core larger runner at $0.102 per minute and its standard 3-core macOS runner at $0.062 per minute ([GitHub Actions runner pricing](https://docs.github.com/en/billing/reference/actions-runner-pricing), checked on 2026-08-13). At 6 vCPU for $0.08, WarpBuild works out to $0.0133 per vCPU-minute against $0.0204 for the GitHub arm64 larger runner. **What does `warp-macos-latest-arm64-6x` point to?** It is an alias for `warp-macos-15-arm64-6x`. The latest tag tracks macOS 15 in sync with GitHub's own `macos-latest` tag, so pin an explicit OS version such as `warp-macos-26-arm64-12x` when a job must stay on one image. **Can WarpBuild macOS runners run Docker?** No. macOS runners do not support nested virtualization and cannot run Docker. Send container builds to WarpBuild Linux runners or to remote Docker builders, and keep the macOS runner for Xcode, Swift, and simulator work. --- # iOS Simulator Runtimes on macOS GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/runners/macos/simulator-runtimes Description: See which iOS, tvOS, watchOS, and visionOS simulator runtimes each WarpBuild macOS image carries, per-minute prices, and how to assert a runtime in a job. # iOS Simulator Runtimes on macOS GitHub Actions Runners See which iOS, tvOS, watchOS, and visionOS simulator runtimes each WarpBuild macOS image carries, per-minute prices, and how to assert a runtime in a job. Last verified: 2026-08-13 A GitHub Actions job can only boot a simulator whose runtime is already installed on the runner image, so the runtimes available to `xcodebuild test` are decided by the macOS label in `runs-on`. WarpBuild macOS runners carry the simulator runtimes bundled with the Xcode versions in each image, and the two macOS 26 labels additionally carry the iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 runtimes that ship with Xcode 27.0. This page lists the runtime coverage of each macOS image, gives the per-minute rate for every macOS label, prices the minutes a job spends downloading a missing runtime, and shows the assertion step that turns a missing runtime into a fast, readable failure instead of a confusing `xcodebuild` error. ## [Catalog](#catalog)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The macOS runners are Apple Silicon on ARM64, in a 6 vCPU shape and a 12 vCPU shape across five `warp-` labels. | Label | macOS image | vCPU | Memory | Storage | Price per minute | | ----------------------- | ----------- | ---- | ------ | --------- | ---------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14 GB | 120GB SSD | $0.080 | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28 GB | 270GB SSD | $0.160 | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14 GB | 120GB SSD | $0.080 | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28 GB | 270GB SSD | $0.160 | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14 GB | 120GB SSD | $0.080 | ### [Runtime coverage by image](#runtime-coverage-by-image)Copy Anchor Link Every WarpBuild macOS image is built from the corresponding upstream GitHub runner image and carries the same tooling, so the resident runtimes on an image are the ones bundled with that image's Xcode versions. The macOS 26 image is the one with an addition on top. | macOS image | Labels | iOS | tvOS | watchOS | visionOS | Per-build inventory | | ----------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | macOS 26 | warp-macos-26-arm64-6x, warp-macos-26-arm64-12x | 27.0 (24A434) plus the runtimes in the upstream image | 27.0 (24J360) plus the runtimes in the upstream image | 27.0 (24R362) plus the runtimes in the upstream image | 27.0 (24M362) plus the runtimes in the upstream image | [macos-26-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md) and the [macOS 26 tooling table](/docs/ci/cloud-runners#macos-26-tooling) | | macOS 15 | warp-macos-15-arm64-6x, warp-macos-15-arm64-12x (aliases warp-macos-latest-arm64-6x, warp-macos-latest-arm64-12x) | Bundled with the image Xcode set | Bundled with the image Xcode set | Bundled with the image Xcode set | Bundled with the image Xcode set where that Xcode carries a visionOS runtime | [macos-15-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-arm64-Readme.md) | | macOS 14 | warp-macos-14-arm64-6x | Bundled with the Xcode 15 line, 15.4 default | Bundled with the Xcode 15 line | Bundled with the Xcode 15 line | Bundled with the Xcode 15 line where that Xcode carries a visionOS runtime | [macos-14-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md) | The readme files linked in that table are the authoritative per-build inventory. GitHub regenerates them on every image release, so an exact runtime build string for the macOS 14 and macOS 15 images belongs in the readme rather than in a page that refreshes on a 90 day cadence. The [preinstalled software docs](/docs/ci/preinstalled-software) keep the readme link for each WarpBuild image in one place, and the [cloud runner docs](/docs/ci/cloud-runners) carry the label, size, and rate for every macOS shape. The macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. Xcode 27.0 on macOS 26 is maintained until then, so a workflow that pins a `/Applications/Xcode_27.0.app` path needs one edit when the switch happens. Two aliases sit on top of the versioned labels. `warp-macos-latest-arm64-6x` resolves to `warp-macos-15-arm64-6x` and `warp-macos-latest-arm64-12x` resolves to `warp-macos-15-arm64-12x`, in sync with GitHub's `macos-latest` tag. A job that needs the 27.0 runtimes names the macOS 26 label directly, because the alias moves when the upstream tag moves. Three constraints shape how much runtime coverage a job can lean on. macOS 13 runners were removed on June 8, 2026, so workflows still naming a macOS 13 label queue without a match. macOS runners do not support nested virtualization and cannot run Docker, so container steps in an iOS pipeline belong in a Linux job. Runner storage is ephemeral and is deleted when the runner terminates, so a downloaded runtime never survives to the next job: the download repeats on every run that needs it. ## [Pricing](#pricing)Copy Anchor Link macOS rates are flat per minute by size and do not change with the image or with the number of runtimes on it. A job on macOS 26 and a job on macOS 14 at the same vCPU count cost the same. | Configuration | Label or GitHub shape | vCPU | Memory | Price per minute | 30-minute job | | --------------------------------- | ---------------------------------------------------------------------- | ---- | ------ | ---------------- | ------------- | | WarpBuild 6 vCPU | warp-macos-26-arm64-6x, warp-macos-15-arm64-6x, warp-macos-14-arm64-6x | 6 | 14 GB | $0.080 | $2.40 | | WarpBuild 12 vCPU | warp-macos-26-arm64-12x, warp-macos-15-arm64-12x | 12 | 28 GB | $0.160 | $4.80 | | GitHub-hosted largest macOS ARM64 | macos\_xl | 5 | 14 GB | $0.102 | $3.06 | `warp-macos-latest-arm64-6x` (6 vCPU, 14 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price. GitHub list price checked on 2026-08-13, from GitHub's published [Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers). WarpBuild rates come from the [pricing page](/pricing). Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Billing is per minute of runner time, which is what makes a runtime download a line on the invoice rather than a free convenience. ### [What a runtime download costs at job time](#what-a-runtime-download-costs-at-job-time)Copy Anchor Link Time the download and install step in your own job log first, then read across. Each row is a step duration you supply, and the arithmetic is that duration times the macOS rate. | Download and install step | Cost per job at $0.080 | 200 jobs per month | 800 jobs per month | | ------------------------- | ---------------------- | ------------------ | ------------------ | | 3 minutes | $0.24 | $48.00 | $192.00 | | 6 minutes | $0.48 | $96.00 | $384.00 | | 10 minutes | $0.80 | $160.00 | $640.00 | On the 12 vCPU shape at $0.160 per minute, every figure in that table doubles: a 6-minute step is $0.96 per job and $192.00 across 200 jobs. A simulator runtime is a multi-gigabyte download, so the step also competes for the 120GB disk on the 6 vCPU shape. Matrices that need several runtimes in one job belong on the 12 vCPU shape with 270GB. The cheapest version of this line is not having it. Pinning a label whose image already carries the runtime you test against drops the download step, and the job pays for compile and test minutes only. Signup includes $10 free credits, which covers a few hundred minutes of macOS runner time while you measure the two shapes against your own test suite. ## [Configuration](#configuration)Copy Anchor Link Two edits put a simulator job on a WarpBuild macOS runner with a runtime guarantee: the `runs-on` label, and an assertion step that fails early when the runtime is absent. ### [Assert the runtime before the test step](#assert-the-runtime-before-the-test-step)Copy Anchor Link Copy Text ``` name: ios-simulator-tests on: pull_request: branches: [main] jobs: test: runs-on: warp-macos-26-arm64-6x env: DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer RUNTIME_ID: com.apple.CoreSimulator.SimRuntime.iOS-27-0 SIMULATOR_NAME: iPhone 16 Pro steps: - uses: actions/checkout@v4 - name: Record the toolchain and runtimes in the job log run: | xcodebuild -version xcrun simctl list runtimes - name: Assert the iOS 27.0 runtime is installed run: | if ! xcrun simctl list runtimes --json | grep -q "$RUNTIME_ID"; then echo "::error::$RUNTIME_ID is not on this image" exit 1 fi - name: Test run: | xcodebuild test \ -scheme AppScheme \ -destination "platform=iOS Simulator,name=$SIMULATOR_NAME,OS=27.0" \ -resultBundlePath TestResults.xcresult - uses: actions/upload-artifact@v4 if: always() with: name: test-results path: TestResults.xcresult ``` Matching on the runtime identifier rather than the display name is what makes the assertion reliable: `com.apple.CoreSimulator.SimRuntime.iOS-27-0` is stable across builds of the same runtime, while the display string carries a build suffix that changes. The `xcrun simctl list runtimes` step above it costs a second and puts the whole inventory in the log, so an image update that adds or removes a runtime reads as a diff between two runs instead of a test failure someone has to bisect. Device names are a separate axis: confirm `SIMULATOR_NAME` against `xcrun simctl list devicetypes` on the label you pin. ### [Download a runtime only when it is missing](#download-a-runtime-only-when-it-is-missing)Copy Anchor Link Copy Text ``` - name: Ensure the iOS runtime is present run: | if ! xcrun simctl list runtimes --json | grep -q "$RUNTIME_ID"; then echo "Runtime missing on this image, downloading" time xcodebuild -downloadPlatform iOS fi ``` The guard matters more than the download. Wrapping the download in the same identifier check means the step is a no-op on images that already carry the runtime, and the `time` prefix writes the duration into the log, which is the number to put in the cost table above. Running `xcodebuild -downloadAllPlatforms` instead pulls every platform and multiplies both the minutes and the disk use. `DEVELOPER_DIR` selects the toolchain for `xcodebuild`, `swift`, and every `xcrun` call in the steps that follow, without `sudo` and without changing the machine-wide default. Pin it to the bundle that carries the runtime you assert on, so the `OS=` value in the destination string and the SDK the tests compile against stay in sync. Start from the [macOS runner hub](/runners/macos) for the full lineup, read [what simulator runtimes ship on macOS runners](/answers/what-simulator-runtimes-ship-on-macos-runners) for the short version of this page, and see the [iOS simulator test guide](/guides/ios-simulator-tests-github-actions) for the surrounding pipeline. Term definitions live in the [simulator runtime glossary entry](/glossary/simulator-runtime). ## [FAQ](#faq)Copy Anchor Link **Which simulator runtimes ship on WarpBuild macOS runners?** Each macOS image carries the simulator runtimes bundled with the Xcode versions in the upstream GitHub image it is built from. The two macOS 26 labels, `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x`, additionally carry iOS 27.0 (24A434), tvOS 27.0 (24J360), watchOS 27.0 (24R362), and visionOS 27.0 (24M362), which ship with Xcode 27.0 (build 27A266a). **How do I check that a runtime exists before the test step runs?** Run `xcrun simctl list runtimes` as an early step and fail the job when the runtime you need is missing. Matching on the runtime identifier, such as `com.apple.CoreSimulator.SimRuntime.iOS-27-0`, is more precise than matching on the display name, and the full listing in the log makes an image change readable as a diff. **What does downloading a simulator runtime inside a job cost?** Runner time at the macOS rate. On the 6 vCPU shape at $0.080 per minute, a 6-minute download and install step adds $0.48 per job, which is $96.00 across 200 jobs in a month. Pinning a label whose image already carries the runtime removes the step and its cost. --- # macOS Runner Sizes and Configurations for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/runners/macos/sizes Description: WarpBuild macOS runners for GitHub Actions come in 6 vCPU and 12 vCPU sizes at $0.08 and $0.16 a minute. Specs, list prices, and which size fits a job. # macOS Runner Sizes and Configurations for GitHub Actions WarpBuild macOS runners for GitHub Actions come in 6 vCPU and 12 vCPU sizes at $0.08 and $0.16 a minute. Specs, list prices, and which size fits a job. Last verified: 2026-08-13 WarpBuild macOS runners for GitHub Actions come in two sizes: 6 vCPU with 14GB of memory and a 120GB SSD at $0.08 per minute, and 12 vCPU with 28GB of memory and a 270GB SSD at $0.16 per minute. Five `warp-` labels carry those two sizes across macOS 26, macOS 15, and macOS 14, so changing the size of a job is a one line `runs-on` edit. This page is the size reference. It lists what each size gives a job, prices both sizes against GitHub's published macOS list prices, and maps sizes onto three common Xcode workloads: unit tests, simulator tests, and archive builds. Specifications and rates come from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners) and were re-checked on 2026-08-13. ## [Catalog](#catalog)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The macOS fleet runs on Apple Silicon with ARM64 architecture, and every macOS label resolves to one of two sizes. | Label | macOS image | Size | vCPU | Memory | Storage | Rate | | ----------------------- | ----------- | ---- | ---- | ------ | --------- | ------------ | | warp-macos-26-arm64-6x | macOS 26 | 6x | 6 | 14GB | 120GB SSD | $0.08/minute | | warp-macos-26-arm64-12x | macOS 26 | 12x | 12 | 28GB | 270GB SSD | $0.16/minute | | warp-macos-15-arm64-6x | macOS 15 | 6x | 6 | 14GB | 120GB SSD | $0.08/minute | | warp-macos-15-arm64-12x | macOS 15 | 12x | 12 | 28GB | 270GB SSD | $0.16/minute | | warp-macos-14-arm64-6x | macOS 14 | 6x | 6 | 14GB | 120GB SSD | $0.08/minute | macOS 14 carries the 6x size only. macOS 26 and macOS 15 carry both. The aliases `warp-macos-latest-arm64-6x` and `warp-macos-latest-arm64-12x` resolve to the two macOS 15 labels. ### [What changes between the two sizes](#what-changes-between-the-two-sizes)Copy Anchor Link | Specification | 6x | 12x | Change | | -------------------- | ------------ | ------------ | --------------------- | | vCPU | 6 | 12 | 6 more cores | | Memory | 14GB | 28GB | 14GB more | | Storage | 120GB SSD | 270GB SSD | 150GB more | | Rate | $0.08/minute | $0.16/minute | $0.08 more per minute | | Rate per vCPU-minute | $0.0133 | $0.0133 | unchanged | | Memory per vCPU | 3.67GB | 3.67GB | unchanged | Cores and memory scale together, so a job that fits comfortably in 14GB on 6x has the same headroom per core on 12x. Storage moves further than either. The rate goes from $0.08 to $0.16 a minute while the disk goes from 120GB to 270GB, an increase of 150GB, so storage is the one specification on this list that outgrows the rate rise. Storage is ephemeral on both sizes and is destroyed with the runner at the end of the job. The image and the size are separate choices. Which Xcode versions and simulator runtimes a job gets is decided by the macOS image in the label, and the [preinstalled software reference](/docs/ci/preinstalled-software) lists what each macOS image carries. Per-image Xcode detail lives on [Xcode versions and simulator runtimes on each macOS image](/runners/macos/xcode-images). ## [Pricing](#pricing)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. A macOS job bills runner minutes at the rate of the size it ran on. | Size | Rate | Rate per vCPU-minute | 10-minute job | 1,000 minutes | | ---- | ------------ | -------------------- | ------------- | ------------- | | 6x | $0.08/minute | $0.0133 | $0.80 | $80.00 | | 12x | $0.16/minute | $0.0133 | $1.60 | $160.00 | ### [Against the nearest GitHub-hosted macOS ARM64 shape](#against-the-nearest-github-hosted-macos-arm64-shape)Copy Anchor Link `warp-macos-latest-arm64-6x` (6 vCPU, 14GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14GB): 22 percent lower list price. GitHub list price checked on 2026-08-13 against the [GitHub Actions runner pricing reference](https://docs.github.com/en/billing/reference/actions-runner-pricing). The arithmetic, written out: ($0.102 - $0.08) / $0.102 = 0.2157, which rounds to 22 percent. Per core the gap is wider, because the WarpBuild size carries one more vCPU. $0.08 divided by 6 vCPU is $0.0133 per vCPU-minute; $0.102 divided by 5 vCPU is $0.0204 per vCPU-minute; ($0.0204 - $0.0133) / $0.0204 = 0.35, or 35 percent lower per core. The same 6 vCPU size carries the same 14GB of memory, plus a 120GB SSD. Applied to a month of builds at the same wall-clock minutes, 1,000 macOS minutes costs $80.00 on the 6x size against $102.00 at GitHub's list rate for that shape, a difference of $22.00\. To run the same arithmetic against a real repository and its own job durations, work through [modeling the monthly cost of macOS runners for GitHub Actions](/guides/github-actions-macos-runner-cost). Signup includes $10 free credits, which covers 125 minutes on a 6x macOS runner or 62 minutes on a 12x macOS runner. Every rate quoted here also appears on the [pricing page](/pricing). ## [Configuration](#configuration)Copy Anchor Link Pick the size per job rather than per repository. Three Xcode workloads sort cleanly. | Workload | Start on | Reasoning | | ------------------------------------------------------------------------------------ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Unit tests (swift test, XCTest without a simulator) | 6x | Compile and test of a package tree stays inside 14GB of memory and 120GB of disk, and the job bills $0.08 a minute while it runs. | | Simulator tests (xcodebuild test against iOS, tvOS, watchOS, or visionOS simulators) | 6x, then 12x when the test plan fans out | Each booted simulator holds memory and disk on top of derived data and the downloaded runtimes, and a plan running several destinations at once is where 28GB and 270GB earn the extra $0.08 a minute. | | Archive and distribution builds (xcodebuild archive, \-exportArchive, notarization) | 12x | A release build writes derived data, an .xcarchive, and an exported artifact onto the same disk in one job, and whole-module optimization across many targets is the phase that occupies every available core. | Because both sizes bill $0.0133 per vCPU-minute, moving a job to 12x changes the total bill only through wall-clock time. A job that keeps 12 cores busy and finishes proportionally sooner lands near the same total. A job that keeps 4 cores busy still bills $0.16 a minute instead of $0.08 and finishes at the same time, so a 20-minute job goes from $1.60 to $3.20 for the same result. Measure before upgrading: print `df -h /` at the end of a job to see how close the disk came to its ceiling, and `sysctl -n hw.ncpu` plus a build log timestamp to see whether the extra cores were used at all. A workflow that splits the three workloads across sizes: Copy Text ``` name: ios on: pull_request: branches: [main] push: branches: [main] jobs: unit-tests: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v4 - run: swift test --parallel simulator-tests: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v4 - name: Run the test plan run: | xcodebuild test \ -scheme App \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ -resultBundlePath TestResults.xcresult - run: df -h / if: always() archive: if: github.event_name == 'push' runs-on: warp-macos-26-arm64-12x steps: - uses: actions/checkout@v4 - name: Archive for distribution run: | xcodebuild archive \ -scheme App \ -archivePath build/App.xcarchive \ -destination 'generic/platform=iOS' - run: df -h / if: always() ``` Changing the size of a job later is a single line: Copy Text ``` archive: - runs-on: warp-macos-26-arm64-6x + runs-on: warp-macos-26-arm64-12x ``` Nothing else in the job needs editing, because both sizes boot the same macOS image and differ only in the machine underneath it. For the full label list, the alias behaviour of the `latest` tags, and the constraints that apply to every macOS size, see [WarpBuild macOS runners for GitHub Actions](/runners/macos). For disk ceilings measured against a real Xcode job, see [how much disk macOS GitHub Actions runners have](/answers/how-much-disk-do-macos-github-actions-runners-have). ## [FAQ](#faq)Copy Anchor Link **What sizes do WarpBuild macOS runners for GitHub Actions come in?** Two. The 6x size is 6 vCPU, 14GB of memory, and a 120GB SSD at $0.08 per minute. The 12x size is 12 vCPU, 28GB of memory, and a 270GB SSD at $0.16 per minute. Five labels carry those sizes across macOS 26, macOS 15, and macOS 14, and macOS 14 carries the 6x size only. **Does the 12 vCPU macOS runner cost more per core than the 6 vCPU one?** No. $0.08 divided by 6 vCPU is $0.0133 per vCPU-minute, and $0.16 divided by 12 vCPU is the same $0.0133 per vCPU-minute. Memory scales with the same ratio at 14GB and 28GB, so the size choice is about wall-clock time and headroom rather than a per-core discount. **How does the 6 vCPU macOS size compare with GitHub-hosted macOS ARM64 list prices?** `warp-macos-latest-arm64-6x` (6 vCPU, 14GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14GB), which is 22 percent lower list price. GitHub list price checked on 2026-08-13 ([GitHub Actions runner pricing](https://docs.github.com/en/billing/reference/actions-runner-pricing)). --- # Xcode Image Coverage on macOS GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/runners/macos/xcode-images Description: WarpBuild macOS runners ship three macOS images across five labels. See the Xcode versions and simulator runtimes on each image, prices, and how to pin one. # Xcode Image Coverage on macOS GitHub Actions Runners WarpBuild macOS runners ship three macOS images across five labels. See the Xcode versions and simulator runtimes on each image, prices, and how to pin one. Last verified: 2026-08-13 The Xcode versions a GitHub Actions job can use are decided by the macOS image the runner boots, so choosing an Xcode version means choosing a runner label. WarpBuild macOS runners ship three macOS images, macOS 14, macOS 15, and macOS 26, across five `warp-` labels, and the two macOS 26 labels carry Xcode 27.0 (build 27A266a) on top of the Xcode versions in the upstream GitHub image. This page lists every macOS label with its image, size, and per-minute rate, maps each image to the Xcode versions and simulator runtimes it carries, prices a real iOS pipeline against GitHub-hosted macOS list prices, and shows the workflow syntax for pinning an image and selecting a toolchain inside the job. ## [Catalog](#catalog)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The macOS runners are Apple Silicon on ARM64, with multiple sizes and configurations per chip: a 6 vCPU shape and a 12 vCPU shape on the two current images. | Label | macOS image | vCPU | Memory | Storage | Price per minute | | ----------------------- | ----------- | ---- | ------ | --------- | ---------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14 GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28 GB | 270GB SSD | $0.16 | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14 GB | 120GB SSD | $0.08 | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28 GB | 270GB SSD | $0.16 | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14 GB | 120GB SSD | $0.08 | Two aliases exist on top of those labels. `warp-macos-latest-arm64-6x` resolves to `warp-macos-15-arm64-6x` and `warp-macos-latest-arm64-12x` resolves to `warp-macos-15-arm64-12x`, because the latest tag tracks GitHub's `macos-latest` tag, which currently points at macOS 15\. A job that names an alias moves to a different image the day the alias moves, which is the main reason Xcode-sensitive builds pin the versioned label instead. The 12 vCPU shape carries 270GB of storage against 120GB on the 6 vCPU shape. That gap matters on Xcode work more than the vCPU count does for some teams, because a downloaded simulator runtime, a derived data directory, and an archive for distribution can each run into tens of gigabytes. Runner storage is ephemeral and is deleted when the runner terminates, so nothing accumulates between jobs. macOS 13 runners were removed on June 8, 2026\. Workflows still naming a macOS 13 label queue without a match, so move them to macOS 14, macOS 15, or macOS 26. ### [Xcode and simulator coverage by image](#xcode-and-simulator-coverage-by-image)Copy Anchor Link Every WarpBuild macOS image is built from the corresponding upstream GitHub runner image and carries the same tooling, so the Xcode set on each image is the published upstream set. The macOS 26 image is the one with an addition on top. | macOS image | Labels | Xcode versions on the image | Simulator runtimes | Published list | | ----------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | macOS 26 | warp-macos-26-arm64-6x, warp-macos-26-arm64-12x | The Xcode versions in the upstream GitHub macOS 26 arm64 image, plus Xcode 27.0 (build 27A266a) added by WarpBuild | iOS 27.0 (24A434), tvOS 27.0 (24J360), watchOS 27.0 (24R362), visionOS 27.0 (24M362) with Xcode 27.0, plus the runtimes in the upstream image | [macos-26-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md) and the [WarpBuild macOS 26 tooling section](/docs/ci/cloud-runners#macos-26-tooling) | | macOS 15 | warp-macos-15-arm64-6x, warp-macos-15-arm64-12x (aliases warp-macos-latest-arm64-6x, warp-macos-latest-arm64-12x) | The Xcode versions in the upstream GitHub macOS 15 arm64 image | The simulator runtimes bundled with those Xcode versions in the upstream image | [macos-15-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-arm64-Readme.md) | | macOS 14 | warp-macos-14-arm64-6x | The Xcode 15 line, with 15.4 as the default, matching GitHub-hosted macOS 14 | The simulator runtimes bundled with the Xcode 15 line in the upstream image | [macos-14-arm64 readme](https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md) | The readme files linked in that table are the authoritative per-build inventory. GitHub regenerates them on every image release, so an exact patch version such as 16.2 or 16.4 belongs in the readme rather than in a marketing page that refreshes on a 90 day cadence. The [preinstalled software docs](/docs/ci/preinstalled-software) keep the readme link for each WarpBuild image in one place. macOS 14 is the narrowest image of the three by design. GitHub removed Xcode 14 and Xcode 16 from its macOS 14 images on November 4, 2024, leaving the Xcode 15 line, and WarpBuild followed the same removal. The default on that image was corrected to 15.4 on March 31, 2025 so that a job which never calls `xcode-select` compiles with the same toolchain it would get on a GitHub-hosted macOS 14 runner. Teams that need Xcode 16 or later on an older base OS have to move up an image rather than reach for a version that image no longer carries. ### [The macOS 26 tooling addition](#the-macos-26-tooling-addition)Copy Anchor Link The `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` runners ship Xcode 27.0, build 27A266a, in addition to the Xcode versions present in the upstream GitHub macOS 26 image. Four simulator runtimes come with it: iOS 27.0 (24A434), tvOS 27.0 (24J360), watchOS 27.0 (24R362), and visionOS 27.0 (24M362). A job on either label can build against the 27.0 SDKs and run tests on the 27.0 simulators without a download step in the workflow. That addition carries a support window worth writing into your workflow comments during review. Xcode 27.0 on macOS 26 is a WarpBuild addition on top of the upstream GitHub image, and it is maintained only until the official macOS 27 images are released. When those images ship, the Xcode 27.0 bundle path on the macOS 26 image stops being the place to pin, and jobs that hardcode it need one edit. Runner image changes are published in the [WarpBuild changelog](/docs/ci/changelog), which is the surface to watch for that switch. The practical value of the addition is timing. Simulator runtimes for a new OS version are usually the blocker for a team that wants to test against a beta or a fresh release, because downloading a runtime inside a job adds minutes to every run and burns disk on a 120GB image. Having the runtime resident on the image turns that into a destination string. ### [What the coverage adds up to](#what-the-coverage-adds-up-to)Copy Anchor Link Counting the catalog: three macOS images, five labels, two sizes on each of the two current images, and one Xcode version on macOS 26 that the upstream GitHub image does not carry. Those counts are measured on the same base images the GitHub-hosted runners use, rather than on a synthetic benchmark. Everything else on the image matches the GitHub-hosted equivalent, which is what makes a label swap a one-line change. Two limits belong in the same paragraph as the coverage. macOS runners do not support nested virtualization and cannot run Docker, so a container step in an iOS pipeline has to move to a Linux job. The WarpBuild cache is enabled by default on Linux runners, so a macOS job caches with `actions/cache` against GitHub's cache service in the usual way. ## [Pricing](#pricing)Copy Anchor Link macOS rates are flat per minute by size and do not change with the image. A macOS 26 job and a macOS 14 job at the same vCPU count cost the same. | Configuration | Label or GitHub shape | vCPU | Memory | Price per minute | Per hour | 30-minute build | | ------------------------------------ | ---------------------------------------------------------------------- | ---- | ------ | ---------------- | -------- | --------------- | | WarpBuild 6 vCPU | warp-macos-26-arm64-6x, warp-macos-15-arm64-6x, warp-macos-14-arm64-6x | 6 | 14 GB | $0.080 | $4.80 | $2.40 | | WarpBuild 12 vCPU | warp-macos-26-arm64-12x, warp-macos-15-arm64-12x | 12 | 28 GB | $0.160 | $9.60 | $4.80 | | GitHub-hosted larger Apple Silicon | macos-latest-xlarge (macos\_xl) | 5 | 14 GB | $0.102 | $6.12 | $3.06 | | GitHub-hosted standard Apple Silicon | macos-latest (actions\_macos) | 3 | 7 GB | $0.062 | $3.72 | $1.86 | | GitHub-hosted larger Intel | macos-latest-large (macos\_l) | 12 | 30 GB | $0.077 | $4.62 | $2.31 | WarpBuild rates come from the [cloud runner catalog](/docs/ci/cloud-runners) and the [pricing page](/pricing). GitHub list prices come from GitHub's published [Actions minute multipliers](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. The matched Apple Silicon pair is the first and third rows: the closest GitHub-hosted shape to a 6 vCPU WarpBuild macOS runner is `macos-latest-xlarge`. At $0.080 against $0.102, that is $0.022 lower per minute, 22 percent lower, or $1.32 lower per hour of macOS build time. Read the last two rows before quoting that number internally. GitHub's 3 vCPU standard macOS runner at $0.062 per minute is the cheapest macOS minute on either price list, and the 12 vCPU Intel shape is a different architecture from an Apple Silicon build host, so it is not a matched comparison for a job that expects arm64 Xcode. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Billing is per minute of runner time, so the cost of an Xcode job is its wall clock duration times the rate in the table above. ### [Worked model: one iOS pipeline](#worked-model-one-ios-pipeline)Copy Anchor Link Take a team with 40 pull request builds per weekday. Each build runs a 14-minute compile and simulator test job on the 6 vCPU shape, and a nightly release build runs 26 minutes on the 12 vCPU shape every day of the month. Pull request minutes: 40 builds times 14 minutes is 560 minutes per weekday. Over 22 weekdays that is 12,320 minutes per month. * On `warp-macos-26-arm64-6x` at $0.080 per minute: 12,320 times 0.080 is $985.60 per month. * On GitHub's `macos-latest-xlarge` at $0.102 per minute: 12,320 times 0.102 is $1,256.64 per month. * Difference on the pull request leg: $271.04 per month. Nightly release minutes: 26 minutes times 30 days is 780 minutes on `warp-macos-26-arm64-12x` at $0.160 per minute, which is $124.80 per month. GitHub's list has no 12 vCPU Apple Silicon shape to match that row, so the honest comparison stops at the pull request leg. Total macOS spend in this model on WarpBuild: $985.60 plus $124.80 is $1,110.40 per month, with $10 of that covered on a new account because signup includes $10 free credits. ### [Worked model: an Xcode version matrix](#worked-model-an-xcode-version-matrix)Copy Anchor Link Compatibility matrices are where Xcode image coverage turns into a line on the invoice. Add a second matrix leg so every pull request builds against Xcode 27.0 on macOS 26 and against the default toolchain on macOS 15\. The per-build cost doubles, because both legs are full 14-minute jobs on the 6 vCPU shape. * One leg: 12,320 minutes per month at $0.080 is $985.60. * Two legs: 24,640 minutes per month at $0.080 is $1,971.20. * The same two-leg matrix on GitHub's `macos-latest-xlarge` at $0.102: 24,640 times 0.102 is $2,513.28, a difference of $542.08 per month. The lever that usually beats both numbers is scope. Run the second leg on the merge queue and on nightly runs rather than on every pull request, and the added minutes drop by roughly the ratio of merges to pull request pushes. The [macOS runner cost guide](/guides/github-actions-macos-runner-cost) works through the same arithmetic for a full macOS fleet, and the [pricing page](/pricing) carries the complete rate card including cache and storage line items. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. For high concurrency specifically on macOS, contact [support@warpbuild.com](mailto:support@warpbuild.com) so capacity is staged ahead of a large matrix rollout. ## [Configuration](#configuration)Copy Anchor Link Two edits move an Xcode job onto a WarpBuild macOS runner: the `runs-on` label and, when the Xcode version matters, an explicit toolchain selection. ### [Pin an image label and select Xcode](#pin-an-image-label-and-select-xcode)Copy Anchor Link Copy Text ``` name: ios-pr on: pull_request: branches: [main] jobs: build-and-test: runs-on: warp-macos-26-arm64-6x env: DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer SIMULATOR_NAME: iPhone 16 Pro steps: - uses: actions/checkout@v4 - name: Record the toolchain in the job log run: | xcodebuild -version xcrun simctl list runtimes - name: Build and test run: | xcodebuild test \ -scheme AppScheme \ -destination "platform=iOS Simulator,name=$SIMULATOR_NAME,OS=27.0" \ -resultBundlePath TestResults.xcresult - uses: actions/upload-artifact@v4 if: always() with: name: test-results path: TestResults.xcresult ``` `DEVELOPER_DIR` is the lightest way to select a toolchain: it is scoped to the job, needs no `sudo`, and applies to `xcodebuild`, `swift`, and every `xcrun` invocation in the steps that follow. The alternative is `sudo xcode-select -s /Applications/Xcode_27.0.app/Contents/Developer` as its own step, which changes the machine-wide default for the rest of the job. The bundle names under `/Applications` are set by the image. Run `ls -1 /Applications | grep Xcode` once on the label you plan to use, or read the bundle list in the upstream image readme linked in the catalog table above, then pin the exact name. The `xcodebuild -version` and `xcrun simctl list runtimes` steps in the workflow above are cheap insurance: they put the toolchain and the available runtimes in the job log, so an image update that changes either shows up as a diff in the log rather than as a confusing compile error weeks later. ### [Pin the label rather than the latest alias](#pin-the-label-rather-than-the-latest-alias)Copy Anchor Link Copy Text ``` jobs: compatibility: strategy: fail-fast: false matrix: include: - runner: warp-macos-26-arm64-6x xcode_app: Xcode_27.0.app - runner: warp-macos-15-arm64-6x xcode_app: Xcode.app runs-on: ${{ matrix.runner }} env: DEVELOPER_DIR: /Applications/${{ matrix.xcode_app }}/Contents/Developer steps: - uses: actions/checkout@v4 - run: xcodebuild -version - run: xcodebuild build-for-testing -scheme AppScheme -destination "generic/platform=iOS Simulator" ``` Both legs name a versioned label. Neither uses `warp-macos-latest-arm64-6x`, and that is deliberate: the alias tracks GitHub's `macos-latest` tag, so the image under it moves when GitHub moves, and a job that depends on a specific Xcode version would move with it. `Xcode.app` is the image default on macOS 15, which is the right pin for a leg whose job is to catch breakage against whatever the current default toolchain is. A useful split for a mixed pipeline is to keep the pull request job on one pinned label, run the matrix on the merge queue, and give the release job the 12 vCPU label so the archive and notarization step gets the larger disk. Sizes and rates for both shapes are in the [cloud runner docs](/docs/ci/cloud-runners), and the per-image tooling links live in the [preinstalled software docs](/docs/ci/preinstalled-software). ### [Two behaviors to plan around](#two-behaviors-to-plan-around)Copy Anchor Link macOS runners do not support nested virtualization and cannot run Docker. A pipeline that lints with a containerized tool or builds a server image alongside the app should keep that work in a Linux job on a `warp-ubuntu-latest-x64-4x` runner at $0.008 per minute and pass artifacts between jobs, which is cheaper than paying macOS rates for a step that does not need macOS. Runner storage is ephemeral. A 120GB disk on the 6 vCPU shape is ample for a checkout, derived data, and one archive, and it fills faster when a job downloads an extra simulator runtime on top of the resident ones. Jobs that need several runtimes at once belong on the 12 vCPU shape with 270GB. Start on the [macOS runner hub](/runners/macos) for the full macOS lineup, or go straight to the workload pages for [iOS builds on GitHub Actions](/solutions/ios-github-actions) and [Swift builds on GitHub Actions](/solutions/swift-github-actions). Signup includes $10 free credits, which covers a few hundred minutes of macOS build time before any card is involved. ## [FAQ](#faq)Copy Anchor Link **Which Xcode versions are available on WarpBuild macOS runners?** Each macOS image carries the Xcode versions of the upstream GitHub image it is built from, and the macOS 26 labels `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` additionally ship Xcode 27.0 (build 27A266a) with the iOS 27.0, tvOS 27.0, watchOS 27.0, and visionOS 27.0 simulator runtimes. macOS 14 carries the Xcode 15 line with 15.4 as the default. **How do I select a specific Xcode version inside a workflow?** Set `DEVELOPER_DIR` to the `Contents/Developer` path of the Xcode bundle you want, or run `sudo xcode-select -s` on that path before the build step. Print `xcodebuild -version` in the same job so the log records which toolchain compiled the artifact. **Does `warp-macos-latest-arm64-6x` always give me the newest Xcode?** No. The latest alias resolves to the macOS 15 image, in sync with GitHub's `macos-latest` tag, so a job on that alias gets the macOS 15 Xcode set. Pin `warp-macos-26-arm64-6x` when you need the macOS 26 image and Xcode 27.0. **How long does Xcode 27.0 stay on the macOS 26 image?** Xcode 27.0 is a WarpBuild addition on top of the upstream GitHub macOS 26 image and is maintained only until the official macOS 27 images are released. Track the runner image changelog and re-pin your `DEVELOPER_DIR` path when that switch happens. --- # GitHub Actions Runners with KVM and Nested Virtualization | WarpBuild URL: https://www.warpbuild.com/runners/nested-virtualization Description: Nested virtualization on WarpBuild Linux x64 GitHub Actions runners exposes /dev/kvm via one runs-on label. Support matrix, rates, and Android emulator setup. # GitHub Actions Runners with KVM and Nested Virtualization Nested virtualization on WarpBuild Linux x64 GitHub Actions runners exposes /dev/kvm via one runs-on label. Support matrix, rates, and Android emulator setup. Last verified: 2026-08-13 Nested virtualization on WarpBuild runners for GitHub Actions is a Linux x64 feature. Adding `nested-virtualization.enabled=true` to the `runs-on` label places the job on hardware that exposes the virtualization extensions to the guest, which makes `/dev/kvm` available inside the runner, and on BYOC runner sets the same capability turns on automatically when every instance type selected for the set supports it ([nested virtualization documentation](/docs/ci/features/nested-virtualization)). This page is the reference for that feature: which runner classes carry it, what the sizes that carry it cost per minute, the exact label syntax, and the permissions step Android emulator workflows need before the emulator uses hardware acceleration. Specifications and rates come from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners) and were re-checked on 2026-08-13. ## [Catalog](#catalog)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. Nested virtualization is available on the Linux x64 class, on cloud runners through a label and on BYOC runner sets through the instance types you select. | Runner class | Nested virtualization | How it is enabled | | -------------------------- | --------------------- | ------------------------------------------------------- | | Cloud: Linux x86-64 | Yes | nested-virtualization.enabled=true on the runs-on label | | Cloud: Linux ARM64 | No | Label is ignored | | Cloud: Windows | No | Label is ignored | | Cloud: macOS | No | Label is ignored | | BYOC: AWS (Linux x86-64) | Yes | Automatic when all selected instance types support it | | BYOC: GCP (Linux x86-64) | Yes | Automatic when all selected instance types support it | | BYOC: Azure (Linux x86-64) | Yes | Automatic on supported VM sizes | | BYOC: ARM64 and Windows | No | Unavailable | Two operational consequences follow from that matrix. A `nested-virtualization.enabled=true` label on an unsupported cloud runner class is silently ignored and the job runs normally without `/dev/kvm`, so a misplaced label produces a passing job on software emulation rather than a failure you can see. And a BYOC runner set that mixes supporting and non-supporting instance types for availability gets nested virtualization on none of its jobs, because the capability is withheld from the whole set rather than varying between jobs inside it. Every Linux x64 size carries the feature. The five sizes and their specifications: | Size | vCPU | Memory | Storage | Rate | | ---- | ---- | ------ | --------- | ------------- | | 2x | 2 | 8GB | 150GB SSD | $0.004/minute | | 4x | 4 | 16GB | 150GB SSD | $0.008/minute | | 8x | 8 | 32GB | 150GB SSD | $0.016/minute | | 16x | 16 | 64GB | 150GB SSD | $0.032/minute | | 32x | 32 | 128GB | 150GB SSD | $0.064/minute | Three image families carry those five sizes, for fifteen Linux x64 labels in total: `warp-ubuntu-latest-x64-` on Ubuntu 24.04, aliased as `warp-ubuntu-2404-x64-`; `warp-ubuntu-2604-x64-` on Ubuntu 26.04; and `warp-ubuntu-2204-x64-` on Ubuntu 22.04\. The rate follows the size and stays the same across the three images, so moving from Ubuntu 22.04 to Ubuntu 26.04 changes the toolchain without changing the bill. Runner storage is ephemeral and is deleted when the runner terminates. For emulator work that means an AVD created during a job is gone at the end of it, and the boot cost belongs in the job time you budget. macOS runners do not support nested virtualization and cannot run Docker, so iOS work uses the simulator runtimes on the macOS image rather than a KVM guest. ## [Pricing](#pricing)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Nested virtualization has no separate line on the rate card, so a job that sets the label bills the per-minute rate of the size it ran on. | Size | Rate | 12-minute job | 10,560 minutes a month | | ---- | ------------- | ------------- | ---------------------- | | 2x | $0.004/minute | $0.048 | $42.24 | | 4x | $0.008/minute | $0.096 | $84.48 | | 8x | $0.016/minute | $0.192 | $168.96 | | 16x | $0.032/minute | $0.384 | $337.92 | | 32x | $0.064/minute | $0.768 | $675.84 | The monthly column models one instrumentation suite that takes 12 minutes and fires 40 times a day across 22 working days, which is 10,560 runner minutes. ### [Against the GitHub-hosted 8-core Linux shape](#against-the-github-hosted-8-core-linux-shape)Copy Anchor Link `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13 against the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The arithmetic, written out: ($0.022 - $0.016) / $0.022 = 0.2727, which rounds to 27 percent. Over the 10,560 minute month above that is $168.96 against $232.32, a difference of $63.36\. The comparison holds size for size and feature for feature, because GitHub's Linux larger hosted runners also expose hardware acceleration for Android virtualization ([GitHub changelog, February 2023](https://github.blog/changelog/2023-02-23-hardware-accelerated-android-virtualization-on-actions-windows-and-linux-larger-hosted-runners/)). Signup includes $10 free credits, which covers 625 minutes on the 8x size or 2,500 minutes on the 2x size. Every rate quoted here also appears on the [pricing page](/pricing). ## [Configuration](#configuration)Copy Anchor Link WarpBuild reads runner options from the `runs-on` string, separated by semicolons. One option is all this feature needs: Copy Text ``` jobs: instrumentation-tests: runs-on: warp-ubuntu-latest-x64-4x;nested-virtualization.enabled=true ``` Once `/dev/kvm` exists on the runner, its default device permissions of `crw-rw---- root:kvm` still stop the `runner` user opening it, so an Android emulator job needs a udev rule first: Copy Text ``` - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm ``` That step is required on all GitHub and GitHub-compatible runners and is documented in the [android-emulator-runner README](https://github.com/ReactiveCircus/android-emulator-runner#running-hardware-accelerated-emulators-on-linux-runners). Skipping it gives you a green build on the wrong engine: the action's `ProbeKVM` check fails, the log carries a line about the user lacking permission to use `/dev/kvm` followed by `Disabling Linux hardware acceleration.`, and the emulator command line includes `-accel off`. Searching an already-passing job log for `-accel off` is the quickest way to find out whether it ever had acceleration. A complete workflow, with a check that fails loudly when the device node is missing: Copy Text ``` name: android-instrumentation on: pull_request: branches: [main] jobs: instrumentation-tests: runs-on: warp-ubuntu-latest-x64-8x;nested-virtualization.enabled=true steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: "17" - name: Confirm the KVM device node is present run: ls -l /dev/kvm - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 arch: x86_64 script: ./gradlew connectedCheck ``` On BYOC the label comes out and the instance list does the work. Pick instance types that all support nested virtualization when you build the runner set, because a single unsupported type in the list turns the capability off for every job the set runs. When a supported label still reports no device node, the Action Debugger pauses the workflow and opens an SSH session on the runner, so you can inspect the guest instead of adding print statements and pushing again. For choosing a size for an emulator matrix, see [sizing runners for Android builds](/guides/sizing-runners-for-android-builds). For the whole workflow shape around instrumentation tests, see [Android emulator tests on GitHub Actions](/solutions/android-emulator-tests-github-actions). For the term itself, see [nested virtualization](/glossary/nested-virtualization), and for the short answer on whether emulators run at all, see [can GitHub Actions run an Android emulator](/answers/can-github-actions-run-android-emulator). ## [FAQ](#faq)Copy Anchor Link **Which WarpBuild runners support nested virtualization and KVM?** Linux x64\. On cloud runners you add `nested-virtualization.enabled=true` to the `runs-on` label, and on BYOC runner sets it is enabled automatically when every instance type selected for the set supports it. Linux ARM64, Windows, and macOS runner classes do not support it, and the label is ignored on them. **Does enabling nested virtualization cost extra?** No. Nested virtualization has no separate line on the rate card, so a job bills the per-minute rate of the size it ran on, from $0.004 a minute on the 2x Linux x64 size to $0.064 a minute on the 32x size. **Why does the Android emulator still run without hardware acceleration on a KVM runner?** Device permissions. The device node defaults to `crw-rw---- root:kvm`, which the `runner` user cannot open, so the emulator action reports that the user lacks permission to use KVM and launches the emulator with `-accel off`. Add the udev rule step that writes `99-kvm4all.rules` before the emulator step. --- # Preinstalled Software on WarpBuild Runners | WarpBuild URL: https://www.warpbuild.com/runners/preinstalled-software Description: WarpBuild runner images carry the same tooling as the GitHub-hosted image each one is built from. Per-image software lists, version checks, and rates. # Preinstalled Software on WarpBuild Runners WarpBuild runner images carry the same tooling as the GitHub-hosted image each one is built from. Per-image software lists, version checks, and rates. Last verified: 2026-08-13 Every WarpBuild runner image carries the same preinstalled tooling as the GitHub-hosted runner image it is built from, so the software inventory for any `warp-` label is the published runner-images readme for that operating system. The documented additions are Tailscale on every image, with the daemon stopped until networking is configured, and Xcode 27.0 with its simulator runtimes on the macOS 26 labels. This page maps each image family to the readme that lists its software, shows the workflow steps that prove a tool version exists before a job depends on it, and prices the two ways of closing a gap: installing the tool on every run, or baking it into an image. Labels, rates, and image notes were checked on 2026-08-13. ## [Catalog](#catalog)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. Each platform carries more than one image family, and the image selector inside the label is the part that decides the software inventory. The size multiplier changes hardware only, so a `2x` and a `32x` runner built from the same image ship an identical toolset. | Image family | Runner labels | Published software list | | ----------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Ubuntu 22.04 x64 | warp-ubuntu-2204-x64- | [Ubuntu2204-Readme.md](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2204-Readme.md) | | Ubuntu 24.04 x64 | warp-ubuntu-latest-x64-, alias warp-ubuntu-2404-x64- | [Ubuntu2404-Readme.md](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md) | | Ubuntu 26.04 x64 | warp-ubuntu-2604-x64- | [Ubuntu2604-Readme.md](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2604-Readme.md) | | Ubuntu 24.04 ARM64 | warp-ubuntu-latest-arm64-, alias warp-ubuntu-2404-arm64- | [arm-ubuntu-24-image.md](https://github.com/actions/partner-runner-images/blob/main/images/arm-ubuntu-24-image.md) | | Ubuntu 26.04 ARM64 | warp-ubuntu-2604-arm64- | [Ubuntu2604-Arm64-Readme.md](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2604-Arm64-Readme.md) | | macOS 14 | warp-macos-14-arm64-6x | [macos-14-arm64-Readme.md](https://github.com/actions/runner-images/blob/main/images/macos/macos-14-arm64-Readme.md) | | macOS 15 | warp-macos-15-arm64-6x, warp-macos-15-arm64-12x, aliases warp-macos-latest-arm64- | [macos-15-arm64-Readme.md](https://github.com/actions/runner-images/blob/main/images/macos/macos-15-arm64-Readme.md) | | macOS 26 | warp-macos-26-arm64-6x, warp-macos-26-arm64-12x | [macos-26-arm64-Readme.md](https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md), plus Xcode 27.0 | | Windows Server 2022 | warp-windows-latest-x64-, alias warp-windows-2022-x64- | [Windows2022-Readme.md](https://github.com/actions/runner-images/blob/main/images/windows/Windows2022-Readme.md) | | Windows Server 2025 | warp-windows-2025-x64- | Same tooling as the GitHub-hosted Windows Server 2025 image, with Visual Studio 2022 | | Windows Server 2025 (VS 2026) | warp-windows-2025-vs2026-x64- | The Windows Server 2025 base image with Visual Studio 2026 installed in place of Visual Studio 2022 | Sources: the [preinstalled software documentation](/docs/ci/preinstalled-software) and the [cloud runner documentation](/docs/ci/cloud-runners), both checked on 2026-08-13. ### [What the readme settles and what it does not](#what-the-readme-settles-and-what-it-does-not)Copy Anchor Link A runner-images readme is written per image release. It answers "which Python, Node, Go, and Java versions are on this image today" precisely, and it answers "which versions will be there next month" only until the next rebuild. WarpBuild rebuilds images from the upstream releases and records every rebuild in the [changelog](/docs/ci/changelog), so the readme is the right place to look while writing a workflow and the wrong place to rely on at run time. The step that belongs in the workflow is a version assertion, covered under Configuration below. ### [Additions on top of the upstream image](#additions-on-top-of-the-upstream-image)Copy Anchor Link Tailscale is preinstalled on all Linux, macOS, and Windows images. The daemon is not started by default and is activated on demand when [networking](/docs/ci/features/networking) is configured for the runner, so a job that never enables networking sees an installed binary and no running service. The macOS 26 labels ship Xcode 27.0 (build 27A266a) with the iOS 27.0 (24A434), tvOS 27.0 (24J360), watchOS 27.0 (24R362), and visionOS 27.0 (24M362) simulator runtimes. Xcode 27.0 is a WarpBuild addition on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. Pin the bundle path in `DEVELOPER_DIR` and expect one edit when that switch lands. The runner configuration also caches container images so pulls resolve faster than a first fetch on a cold image. ### [Differences worth knowing before you switch a label](#differences-worth-knowing-before-you-switch-a-label)Copy Anchor Link * Runner storage is ephemeral and is deleted when the runner terminates. Anything a job installs is gone before the next job starts. * Linux storage is 150GB of SSD on every size, Windows is 256GB, and macOS is 120GB on the 6 vCPU shape and 270GB on the 12 vCPU shape. * Ubuntu 24.04 ARM64 images set the work dir to `/runner/_work`, which differs from GitHub's `/home/runner/work/`. Scripts that hardcode the GitHub path need a `$GITHUB_WORKSPACE` reference instead. * macOS runners do not support nested virtualization and cannot run Docker, so container steps belong in a Linux job. On Linux x64, `/dev/kvm` access comes from the `nested-virtualization.enabled=true` label. * WarpBuild caches are enabled by default on Linux runners and are not supported on Windows runners, where `actions/cache` covers the same ground. * The ARM64 Ubuntu 22.04 images were deprecated on March 31, 2025, macOS 13 runners were removed on June 8, 2026, and Windows 2 vCPU runners were removed on the same date. ## [Pricing](#pricing)Copy Anchor Link The image does not change the rate. A per-minute price depends only on the machine shape, so Ubuntu 26.04 costs what Ubuntu 22.04 costs at the same size and macOS 26 costs what macOS 14 costs. Moving to a newer image to pick up newer tooling is a scheduling decision rather than a budget one. | Platform | Label pattern | Sizes and per-minute rates | | ----------- | -------------------------------- | ------------------------------------------------------- | | Linux x64 | warp-ubuntu--x64- | 2x $0.004, 4x $0.008, 8x $0.016, 16x $0.032, 32x $0.064 | | Linux ARM64 | warp-ubuntu--arm64- | 2x $0.003, 4x $0.006, 8x $0.012, 16x $0.024, 32x $0.048 | | macOS | warp-macos--arm64- | 6x $0.08, 12x $0.16 | | Windows | warp-windows--x64- | 4x $0.016, 8x $0.032, 16x $0.064, 32x $0.128 | Rates come from the [pricing page](/pricing) and the [cloud runner documentation](/docs/ci/cloud-runners), checked on 2026-08-13. Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Signup includes $10 free credits, which is 2,500 minutes on the 2 vCPU Linux x64 shape at $0.004 per minute or 125 minutes on the 6 vCPU macOS shape at $0.08 per minute, which is enough to measure the install step described below on both platforms before any card is charged. ### [What a missing tool costs per run](#what-a-missing-tool-costs-per-run)Copy Anchor Link When an image lacks something a job needs, the usual fix is a step that installs it on every run. That step is billed as runner time at the label rate, and because storage is ephemeral it repeats on every job. The table prices it on `warp-ubuntu-latest-x64-8x` at $0.016 per minute, at 880 runs a month, which is 40 runs a weekday across 22 weekdays. | Install step duration | Added cost per run | Added cost per month at 880 runs | | --------------------- | ------------------ | -------------------------------- | | 1 minute | $0.016 | $14.08 | | 3 minutes | $0.048 | $42.24 | | 6 minutes | $0.096 | $84.48 | | 10 minutes | $0.16 | $140.80 | The same six-minute step on `warp-macos-26-arm64-6x` at $0.08 per minute is $0.48 per run and $422.40 a month at that volume. Multiply by the shard count when a suite fans out, because each shard boots its own runner and repeats the install. Two alternatives price out against those rows. Caching the installed toolchain costs $0.20 per GB-month of cache storage plus $0.0001 per cache write or restore, so a 2GB toolchain cache is $0.40 a month in storage and $0.088 for 880 restores. Capturing the state with snapshot runners costs $0.04 per snapshot restore and $0.025 per snapshot-hour of storage, so 880 restores plus one snapshot held for a 30-day month is $35.20 plus $18.00\. Both land under the $84.48 that a six-minute install charges at the same volume, and both are listed in full on the [pricing page](/pricing). ## [Configuration](#configuration)Copy Anchor Link Three edits cover the common cases: record what the image has, fail the job when a required version is absent, and pin versions through setup actions rather than through the image default. ### [Record and assert the toolchain](#record-and-assert-the-toolchain)Copy Anchor Link Copy Text ``` name: verify-toolchain on: pull_request: workflow_dispatch: jobs: build: runs-on: warp-ubuntu-latest-x64-8x env: REQUIRED_NODE_MAJOR: "22" steps: - uses: actions/checkout@v4 - name: Record the image toolchain run: | uname -m node --version python3 --version docker --version ls /opt/hostedtoolcache - name: Fail early when the Node major version is wrong run: | major="$(node -p 'process.versions.node.split(".")[0]')" if [ "$major" != "$REQUIRED_NODE_MAJOR" ]; then echo "::error::image ships Node $major, this workflow requires $REQUIRED_NODE_MAJOR" exit 1 fi - uses: actions/setup-node@v4 with: node-version-file: .nvmrc cache: npm - run: npm ci - run: npm test ``` The record step is worth keeping permanently. It writes the image inventory into the job log, so a build that passed last week and fails today becomes a diff between two logs and an image version from the changelog. The assert step turns silent version drift into a failed step at second five instead of a confusing compile error at minute nine. The `setup-node` step then pins the version from a committed `.nvmrc`, which is what stops the job from depending on whatever the image happens to carry. The equivalent record steps on the other platforms: Copy Text ``` - name: Record the image toolchain on Windows shell: pwsh run: | $PSVersionTable.PSVersion dotnet --list-sdks & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property catalog_productDisplayVersion - name: Record the image toolchain on macOS run: | xcodebuild -version xcrun simctl list runtimes available ls /Applications | grep Xcode ``` ### [When the image is missing something](#when-the-image-is-missing-something)Copy Anchor Link Three routes close the gap, in ascending order of setup cost. 1. Install in the job. A setup action or the package manager, priced in the table above, with `actions/cache` keyed on a committed lock file so the download is paid once per dependency change rather than once per run. 2. Capture the installed state. Snapshot runners boot later jobs from a runner VM captured mid-workflow through the `snapshot.enabled=true` or `snapshot.key=` labels, on Linux x64 and Linux ARM64\. Snapshots are deleted after 15 days, so the capture job runs on a schedule. 3. Build a custom VM image and run it on BYOC. The [custom VM image requirements](/docs/ci/byoc/custom-vm-images) are specific: a `systemd`\-based distro, `curl`, `wget`, `bash`, `jq`, and `libicu` present, and `tar`, `gzip`, `coreutils`, `shadow-utils`, and `systemd` left in place. Windows images need `aria2` on the system `PATH`, and Windows AMIs on AWS must be sysprepped before capture. There is no additional cost for using custom VM images. [Build custom runner images for GitHub Actions](/guides/custom-runner-images-github-actions) walks the full build and registration path. Two tools change the calculation here. Remote Docker builders keep container toolchains off the runner image, so a job that only needs `docker build` does not need a bigger runner image. The Action Debugger opens an SSH session on a paused runner, which is the fastest way to answer "what is actually installed" for a failure that reproduces only in GitHub Actions. ### [Where to go next](#where-to-go-next)Copy Anchor Link * [Linux x64 runners for GitHub Actions](/runners/linux-x64) covers the Ubuntu image families, shapes, and rates. * [Windows runners for GitHub Actions](/runners/windows) covers the Windows Server and Visual Studio image split. * [macOS runners for GitHub Actions](/runners/macos) covers the macOS images and the Xcode versions on each. * The [preinstalled software documentation](/docs/ci/preinstalled-software) is the source of record for every readme link above. ## [FAQ](#faq)Copy Anchor Link **Is the software on WarpBuild runners the same as on GitHub-hosted runners?** Yes. Each WarpBuild image carries the same tooling as the GitHub-hosted image it is built from, and the preinstalled software documentation links the published runner-images readme for every image family. Two documented additions sit on top: Tailscale is present on all Linux, macOS, and Windows images with the daemon stopped until networking is configured, and the macOS 26 labels ship Xcode 27.0 with its simulator runtimes. **How do I check that a tool version exists before a job depends on it?** Add a step that prints the versions the job needs (`node --version`, `dotnet --list-sdks`, `xcodebuild -version`) and a second step that exits non-zero when the version is wrong. The print step keeps the inventory in the job log, so a break after an image rebuild becomes a diff between two logs rather than a guess. **What are my options when the image is missing a tool?** Install it in the job with a setup action or the package manager, capture the installed state with snapshot runners on Linux, or build a custom VM image and run it on BYOC. There is no additional cost for using custom VM images; the job-time install is billed as runner minutes at the label rate. --- # Snapshot Runners for GitHub Actions | WarpBuild URL: https://www.warpbuild.com/runners/snapshot Description: Snapshot runners boot a GitHub Actions job from a saved WarpBuild Ubuntu runner disk, so dependencies, working trees and pulled images are already there. # Snapshot Runners for GitHub Actions Snapshot runners boot a GitHub Actions job from a saved WarpBuild Ubuntu runner disk, so dependencies, working trees and pulled images are already there. Last verified: 2026-08-13 A snapshot runner is a GitHub Actions runner that boots from a disk image captured during an earlier job instead of from the base runner image. WarpBuild supports snapshots on its Cloud Ubuntu runners: append `snapshot.enabled=true` or `snapshot.key=` to the `runs-on` label, capture the machine mid-workflow with the `WarpBuilds/snapshot-save` action, and later jobs start with dependencies, checkouts, and pulled container images already on disk. Three limits shape every decision below. Snapshots run on WarpBuild Cloud Ubuntu runners only. Every snapshot is deleted after 15 days. Snapshot restore and snapshot storage bill as their own line items on top of runner minutes. ## [Catalog](#catalog)Copy Anchor Link ### [What a snapshot holds](#what-a-snapshot-holds)Copy Anchor Link A snapshot captures the state of the runner VM disk at the moment the `snapshot-save` action runs. A later job that boots from that snapshot starts from that disk image, so everything written to the filesystem before the save is present: * installed system packages and language toolchains * dependency trees such as `node_modules`, `vendor/`, and `~/.cargo` * the git working tree and its `.git` directory in the workspace * Docker images already pulled into the local image store * compiler and build caches under `$HOME`, including Bazel and Gradle output directories State that lives only in memory is dropped. The machine boots from the snapshot, so no process from the earlier job is running, no port is listening, and no container is up. Any service your steps depend on has to be started again by the job that boots from the snapshot. `/tmp` does not persist. The directory is cleaned on reboot and a snapshot boot is a reboot, so anything a build wrote to `/tmp` is gone on the next run. Keep state you want inside the snapshot under `$HOME`, inside the workspace, or in another path outside `/tmp`. Job identity resets on every run. GitHub hands the job to a fresh runner registration with its own `GITHUB_*` environment, its own secrets, and its own token. The snapshot supplies the disk; GitHub supplies the job. A runner that boots from a snapshot exposes the environment variable `WARPBUILD_SNAPSHOT_KEY`, set to the alias it booted from. Steps read that variable to branch, for example to skip a full dependency install when the tree is already warm, or to print which alias a run started from. Snapshots are deleted after 15 days. When an alias expires or has never been written, a job with `snapshot.key=` still runs: the runner boots from the base image and pays the setup time back on that run. ### [Where snapshot labels work](#where-snapshot-labels-work)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. Only the Cloud Ubuntu runners accept snapshots. BYOC runs on AWS, GCP, and Azure, and snapshots are unsupported on all three. | Runner surface | Snapshot support | Behavior with a snapshot label | | ---------------------------- | ---------------- | ------------------------------------------------------- | | WarpBuild Cloud Ubuntu x64 | Supported | Boots from the snapshot or the base image per the label | | WarpBuild Cloud Ubuntu ARM64 | Supported | Boots from the snapshot or the base image per the label | | WarpBuild Cloud macOS | Unsupported | Label silently ignored, job runs normally | | WarpBuild Cloud Windows | Unsupported | Label silently ignored, job runs normally | | BYOC on AWS, GCP, or Azure | Unsupported | Label silently ignored, job runs normally | The silent part matters in practice. A macOS or Windows job carrying `snapshot.key=ios-deps` produces no warning and no error. It runs at full setup cost every time, and the only symptom is a job duration that never improves. Grep your workflows for snapshot labels on non-Ubuntu runners before you go hunting for a bug. ### [Snapshot capable runner sizes](#snapshot-capable-runner-sizes)Copy Anchor Link Every Cloud Ubuntu size accepts a snapshot label. Rates below are WarpBuild list prices per minute, verified on 2026-08-13. | Label | Image | vCPU | RAM | Storage | Per minute | | ---------------------------- | ------------ | ---- | ------ | --------- | ---------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.064 | | warp-ubuntu-latest-arm64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.003 | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.006 | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.012 | | warp-ubuntu-latest-arm64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.024 | | warp-ubuntu-latest-arm64-32x | Ubuntu 24.04 | 32 | 128 GB | 150GB SSD | $0.048 | Pinned image labels carry the same sizes and the same rates: `warp-ubuntu-2604-x64-*` and `warp-ubuntu-2404-x64-*` for Ubuntu 26.04 and 24.04, `warp-ubuntu-2204-x64-*` for Ubuntu 22.04, plus `warp-ubuntu-2604-arm64-*` and `warp-ubuntu-2404-arm64-*` on ARM64\. Ubuntu 22.04 ARM64 images were deprecated on March 31, 2025\. Pin the image when a snapshot alias has to stay valid across image refreshes, because a base image change and a snapshot built on the older base can drift apart. One layout detail applies to ARM64: Ubuntu 24.04 ARM64 runners set the work dir to `/runner/_work`, where GitHub-hosted runners use `/home/runner/work/`. Absolute paths baked into a snapshot on one architecture do not transfer to the other. ## [Pricing](#pricing)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. Snapshots add two metered lines on top of runner minutes. | Line item | Rate | When it bills | | ------------------------- | ----------------------------------- | ----------------------------------- | | Ubuntu runner time, x64 | $0.004 to $0.064 per minute by size | Every job minute | | Ubuntu runner time, ARM64 | $0.003 to $0.048 per minute by size | Every job minute | | Snapshot restore | $0.04 per job | Each job that boots from a snapshot | | Snapshot storage | $0.025 per snapshot-hour | While a snapshot exists | | Cache storage | $0.20 per GB-month | While cache entries exist | | Cache write or restore | $0.0001 per operation | Per cache operation | | Networking (Tailscale) | Free | Not billed | The CI Billing report keeps these apart. Its summary cards show total cost, runner cost, snapshot cost, and total jobs for the period, and each table row shows one job execution with its runner label, snapshot usage, billed time, and a cost breakdown split into runner plus snapshot. Filter by snapshot usage to see which repositories are actually paying the restore fee. Full field list is in the [WarpBuild reports documentation](/docs/ci/features/reports). ### [The break-even calculation](#the-break-even-calculation)Copy Anchor Link A restore costs $0.04 regardless of runner size, so the amount of work a snapshot has to remove before it pays for itself falls as the runner gets bigger. Divide $0.04 by the per-minute rate. | Label | Rate per minute | Minutes a restore must remove to break even | | --------------------------- | --------------- | ------------------------------------------- | | warp-ubuntu-latest-x64-2x | $0.004 | 10.00 | | warp-ubuntu-latest-x64-4x | $0.008 | 5.00 | | warp-ubuntu-latest-x64-8x | $0.016 | 2.50 | | warp-ubuntu-latest-x64-16x | $0.032 | 1.25 | | warp-ubuntu-latest-x64-32x | $0.064 | 0.63 | | warp-ubuntu-latest-arm64-8x | $0.012 | 3.33 | Storage is the second term. At $0.025 per snapshot-hour, one alias kept alive costs $0.60 per day and $18.00 per 30-day month. An alias that stops being refreshed is deleted after 15 days, which caps an abandoned alias at $9.00 (15 days times 24 hours times $0.025). Divide that monthly storage figure across the jobs that use the alias: 1,000 jobs against one alias adds $0.018 per job, while 50 jobs against the same alias adds $0.36 per job and usually wipes out the gain. ### [Worked model: a monorepo test job that pays off](#worked-model-a-monorepo-test-job-that-pays-off)Copy Anchor Link A repository runs 1,000 jobs per month on `warp-ubuntu-latest-x64-16x`. Setup, which is dependency install plus a cold build cache, takes 8 minutes of a 20-minute job. A snapshot carries that state, so the job runs 12 minutes. | Line | Without snapshots | With one snapshot alias | | ---------------------------------- | ----------------- | ----------------------- | | Billed minutes per job | 20.0 | 12.0 | | Runner cost per job | $0.640 | $0.384 | | Snapshot restore per job | $0.000 | $0.040 | | Cost per job | $0.640 | $0.424 | | 1,000 jobs | $640.00 | $424.00 | | Snapshot storage, 1 alias, 30 days | $0.00 | $18.00 | | Monthly total | $640.00 | $442.00 | For an external reference point, the same 20-minute job on the 16-core GitHub-hosted Linux larger runner (16 vCPU, 64 GB) lists at $0.042 per minute, which is $0.84 per job and $840.00 for 1,000 jobs. GitHub list price from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. GitHub-hosted runners have no snapshot equivalent, so that column has no restore or storage line. ### [Worked model: a small job where snapshots lose](#worked-model-a-small-job-where-snapshots-lose)Copy Anchor Link The same arithmetic run on a small runner goes the other way. A repository runs 1,000 jobs per month on `warp-ubuntu-latest-x64-2x`, and the snapshot removes 4 minutes from a 10-minute job. | Line | Without snapshots | With one snapshot alias | | ---------------------------------- | ----------------- | ----------------------- | | Billed minutes per job | 10.0 | 6.0 | | Runner cost per job | $0.040 | $0.024 | | Snapshot restore per job | $0.000 | $0.040 | | Cost per job | $0.040 | $0.064 | | 1,000 jobs | $40.00 | $64.00 | | Snapshot storage, 1 alias, 30 days | $0.00 | $18.00 | | Monthly total | $40.00 | $82.00 | The 4 minutes removed sit below the 10-minute break-even for a 2x runner, so the restore fee alone reverses the result before storage is counted. Run this arithmetic per workflow with your own numbers from CI Billing before you add a snapshot label. Every rate used here comes from the [WarpBuild pricing page](/pricing) and was verified on 2026-08-13. Signup includes $10 free credits, which covers a few hundred snapshot restores while you measure your own break-even point. ## [Configuration](#configuration)Copy Anchor Link Snapshot behavior is selected entirely from the `runs-on` label. Append the setting to the runner label with a semicolon. * `warp-ubuntu-latest-x64-4x;snapshot.enabled=true` turns the feature on. The runner always boots from the base image. Use `snapshot-save` to capture the machine at the point in the workflow you want later jobs to start from. * `warp-ubuntu-latest-x64-4x;snapshot.key=my-alias` turns the feature on and boots from the existing snapshot for that alias when one is available. When no snapshot exists for the alias, the runner boots from the base image. The `WarpBuilds/snapshot-save` action takes three inputs: `alias` (required, the unique name you boot from later), `fail-on-error` (optional, default `true`), and `wait-timeout-minutes` (optional, default `30`). Set `fail-on-error: false` when a failed capture should leave the pipeline green and let the next run try again. Full label reference and the security notes live in the [WarpBuild snapshot runners documentation](/docs/ci/features/snapshot-runners). ### [Use case 1: warm dependency caches](#use-case-1-warm-dependency-caches)Copy Anchor Link The pattern here separates who writes from who reads. Pushes to `main` boot clean with `snapshot.enabled=true` and publish a fresh snapshot. Pull requests boot from that alias with `snapshot.key`, so the install step finds the dependency tree already in place. Copy Text ``` name: test on: push: branches: [main] pull_request: jobs: unit-tests: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-4x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-4x;snapshot.key=web-deps' }} steps: - uses: actions/checkout@v5 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run tests run: pnpm test - name: Remove credentials before the snapshot if: github.ref == 'refs/heads/main' run: rm -rf $HOME/.ssh $HOME/.aws $HOME/.npmrc - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "web-deps" fail-on-error: false wait-timeout-minutes: 30 ``` The WarpBuild docs recommend `git clean -ffdx` in the cleanup step. That command removes gitignored paths, which includes `node_modules` and most dependency directories, so it cancels this use case. Use it when the state you want in the snapshot lives outside the repository, and remove credential files by path when the state you want lives inside it. A lockfile change on a snapshot boot is handled by the package manager, since `pnpm install --frozen-lockfile` reconciles the existing tree against the new lockfile rather than starting from nothing. Refresh the alias on `main` regularly so the drift stays small. ### [Use case 2: large working trees](#use-case-2-large-working-trees)Copy Anchor Link Repositories with long histories or large build outputs spend real minutes on `actions/checkout` and on cold build caches. A snapshot carries both the `.git` directory and the build output tree, and `actions/checkout` reuses an existing checkout when the directory already holds the right repository, so the fetch transfers only new objects. Copy Text ``` name: build on: push jobs: build: runs-on: warp-ubuntu-latest-x64-16x;snapshot.key=monorepo-worktree steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Report boot source run: echo "booted from ${WARPBUILD_SNAPSHOT_KEY:-base image}" - name: Build run: bazel build //... - name: Remove credentials before the snapshot run: rm -rf $HOME/.ssh $HOME/.aws - name: Save snapshot uses: WarpBuilds/snapshot-save@v1 with: alias: "monorepo-worktree" fail-on-error: false wait-timeout-minutes: 45 ``` Every run here refreshes the alias, so each snapshot builds on the previous one. That keeps the working tree and the Bazel output base warm across the branch. Sizing and remote cache setup for this shape are covered in the [Bazel on GitHub Actions solution page](/solutions/bazel-github-actions). Raise `wait-timeout-minutes` when the disk is large. The default of 30 minutes is generous for a dependency tree and tight for a snapshot holding tens of gigabytes of build output. ### [Use case 3: prepared containers](#use-case-3-prepared-containers)Copy Anchor Link Integration suites that pull database, queue, and fixture images spend minutes on registry transfers. Images pulled into the local Docker store live on disk, so they survive in the snapshot. Running containers do not, because the machine reboots, so bring services up in every job. Copy Text ``` name: integration on: pull_request jobs: integration: runs-on: warp-ubuntu-latest-x64-8x;snapshot.key=integration-images steps: - uses: actions/checkout@v5 - name: Pull service images run: docker compose -f docker-compose.ci.yml pull --quiet - name: Start services run: docker compose -f docker-compose.ci.yml up -d --wait - name: Run integration tests run: ./scripts/integration-tests.sh - name: Stop services before the snapshot run: docker compose -f docker-compose.ci.yml down - name: Remove credentials before the snapshot run: rm -rf $HOME/.ssh $HOME/.aws $HOME/.docker/config.json - name: Save snapshot uses: WarpBuilds/snapshot-save@v1 with: alias: "integration-images" fail-on-error: false wait-timeout-minutes: 30 ``` The `docker pull` step stays in the workflow on purpose. On a snapshot boot it confirms the digests already present and downloads only what changed. ### [Cleanup and security](#cleanup-and-security)Copy Anchor Link Run a cleanup step before every save. Registry credentials in `$HOME/.docker/config.json`, cloud credentials in `$HOME/.aws`, and SSH keys in `$HOME/.ssh` all sit on the disk the snapshot captures. Two exposure paths make this concrete. On a public repository, anyone who can open a pull request can reference the alias in a workflow run and boot a machine from your snapshot. On a private repository, WarpBuild provisions runners at the organization level and GitHub may hand a runner intended for snapshot jobs to a different job in the organization, which puts snapshot contents in front of other members of the org. ### [When to stay on stateless runners](#when-to-stay-on-stateless-runners)Copy Anchor Link Snapshots earn their fee under narrow conditions. Skip them when any of these hold: * **Setup is shorter than the break-even.** Below 10 minutes of removable work on a 2x runner or 5 minutes on a 4x runner, the restore fee costs more than it saves. * **A cache action already covers the state.** Dependency directories restore well from the [WarpBuild cache](/docs/ci/features/caching) with `WarpBuilds/cache@v1`, which bills $0.0001 per operation and $0.20 per GB-month against the $0.04 restore fee. Start there and read the [persistent caches for GitHub Actions guide](/guides/persistent-caches-github-actions) before adding a snapshot alias. Teams hitting entry size ceilings should read the [GitHub Actions cache size limit guide](/guides/github-actions-cache-size-limit) first. * **The job must start from a known clean disk.** Release builds, compliance jobs, and anything that signs artifacts should boot from the base image. * **Wall clock matters more than minutes.** Snapshot runners take 45 to 60 seconds to boot, which is slower than a default runner boot. * **The workflow runs infrequently.** Snapshots expire after 15 days, and a low-volume alias spreads $18.00 of monthly storage across too few jobs. * **The runner is macOS, Windows, or BYOC.** The label is silently ignored on those surfaces. ### [Automation](#automation)Copy Anchor Link Wire a scheduled job to delete aliases whose workflow was removed, so storage stops billing before the 15-day expiry does it for you. Definitions of the concept and its neighbors sit on [what are snapshot runners](/answers/what-are-snapshot-runners). ## [FAQ](#faq)Copy Anchor Link ### [Do snapshot runners work on Windows, macOS, or BYOC runners?](#do-snapshot-runners-work-on-windows-macos-or-byoc-runners)Copy Anchor Link No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. If you put `snapshot.enabled=true` or `snapshot.key=` on a BYOC, Windows, or macOS runner label, the label is silently ignored and the job runs normally without snapshot functionality. ### [What is the difference between snapshot.enabled=true and snapshot.key=alias?](#what-is-the-difference-between-snapshotenabledtrue-and-snapshotkeyalias)Copy Anchor Link `snapshot.enabled=true` turns the feature on and always boots the runner from the base image, so it is the label for the job that produces a clean snapshot. `snapshot.key=` also turns the feature on and boots from the existing snapshot for that alias when one exists, falling back to the base image when it does not. ### [Does /tmp persist across a snapshot?](#does-tmp-persist-across-a-snapshot)Copy Anchor Link No. The `/tmp` directory is cleaned on reboot and a snapshot boot is a reboot, so nothing written to `/tmp` survives. Keep state you want in the snapshot under `$HOME`, inside the workspace, or in another path outside `/tmp`. ### [How long does a WarpBuild snapshot live and what does it cost to keep?](#how-long-does-a-warpbuild-snapshot-live-and-what-does-it-cost-to-keep)Copy Anchor Link Snapshots are deleted after 15 days. Storage bills at $0.025 per snapshot-hour, which is $0.60 per day and $18.00 per 30-day month for one alias kept alive, and a restore bills $0.04 for each job that boots from a snapshot. Both lines appear separately from runner cost on the CI Billing report and on the [pricing page](/pricing). --- # Windows Runners for GitHub Actions: Sizes and Pricing | WarpBuild URL: https://www.warpbuild.com/runners/windows Description: WarpBuild runs Windows Server 2022 and 2025 GitHub Actions runners at 4 to 32 vCPU for $0.016 to $0.128 per minute. Labels, images, and cost models. # Windows Runners for GitHub Actions: Sizes and Pricing WarpBuild runs Windows Server 2022 and 2025 GitHub Actions runners at 4 to 32 vCPU for $0.016 to $0.128 per minute. Labels, images, and cost models. Last verified: 2026-08-13 WarpBuild runs Windows GitHub Actions runners on Windows Server 2022 and Windows Server 2025 in four x86-64 sizes, from 4 vCPU with 16GB of memory up to 32 vCPU with 128GB, priced from $0.016 to $0.128 per minute. Changing `runs-on` to a `warp-windows-` label moves the job, and because the images carry the same tooling as the GitHub-hosted Windows equivalents, the rest of the workflow file stays as it is. This page lists every Windows label with its image, shape, storage, and alias; sets the per-minute rates against GitHub's published list prices for the equivalent Windows larger runners; and shows the workflow configuration for .NET, MSBuild, and NuGet builds. Rates and catalog rows were checked on 2026-08-13. ## [Catalog](#catalog)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Windows fleet is x86-64 only and splits into three image families that share one set of four machine shapes. | Runner label | Image | vCPU | Memory | Storage | Alias | | -------------------------------- | ------------------------------------------- | ---- | ------ | --------- | ------------------------- | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16GB | 256GB SSD | warp-windows-2022-x64-4x | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32GB | 256GB SSD | warp-windows-2022-x64-8x | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64GB | 256GB SSD | warp-windows-2022-x64-16x | | warp-windows-latest-x64-32x | Windows Server 2022 | 32 | 128GB | 256GB SSD | warp-windows-2022-x64-32x | | warp-windows-2025-x64-4x | Windows Server 2025 | 4 | 16GB | 256GB SSD | none | | warp-windows-2025-x64-8x | Windows Server 2025 | 8 | 32GB | 256GB SSD | none | | warp-windows-2025-x64-16x | Windows Server 2025 | 16 | 64GB | 256GB SSD | none | | warp-windows-2025-x64-32x | Windows Server 2025 | 32 | 128GB | 256GB SSD | none | | warp-windows-2025-vs2026-x64-4x | Windows Server 2025 with Visual Studio 2026 | 4 | 16GB | 256GB SSD | none | | warp-windows-2025-vs2026-x64-8x | Windows Server 2025 with Visual Studio 2026 | 8 | 32GB | 256GB SSD | none | | warp-windows-2025-vs2026-x64-16x | Windows Server 2025 with Visual Studio 2026 | 16 | 64GB | 256GB SSD | none | | warp-windows-2025-vs2026-x64-32x | Windows Server 2025 with Visual Studio 2026 | 32 | 128GB | 256GB SSD | none | Source: [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. ### [Reading the label](#reading-the-label)Copy Anchor Link A Windows label has four parts: the `warp-windows` prefix, an image selector (`latest`, `2025`, or `2025-vs2026`), the architecture (`x64`), and the size multiplier (`4x`, `8x`, `16x`, `32x`). The multiplier tracks vCPU count directly, so `8x` is 8 vCPU and `32x` is 32 vCPU. Memory scales at 4GB per vCPU across the whole Windows range, and storage is fixed at 256GB of SSD on every size rather than growing with the shape. The `warp-windows-latest-x64-` labels currently resolve to Windows Server 2022\. The `warp-windows-2022-x64-` aliases point at the same machines, so a repository that wants to stay pinned to Windows Server 2022 through a future `latest` rollover should write the dated alias instead of `latest`. ### [The three image families](#the-three-image-families)Copy Anchor Link **Windows Server 2022** is the default. It is what `latest` resolves to today and it is the closest match to the GitHub-hosted `windows-2022` image, which makes it the low-risk target for a workflow that is being moved over without any other changes. **Windows Server 2025** is the newer platform image and carries Visual Studio 2022, the same Visual Studio generation as the 2022 image. Moving a job from `warp-windows-latest-x64-8x` to `warp-windows-2025-x64-8x` changes the operating system underneath the build while leaving the toolset generation alone, which keeps the variable count down when something regresses. **Windows Server 2025 with Visual Studio 2026** uses the same Windows Server 2025 base image with Visual Studio 2026 installed in place of Visual Studio 2022, selected by the `warp-windows-2025-vs2026-x64-` labels. These labels are transitional. Visual Studio 2026 may become the default for the Windows Server 2025 runners in a later update, which mirrors [GitHub's own rollout of the Windows Server 2025 with Visual Studio 2026 image](https://github.com/actions/runner-images/issues/14016). Treat the `vs2026` label as the way to test a Visual Studio 2026 toolset ahead of that switch rather than as a permanent pin. ### [Shapes that were removed](#shapes-that-were-removed)Copy Anchor Link The 2 vCPU Windows runners were removed on June 8, 2026\. Workflows that referenced a `2x` Windows label need to move to `4x` or larger. Cloud spot runners were removed on the same date, so the `*-spot` Windows labels are gone as well; spot capacity now comes from BYOC runners in your own cloud account. ### [What ships on the image](#what-ships-on-the-image)Copy Anchor Link The Windows x86-64 runner images carry the same tooling as the GitHub-hosted equivalents, so `dotnet`, `msbuild`, `nuget`, `git`, PowerShell, and the Visual Studio build tools are present before the first step runs. The version list for the Windows Server 2022 image is published in the [preinstalled software documentation](/docs/ci/preinstalled-software), which links straight to GitHub's own image readme for the same generation. Tailscale is present on every Windows image with the daemon stopped until networking is configured for the runner. Runner storage is ephemeral. A Windows runner is a fresh virtual machine per job and the 256GB volume is destroyed when the job finishes, so nothing carries over between jobs unless it goes through a cache action or an artifact upload. One Windows-specific gap is worth planning around: WarpBuild caches are not supported for Windows-based runners. Windows jobs use `actions/cache` for NuGet packages and build output the same way they would on a GitHub-hosted Windows runner. The configuration section below shows the cache step that matters most for .NET. ### [Concurrency](#concurrency)Copy Anchor Link Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. In practice this matters more on Windows than on Linux, because Windows matrices tend to be wide (several target frameworks times several runtime identifiers) and a queue ceiling turns a wide matrix into a serial one. ## [Pricing](#pricing)Copy Anchor Link Windows runner rates run from $0.016 per minute at 4 vCPU to $0.128 per minute at 32 vCPU. The rate depends only on the shape, so a Windows Server 2025 runner and a Visual Studio 2026 runner of the same size cost the same as the Windows Server 2022 runner of that size. | Size | vCPU | Memory | WarpBuild label | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | Difference per minute | Lower list price | | ---- | ---- | ------ | --------------------------- | -------------------- | ---------------------------------------------- | ----------------- | --------------------- | ---------------- | | 4x | 4 | 16GB | warp-windows-latest-x64-4x | $0.016 | 4-core Windows larger runner (4 vCPU, 16GB) | $0.022 | $0.006 | 27 percent | | 8x | 8 | 32GB | warp-windows-latest-x64-8x | $0.032 | 8-core Windows larger runner (8 vCPU, 32GB) | $0.042 | $0.010 | 24 percent | | 16x | 16 | 64GB | warp-windows-latest-x64-16x | $0.064 | 16-core Windows larger runner (16 vCPU, 64GB) | $0.082 | $0.018 | 22 percent | | 32x | 32 | 128GB | warp-windows-latest-x64-32x | $0.128 | 32-core Windows larger runner (32 vCPU, 128GB) | $0.162 | $0.034 | 21 percent | GitHub list prices for the Windows larger runner SKUs come from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the shapes from the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), both checked on 2026-08-13\. GitHub also publishes plan-level pricing at [github.com/pricing](https://github.com/pricing). Every row above pairs a WarpBuild size with the GitHub shape that has the same vCPU and memory, so the comparison is per-minute rate against per-minute rate at equal hardware. WarpBuild rates for every platform are listed on the [WarpBuild pricing page](/pricing). ### [What the rate covers](#what-the-rate-covers)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee. You are billed for runner minutes, so adding a tenth engineer to the organization changes the bill only through the jobs they queue. Signup includes $10 free credits, which at the 8 vCPU Windows rate covers roughly 312 runner minutes before any card is charged ($10 / $0.032 = 312.5 minutes). SSO is a flat $250 per month, whatever the user count, listed alongside the runner rates on the [pricing page](/pricing). ### [Worked model: a Windows build fleet](#worked-model-a-windows-build-fleet)Copy Anchor Link Take a repository whose Windows lane runs 300 jobs per working day at an average of 9 minutes each, on the 8 vCPU shape. * Minutes per day: 300 jobs x 9 minutes = 2,700 minutes * Minutes per month at 21 working days: 2,700 x 21 = 56,700 minutes * On `warp-windows-latest-x64-8x` at $0.032 per minute: 56,700 x $0.032 = $1,814.40 * On the 8-core GitHub-hosted Windows larger runner at $0.042 per minute: 56,700 x $0.042 = $2,381.40 * Monthly difference at identical shape and identical minutes: $567.00 The same arithmetic at a flat 10,000 Windows minutes per month, which is easier to scale to your own volume: | Size | 10,000 minutes on WarpBuild | 10,000 minutes on the GitHub-hosted equivalent | Difference | | ------- | --------------------------- | ---------------------------------------------- | ---------- | | 4 vCPU | $160.00 | $220.00 | $60.00 | | 8 vCPU | $320.00 | $420.00 | $100.00 | | 16 vCPU | $640.00 | $820.00 | $180.00 | | 32 vCPU | $1,280.00 | $1,620.00 | $340.00 | GitHub rates in both models are the published list prices checked on 2026-08-13 at the link above. Both models hold minutes constant on each side, which is the conservative comparison. Substitute your own monthly Windows minutes from the Actions usage report to get your number; the structure of the model does not change. ### [Worked model: choosing a size](#worked-model-choosing-a-size)Copy Anchor Link Windows rates double with each step up the size ladder, so a larger runner pays for itself only when it cuts wall-clock time by more than half. The break-even rule for a step from `4x` to `8x` is: Copy Text ``` cost_4x = duration_on_4x x $0.016 cost_8x = duration_on_8x x $0.032 ``` Plug in your own measured durations from two runs of the same commit. Worked with an example pair of 14 minutes on 4 vCPU and 9 minutes on 8 vCPU: * `4x`: 14 minutes x $0.016 = $0.224 per job * `8x`: 9 minutes x $0.032 = $0.288 per job * Delta: $0.064 more per job to return 5 minutes of wall-clock time per job At 300 jobs per day and 21 working days that is $403.20 more per month and 525 engineer-hours per month of pull request feedback returned (6,300 jobs x 5 minutes). Whether that trade is worth taking is a question for the team; the point is that both sides of it are computable from the per-minute table, and per-minute billing means a job that finishes early stops costing money at the moment it finishes. MSBuild parallelism is what decides where a solution lands on that curve. A solution with a deep project dependency chain will not use 32 vCPU no matter what `/m` is set to, and paying the 32 vCPU rate for a chain that saturates 8 cores burns the difference for nothing. Measure at `8x` first, then step up only for solutions that show the cores being used. ### [BYOC Windows runners](#byoc-windows-runners)Copy Anchor Link BYOC runs on AWS, GCP, and Azure across the WarpBuild platform. For the Windows fleet specifically, BYOC is available on AWS and Azure. Windows BYOC runners cost $0.002 per minute in WarpBuild fees and the compute is billed by your own cloud account, where AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices. AWS discount checked on 2026-08-13\. Terraform support exists for BYOC on AWS, so the runner pool can be declared in the same configuration as the rest of the account. ## [Configuration](#configuration)Copy Anchor Link A Windows job moves by editing one line. The label goes in `runs-on` and everything else in the workflow stays as it is. Copy Text ``` name: windows-build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-windows-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - name: Cache NuGet packages uses: actions/cache@v4 with: path: ~\.nuget\packages key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} restore-keys: | nuget-${{ runner.os }}- - run: dotnet restore Contoso.sln --locked-mode - run: dotnet build Contoso.sln --configuration Release --no-restore - run: dotnet test Contoso.sln --configuration Release --no-build --logger trx ``` ### [Selecting an image](#selecting-an-image)Copy Anchor Link Start on `warp-windows-latest-x64-` when moving an existing workflow. It resolves to Windows Server 2022, which is the closest match to the GitHub-hosted `windows-2022` image, so a red build after the switch points at the machine size or the runner environment rather than at an operating system upgrade. Move to `warp-windows-2025-x64-` when the build needs the Windows Server 2025 platform. The Visual Studio generation stays at 2022 on this image, so the only variable that changes is the operating system. Use `warp-windows-2025-vs2026-x64-` when the build needs Visual Studio 2026\. Because these labels are transitional, run them on a branch or a scheduled job first and keep the production lane on a dated label until the toolset is proven. A matrix is the cheapest way to prove an image migration before committing to it. Run the same job across all three images at the smallest size and compare: Copy Text ``` name: windows-image-compat on: schedule: - cron: "0 6 * * 1" workflow_dispatch: jobs: compat: strategy: fail-fast: false matrix: image: - warp-windows-latest-x64-4x - warp-windows-2025-x64-4x - warp-windows-2025-vs2026-x64-4x runs-on: ${{ matrix.image }} steps: - uses: actions/checkout@v4 - run: dotnet --info - run: dotnet restore Contoso.sln --locked-mode - run: dotnet test Contoso.sln --configuration Release ``` At $0.016 per minute per leg, a weekly three-way compatibility matrix of 10-minute jobs costs $0.48 per week. ### [MSBuild and Visual Studio solutions](#msbuild-and-visual-studio-solutions)Copy Anchor Link Solutions that build through MSBuild rather than the .NET SDK need the Visual Studio developer environment on `PATH`. The setup action resolves the installed Visual Studio instance on the image, which is why the same workflow file works on the Visual Studio 2022 images and the Visual Studio 2026 image without a version pin in the workflow: Copy Text ``` jobs: msbuild: runs-on: warp-windows-2025-vs2026-x64-16x steps: - uses: actions/checkout@v4 - uses: microsoft/setup-msbuild@v2 - uses: nuget/setup-nuget@v2 - name: Cache NuGet packages uses: actions/cache@v4 with: path: ~\.nuget\packages key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} - run: nuget restore Contoso.sln - run: msbuild Contoso.sln /p:Configuration=Release /m /verbosity:minimal - run: msbuild Contoso.sln /t:Pack /p:Configuration=Release ``` `/m` lets MSBuild build independent projects in parallel and is what makes the 16 vCPU and 32 vCPU sizes worth their rate. Without it MSBuild builds one project at a time and a larger runner mostly buys idle cores. ### [NuGet caching on Windows](#nuget-caching-on-windows)Copy Anchor Link WarpBuild caches are not supported for Windows-based runners, so Windows jobs restore packages through `actions/cache` exactly as they would on a GitHub-hosted Windows runner. Three details decide whether the cache actually hits: **Cache the global packages folder rather than the project folders.** The path is `~\.nuget\packages` on Windows. Setting `NUGET_PACKAGES` to a workspace path and caching that path instead also works and makes the location explicit: Copy Text ``` env: NUGET_PACKAGES: ${{ github.workspace }}\.nuget\packages ``` **Key on a lock file.** `hashFiles('**/packages.lock.json')` produces a key that changes only when the resolved dependency graph changes. Committing lock files and running `dotnet restore --locked-mode` also turns a silent dependency drift into a failed restore, which is the behavior you want in a pull request. **Restore before build, then build with `--no-restore`.** Letting `dotnet build` run its own implicit restore after a cache hit re-resolves packages and undoes part of what the cache saved. For test output, `--logger trx` writes result files that upload cleanly with `actions/upload-artifact`, and the 256GB volume gives large integration test runs room for result files and crash dumps without a disk pressure failure. ### [Job-level sizing](#job-level-sizing)Copy Anchor Link Sizes are per job, so a workflow can put the expensive compile on a large runner and keep the cheap jobs small. This is usually a bigger lever on the bill than picking one size for the whole repository: Copy Text ``` jobs: lint: runs-on: warp-windows-latest-x64-4x steps: - uses: actions/checkout@v4 - run: dotnet format --verify-no-changes build-and-test: runs-on: warp-windows-latest-x64-16x steps: - uses: actions/checkout@v4 - run: dotnet restore Contoso.sln --locked-mode - run: dotnet build Contoso.sln --configuration Release --no-restore /m - run: dotnet test Contoso.sln --configuration Release --no-build ``` ### [Shells and paths](#shells-and-paths)Copy Anchor Link Windows jobs default to PowerShell for `run` steps. Add `shell: bash` on a step when a script depends on the Git bash environment, and keep path separators consistent inside each step; mixing `\` and `/` in the same script is the usual cause of a step that passes locally and fails on the runner. ### [Managing runners programmatically](#managing-runners-programmatically)Copy Anchor Link For the full runner reference including the other platforms, see the [cloud runners documentation](/docs/ci/cloud-runners). For image tooling versions, see the [preinstalled software documentation](/docs/ci/preinstalled-software). ### [Where to go next](#where-to-go-next)Copy Anchor Link * [Cut wall-clock time on Windows GitHub Actions jobs](/guides/faster-windows-github-actions) covers the sizing, caching, and matrix changes that move Windows job duration. * [Run .NET builds and tests on GitHub Actions](/solutions/dotnet-github-actions) walks the full .NET workflow across platforms. * [Model the cost of GitHub Actions larger runners](/guides/github-actions-larger-runners-cost) generalizes the per-minute arithmetic above across every runner size. * [Diagnose GitHub Actions queue times](/guides/github-actions-queue-times) covers what to check when jobs sit queued rather than running slowly. ## [FAQ](#faq)Copy Anchor Link **What Windows runner sizes does WarpBuild offer?** Four sizes on x86-64: 4 vCPU with 16GB, 8 vCPU with 32GB, 16 vCPU with 64GB, and 32 vCPU with 128GB. Every size carries 256GB of SSD storage. The 2 vCPU Windows runners were removed on June 8, 2026, so 4 vCPU is the smallest available shape. **What does a Windows runner cost per minute?** $0.016 per minute at 4 vCPU, $0.032 at 8 vCPU, $0.064 at 16 vCPU, and $0.128 at 32 vCPU. Billing is per minute of runner time with no base subscription fee, no platform fee, and no seat fee, and signup includes $10 free credits. **Which Windows image should I pick?** `warp-windows-latest-x64-` resolves to Windows Server 2022 and is the safe default for an existing workflow. Use `warp-windows-2025-x64-` for Windows Server 2025 with Visual Studio 2022, and `warp-windows-2025-vs2026-x64-` when the build needs Visual Studio 2026. **Does the WarpBuild cache work on Windows runners?** No. WarpBuild caches are not supported for Windows-based runners. Use `actions/cache` in the workflow for NuGet packages, MSBuild output, and other Windows build state. --- # Visual Studio Images on Windows GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/runners/windows/visual-studio-images Description: Visual Studio 2026 runs on the warp-windows-2025-vs2026-x64 labels. The Windows Server 2022 and 2025 labels carry Visual Studio 2022. Labels, rates, config. # Visual Studio Images on Windows GitHub Actions Runners Visual Studio 2026 runs on the warp-windows-2025-vs2026-x64 labels. The Windows Server 2022 and 2025 labels carry Visual Studio 2022\. Labels, rates, config. Last verified: 2026-08-13 Visual Studio 2026 runs on WarpBuild Windows GitHub Actions runners through the `warp-windows-2025-vs2026-x64-` labels, which boot the Windows Server 2025 base image with Visual Studio 2026 installed in place of Visual Studio 2022\. Every other Windows label, `warp-windows-latest-x64-` on Windows Server 2022 and `warp-windows-2025-x64-` on Windows Server 2025, still carries Visual Studio 2022, so the toolset a job compiles with is chosen by the string in `runs-on`. This page maps each Windows label to its Windows Server generation and its Visual Studio generation, prices every size against GitHub's published Windows larger runner list prices, and shows the workflow configuration for pinning an image and for moving a job off Windows Server 2022 without changing two variables at once. Catalog rows and rates were checked on 2026-08-13. ## [Catalog](#catalog)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Windows fleet is x86-64 and splits into three image families over one set of four machine shapes, so a single label selects a Windows Server generation, a Visual Studio generation, and a size. | Runner label | Windows image | Visual Studio | vCPU | Memory | Storage | Alias | | -------------------------------- | ------------------- | ------------- | ---- | ------ | --------- | ------------------------- | | warp-windows-latest-x64-4x | Windows Server 2022 | 2022 | 4 | 16GB | 256GB SSD | warp-windows-2022-x64-4x | | warp-windows-latest-x64-8x | Windows Server 2022 | 2022 | 8 | 32GB | 256GB SSD | warp-windows-2022-x64-8x | | warp-windows-latest-x64-16x | Windows Server 2022 | 2022 | 16 | 64GB | 256GB SSD | warp-windows-2022-x64-16x | | warp-windows-latest-x64-32x | Windows Server 2022 | 2022 | 32 | 128GB | 256GB SSD | warp-windows-2022-x64-32x | | warp-windows-2025-x64-4x | Windows Server 2025 | 2022 | 4 | 16GB | 256GB SSD | none | | warp-windows-2025-x64-8x | Windows Server 2025 | 2022 | 8 | 32GB | 256GB SSD | none | | warp-windows-2025-x64-16x | Windows Server 2025 | 2022 | 16 | 64GB | 256GB SSD | none | | warp-windows-2025-x64-32x | Windows Server 2025 | 2022 | 32 | 128GB | 256GB SSD | none | | warp-windows-2025-vs2026-x64-4x | Windows Server 2025 | 2026 | 4 | 16GB | 256GB SSD | none | | warp-windows-2025-vs2026-x64-8x | Windows Server 2025 | 2026 | 8 | 32GB | 256GB SSD | none | | warp-windows-2025-vs2026-x64-16x | Windows Server 2025 | 2026 | 16 | 64GB | 256GB SSD | none | | warp-windows-2025-vs2026-x64-32x | Windows Server 2025 | 2026 | 32 | 128GB | 256GB SSD | none | Source: [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. ### [How the label encodes the image](#how-the-label-encodes-the-image)Copy Anchor Link A Windows label has four parts: the `warp-windows` prefix, an image selector, the `x64` architecture, and a size multiplier. The image selector is the only part that decides which Visual Studio generation is installed. `latest` resolves to Windows Server 2022, `2025` selects Windows Server 2025 with Visual Studio 2022, and `2025-vs2026` selects Windows Server 2025 with Visual Studio 2026. The size multiplier changes hardware only. A `4x` runner and a `32x` runner built from the same image carry an identical toolset, so a compatibility problem that appears when you resize a job points at memory, disk, or build parallelism instead of at a different Visual Studio installation. ### [The Visual Studio 2022 images](#the-visual-studio-2022-images)Copy Anchor Link Two labels give you Visual Studio 2022 on different Windows Server generations. `warp-windows-latest-x64-` runs Windows Server 2022 and is the closest match to the GitHub-hosted `windows-2022` image. `warp-windows-2025-x64-` runs Windows Server 2025 with the same Visual Studio generation, which is what makes it a useful intermediate step: the operating system moves while the compiler, the MSVC runtime, and the MSBuild version stay put. ### [The Visual Studio 2026 image and its transitional caveat](#the-visual-studio-2026-image-and-its-transitional-caveat)Copy Anchor Link `warp-windows-2025-vs2026-x64-` uses the Windows Server 2025 base image with Visual Studio 2026 installed instead of Visual Studio 2022\. The documentation is explicit that these labels are transitional: they include Visual Studio 2026 while the `warp-windows-2025-x64-` labels still include Visual Studio 2022, and Visual Studio 2026 may become the default for the Windows Server 2025 runners in a later update. That reflects GitHub's own rollout process for the [Windows Server 2025 with Visual Studio 2026 image](https://github.com/actions/runner-images/issues/14016). Two consequences follow for a workflow author. A job pinned to `warp-windows-2025-x64-16x` today compiles with Visual Studio 2022 and could compile with Visual Studio 2026 after that default switch lands. A job pinned to `warp-windows-2025-vs2026-x64-16x` gets Visual Studio 2026 now and keeps getting it after the switch, because the selector names the toolset directly. Decide which side of the switch each lane belongs on before it happens rather than after a red build tells you. ### [Image versions and update cadence](#image-versions-and-update-cadence)Copy Anchor Link Windows images are rebuilt from the upstream GitHub runner image releases and every rebuild is recorded in the [WarpBuild changelog](/docs/ci/changelog). The August 12, 2026 entry lists `win22/20260809.268` for Windows Server 2022, `win25/20260809.223` for Windows Server 2025, and `win25-vs2026/20260810.198` for the Windows Server 2025 with Visual Studio 2026 image. Those version strings are the unit to quote in a bug report. A build that worked last week and fails today on the same label usually moved across one of those rebuilds, and naming the version string turns the question from "Windows is broken" into a diff between two published image manifests. ### [Shapes, storage, and removed sizes](#shapes-storage-and-removed-sizes)Copy Anchor Link Memory scales at 4GB per vCPU across the whole Windows range, and storage is fixed at 256GB of SSD on every size rather than growing with the shape. That storage figure matters on Visual Studio work because a full solution build plus test result files, PDBs, and an installer payload all land on the same volume. Runner storage is ephemeral and is deleted when the runner terminates. The 2 vCPU Windows runners were removed on June 8, 2026, so 4 vCPU is the smallest available Windows shape and any workflow still naming a `2x` Windows label needs to move up. ### [Concurrency](#concurrency)Copy Anchor Link Generally available Windows runners do not have plan-level concurrency caps, and capacity adjusts dynamically. That makes a three-image validation matrix practical to run on a schedule without a plan limit serializing its legs. ## [Pricing](#pricing)Copy Anchor Link The per-minute rate on a Windows runner depends only on the machine shape. A Visual Studio 2026 runner costs the same as the Windows Server 2022 runner of the same size, so adopting the newer toolset is a scheduling decision rather than a budget one. | Size | vCPU | Memory | Visual Studio 2026 label | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | Lower list price | Minutes on $10 credits | | ---- | ---- | ------ | -------------------------------- | -------------------- | ---------------------------------------------- | ----------------- | ---------------- | ---------------------- | | 4x | 4 | 16GB | warp-windows-2025-vs2026-x64-4x | $0.016 | 4-core Windows larger runner (4 vCPU, 16GB) | $0.022 | 27 percent | 625 | | 8x | 8 | 32GB | warp-windows-2025-vs2026-x64-8x | $0.032 | 8-core Windows larger runner (8 vCPU, 32GB) | $0.042 | 24 percent | 312 | | 16x | 16 | 64GB | warp-windows-2025-vs2026-x64-16x | $0.064 | 16-core Windows larger runner (16 vCPU, 64GB) | $0.082 | 22 percent | 156 | | 32x | 32 | 128GB | warp-windows-2025-vs2026-x64-32x | $0.128 | 32-core Windows larger runner (32 vCPU, 128GB) | $0.162 | 21 percent | 78 | GitHub list prices for the Windows larger runner SKUs come from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), and the shapes from the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), both checked on 2026-08-13\. Each row pairs a WarpBuild size with the GitHub shape that has the same vCPU count and the same memory, so the comparison is rate against rate at equal hardware. The same rates apply to the `warp-windows-latest-x64-` and `warp-windows-2025-x64-` labels and are listed for every platform on the [WarpBuild pricing page](/pricing). ### [What the rate covers](#what-the-rate-covers)Copy Anchor Link Pricing is purely usage based. There is no base subscription fee, no platform fee, and no seat fee, so a Visual Studio 2026 pilot that only a few engineers touch adds nothing to the bill beyond the minutes it runs. Signup includes $10 free credits, which covers 312 minutes on the 8 vCPU Windows shape at $0.032 per minute before any card is charged. SSO is available for a flat $250 per month, whatever the user count. ### [Worked model: a Visual Studio 2026 shadow lane](#worked-model-a-visual-studio-2026-shadow-lane)Copy Anchor Link The usual way to adopt a new toolset without risking the merge queue is a shadow job: the production lane stays on the current image and a second job compiles the same commit on the candidate image with `continue-on-error` set. Take 25 pull request events per working day, one shadow job each, 12 minutes per job on the 8 vCPU shape: * Minutes per day: 25 jobs x 12 minutes = 300 minutes * Minutes per month at 21 working days: 300 x 21 = 6,300 minutes * On `warp-windows-2025-vs2026-x64-8x` at $0.032 per minute: 6,300 x $0.032 = $201.60 * On the 8-core GitHub-hosted Windows larger runner at $0.042 per minute: 6,300 x $0.042 = $264.60 * Monthly difference at identical shape and identical minutes: $63.00 Substitute your own pull request volume and job duration; the structure holds. The shadow lane is temporary by design, so the number that matters is the cost of carrying it for the weeks between the first green run and the cutover. ### [Worked model: a nightly three-image matrix](#worked-model-a-nightly-three-image-matrix)Copy Anchor Link A cheaper alternative to a per-pull-request shadow job is one nightly matrix across all three images. At 18 minutes per leg on the 16 vCPU shape: * Per night: 3 legs x 18 minutes = 54 minutes, and 54 x $0.064 = $3.46 * Per 30-day month: $103.68 That buys a daily signal on Windows Server 2022, Windows Server 2025 with Visual Studio 2022, and Windows Server 2025 with Visual Studio 2026 for about the price of one engineer-hour, and it catches an image rebuild that breaks a build within a day of the rebuild shipping. ### [Windows runners in your own cloud](#windows-runners-in-your-own-cloud)Copy Anchor Link BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS, so a Windows pool can be declared in the same configuration as the rest of the account. Compute is billed by your cloud account and the WarpBuild fee is charged per runner minute on top. ## [Configuration](#configuration)Copy Anchor Link Selecting a Visual Studio generation is a one-line change in `runs-on`. This job compiles a solution with Visual Studio 2026 on a 16 vCPU Windows Server 2025 runner: Copy Text ``` name: windows-build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-windows-2025-vs2026-x64-16x steps: - uses: actions/checkout@v4 - uses: microsoft/setup-msbuild@v2 - uses: nuget/setup-nuget@v2 - name: Record the Visual Studio toolset shell: pwsh run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" & $vswhere -latest -property catalog_productDisplayVersion & $vswhere -latest -property installationPath - name: Cache NuGet packages uses: actions/cache@v4 with: path: ~\.nuget\packages key: nuget-${{ runner.os }}-${{ hashFiles('**/packages.lock.json') }} restore-keys: | nuget-${{ runner.os }}- - run: nuget restore Contoso.sln - run: msbuild Contoso.sln /p:Configuration=Release /m /verbosity:minimal ``` The `vswhere` step is worth keeping permanently. It writes the installed Visual Studio version into the job log, so when the transitional labels eventually change what `warp-windows-2025-x64-` means, every archived run already records which toolset produced its artifacts. ### [Pin the image label rather than the alias](#pin-the-image-label-rather-than-the-alias)Copy Anchor Link `warp-windows-latest-x64-` and its `warp-windows-2022-x64-` alias point at the same machines today. The dated form is the safer one to commit, because `latest` moves when the default image generation moves and the dated label does not. The same reasoning applies one level up for Visual Studio. A lane that must stay on Visual Studio 2022 should name `warp-windows-2025-x64-` only while that label carries Visual Studio 2022, and should move to `warp-windows-latest-x64-` once the default switch is announced in the changelog. A lane that must have Visual Studio 2026 should name the `vs2026` label, which is stable through the switch. ### [Moving a job from Windows Server 2022 to Windows Server 2025](#moving-a-job-from-windows-server-2022-to-windows-server-2025)Copy Anchor Link Change one variable per step. Move to `warp-windows-2025-x64-` first, which upgrades the operating system while holding Visual Studio at 2022\. When that lane has been green for a week, move to `warp-windows-2025-vs2026-x64-` to change the toolset. A failure after the first step points at the operating system; a failure after the second points at the compiler or the SDK. A scheduled matrix runs both candidate steps beside the current one, which gives you the evidence for both moves at once: Copy Text ``` name: windows-image-compat on: schedule: - cron: "0 6 * * *" workflow_dispatch: jobs: compat: strategy: fail-fast: false matrix: image: - warp-windows-latest-x64-16x - warp-windows-2025-x64-16x - warp-windows-2025-vs2026-x64-16x runs-on: ${{ matrix.image }} steps: - uses: actions/checkout@v4 - uses: microsoft/setup-msbuild@v2 - run: msbuild Contoso.sln /p:Configuration=Release /m - run: dotnet test Contoso.sln --configuration Release ``` `fail-fast: false` keeps the other legs running when one image fails, which is the point of the matrix. Set `/m` on every MSBuild invocation, because without it MSBuild builds one project at a time and a 16 vCPU or 32 vCPU runner mostly buys idle cores. Rolling back is one line in `runs-on`, and because the images are separate rather than versioned in place, the old lane is still there to roll back to. ### [Caching on Windows](#caching-on-windows)Copy Anchor Link WarpBuild caches are not supported for Windows-based runners, so Windows jobs restore packages through `actions/cache` exactly as they would on a GitHub-hosted Windows runner. Key the NuGet cache on a committed lock file with `hashFiles('**/packages.lock.json')` so the key changes only when the resolved dependency graph changes, and cache the global packages folder at `~\.nuget\packages` rather than per-project folders. [MSBuild caching on GitHub Actions Windows runners](/guides/msbuild-caching-github-actions) goes through the rest of the Windows caching surface. ### [Watching the switch](#watching-the-switch)Copy Anchor Link Two tools help here. CI observability gives you per-job duration across the image labels, so the nightly matrix produces a trend rather than a pass or fail. The Action Debugger opens a session on the runner for the failure that only reproduces on one image. ### [Where to go next](#where-to-go-next)Copy Anchor Link * [Windows runners for GitHub Actions, sizes and pricing](/runners/windows) covers the full Windows fleet, its shapes, and its rates. * [MSBuild caching on GitHub Actions Windows runners](/guides/msbuild-caching-github-actions) covers NuGet and build output caching in detail. * [Run .NET builds and tests on GitHub Actions](/solutions/dotnet-github-actions) walks the full .NET workflow across platforms. * [Build Unity projects on GitHub Actions](/solutions/unity-github-actions) covers the Windows editor and build toolchain. * The [cloud runners documentation](/docs/ci/cloud-runners) is the source of record for every label above. ## [FAQ](#faq)Copy Anchor Link **Which WarpBuild runner label gives me Visual Studio 2026?** The `warp-windows-2025-vs2026-x64-` labels, in sizes `4x`, `8x`, `16x`, and `32x`. They run the Windows Server 2025 base image with Visual Studio 2026 installed in place of Visual Studio 2022\. Every other Windows label carries Visual Studio 2022. **Are the vs2026 labels permanent?** They are transitional. The documentation states that Visual Studio 2026 may become the default for the Windows Server 2025 runners in a later update, which follows GitHub's own rollout of the Windows Server 2025 with Visual Studio 2026 image. Pin the label you want and watch the changelog for the default switch. **Does a Visual Studio 2026 runner cost more than a Visual Studio 2022 runner?** No. The rate depends only on the machine shape: $0.016 per minute at 4 vCPU, $0.032 at 8 vCPU, $0.064 at 16 vCPU, and $0.128 at 32 vCPU, across all three Windows image families. **How do I move a job from Windows Server 2022 to Windows Server 2025 safely?** Move to `warp-windows-2025-x64-` first, which changes the operating system while keeping Visual Studio 2022\. Once that lane is green, switch to `warp-windows-2025-vs2026-x64-` to change the toolset. Two moves keep one variable in play at a time. --- # WarpBuild for Software Engineering Daily Listeners | $50 in Free Credits | WarpBuild URL: https://www.warpbuild.com/sed Description: Heard us on Software Engineering Daily? Make GitHub Actions 2x faster at half the cost with a one-line change. Redeem code 2026SEDPNL for $50 in free credits. //Software Engineering Daily Listeners # GitHub Actions, 2x faster at half the cost. Thanks for listening to Software Engineering Daily. Switch with a one-line change to your workflow - Linux, macOS, and Windows runners in WarpBuild's cloud or your own. $50 in free credits for SE Daily listeners2026SEDPNLRedeem under [Settings → Billing](https://app.warpbuild.com/settings/billing) after signing up [Claim $50 in Credits](https://app.warpbuild.com)[Schedule Demo](https://cal.com/suryao/start) No credit card required · 2-minute setup //CI in the AI era ## Your GitHub Actions bill is now a function of how much AI code you generate AI is writing more code than ever, which means GitHub Actions is running more than ever. WarpBuild keeps the feedback loop fast and the bill flat. ### AI writes the code. CI ships it. Every AI-generated PR still has to build, test, and merge through the same GitHub Actions pipeline. More generated code means more runs, longer queues, and a bigger bill. ### Faster loops for humans and agents You push a commit, and then you wait. So do your coding agents - every iteration is gated on CI feedback. Cutting build times in half compounds across every loop. ### Half the cost per minute WarpBuild runners are priced at half of GitHub-hosted runners and finish jobs faster, so you save on both rate and runtime as your AI output scales. //Getting started ## One line of YAML. $50 in credits. Three steps from your first listen to your first faster build. ``` - runs-on: ubuntu-latest+ runs-on: warp-ubuntu-latest-x64-2x ``` 1 ### Create your account Sign up at [app.warpbuild.com](https://app.warpbuild.com). No credit card required. 2 ### Redeem your credits Add promo code 2026SEDPNL under [Settings → Billing](https://app.warpbuild.com/settings/billing) for $50 in free credits. 3 ### Change one line of YAML Swap runs-on in your workflow file and your next build runs on WarpBuild. //WarpBuild CI ## Everything you heard on the show Drop-in GitHub Actions runner infrastructure: 2-10x faster builds, half the cost, faster caching, and BYOC for teams that want CI in their own cloud. [2x faster, 50% cheaperFaster CPUs, faster disks, and 3-10x faster caching than GitHub-hosted runners.](/compare/github)[One line of YAMLChange runs-on and you're done. No workflow rewrites, no migration project.](/docs/ci/quick-start)[Linux, macOS, and Windowsx86-64 and ARM64 runners across all major operating systems, including M4 Pro Macs.](/docs/ci/cloud-runners)[Our cloud or yoursManaged runners in WarpBuild's cloud, or BYOC deployments in your own AWS, GCP, or Azure.](/docs/ci/byoc)[Unlimited concurrencyMatrix strategies actually parallelize. No queuing behind your own jobs.](/docs/ci/cloud-runners#concurrency)[Enterprise readySOC2 Type 2 attested, ephemeral VMs, SSO, and 24x7 support with SLA guarantees.](/docs/ci/security) //Trusted at scale ## Trusted by teams like Sky from Comcast, Bitcoin, and Braintrust Enterprise ready and SOC2 Type 2 attested, from solo open-source projects to thousand-developer organizations. ![Sonar](/images/customers/sonarqube.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Comcast](/images/customers/comcast.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Sky](/_next/image?url=%2Fimages%2Fcustomers%2Fsky-uk.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Braintrust](/images/customers/braintrust.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Adaptive](/_next/image?url=%2Fimages%2Fcustomers%2Fadaptive.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![OxCaml](/images/customers/oxcaml.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![DDEV](/images/customers/ddev.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Kintsugi](/images/customers/kintsugi-ai.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Bitcoin](/images/customers/bitcoin.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![LanceDB](/_next/image?url=%2Fimages%2Fcustomers%2Flancedb.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) Run times went down from \~25 mins to \~9 mins. Even bazel cache load time was faster by 30% Wow. These macOS runners are a LOT faster than GitHub's. Previous builds would take 30 mins, now they're done in 8! Tried out pretty much every provider in the market, and this one works the best out of the box //FAQ ## Frequently asked questions How do I redeem the $50 in free credits? Sign up at then add promo code 2026SEDPNL on the billing settings page at The credits are applied to your account immediately. Do I need a credit card to get started? No. Sign up, redeem your credits, and run your first builds without entering a card. Will my existing GitHub Actions workflows work? Yes. WarpBuild runners are fully compatible with GitHub Actions and come with the standard tooling preinstalled. The only change is the runs-on label in your workflow file. Can we run WarpBuild in our own cloud? Yes. Bring Your Own Cloud deployments run WarpBuild runners inside your own AWS, GCP, or Azure account, with static IPs and custom machine images. See for details. Is WarpBuild ready for enterprise use? Yes. WarpBuild is SOC2 Type 2 attested with ephemeral, fully isolated VMs, SSO and access controls, and 24x7 support backed by SLAs. Teams like Sky from Comcast, Bitcoin, and Braintrust run their CI on WarpBuild. ## Stop waiting on CI. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Claim $50 in Credits](https://app.warpbuild.com) --- # WarpBuild for Simon Willison Readers | $50 in Free Credits | WarpBuild URL: https://www.warpbuild.com/simonw Description: More AI code means more commits and more CI. Run GitHub Actions 2x faster at half the cost with a one-line change. Redeem code simonw50 for $50 in free credits. //For Simon Willison Readers # More AI code. More commits. More CI. Run GitHub Actions 2x faster at half the cost with a one-line change. Linux, macOS, and Windows on x86-64 and ARM64, with unlimited macOS concurrency. $50 in free credits for Simon Willison Readerssimonw50Redeem under [Settings > Billing](https://app.warpbuild.com/settings/billing) after signing up [Claim $50 in Credits](https://app.warpbuild.com)[Schedule Demo](https://cal.com/suryao/start) No credit card required | 2-minute setup //CI in the AI era ## AI agents write the code. CI is now the bottleneck. AI is writing more code than ever, which means GitHub Actions is running more than ever. WarpBuild keeps the feedback loop fast and the bill low. ### More AI code, more commits Agents open pull requests all day. Every one has to build, test, and merge through the same GitHub Actions pipeline. ### More commits, more CI Every commit runs a full pipeline. CI is now the bottleneck for you and for your coding agents, and the bill grows with it. ### Twice as fast at half the cost WarpBuild runners are priced at half of GitHub-hosted runners and finish jobs faster, so you save on both rate and runtime. //Getting started ## One line of YAML. $50 in credits. Three steps from this page to your first faster build. ``` - runs-on: ubuntu-latest+ runs-on: warp-ubuntu-latest-x64-2x ``` 1 ### Create your account Sign up at [app.warpbuild.com](https://app.warpbuild.com). No credit card required. 2 ### Redeem your credits Add promo code simonw50 under [Settings > Billing](https://app.warpbuild.com/settings/billing) for $50 in free credits. 3 ### Change one line of YAML Swap runs-on in your workflow file and your next build runs on WarpBuild. //WarpBuild CI ## The broadest OS and architecture support Drop-in GitHub Actions runner infrastructure: 2-10x faster builds, half the cost, faster caching, and BYOC for teams that want CI in their own cloud. [2x faster, 50% cheaperFaster CPUs, faster disks, and 3-10x faster caching than GitHub-hosted runners.](/compare/github)[One line of YAMLChange runs-on and you're done. No workflow rewrites, no migration project.](/docs/ci/quick-start)[Linux, macOS, and Windowsx86-64 and ARM64 runners across all major operating systems, including M4 Pro Macs.](/docs/ci/cloud-runners)[macOS with unlimited concurrencyRun as many Mac jobs at once as your agents create. No queuing behind your own jobs.](/products/ci/macos-runners)[Our cloud or yoursManaged runners in WarpBuild's cloud, or BYOC deployments in your own AWS, GCP, or Azure.](/docs/ci/byoc)[Enterprise readySOC2 Type 2 attested, ephemeral VMs, SSO, and 24x7 support with SLA guarantees.](/docs/ci/security) //Trusted at scale ## Trusted by teams at Braintrust, Raycast, OpenRouter, Comcast, Jane Street, and more Enterprise ready and SOC2 Type 2 attested, from solo open-source projects to thousand-developer organizations. ![Sonar](/images/customers/sonarqube.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Comcast](/images/customers/comcast.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Sky](/_next/image?url=%2Fimages%2Fcustomers%2Fsky-uk.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Braintrust](/images/customers/braintrust.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Adaptive](/_next/image?url=%2Fimages%2Fcustomers%2Fadaptive.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![OxCaml](/images/customers/oxcaml.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![DDEV](/images/customers/ddev.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Kintsugi](/images/customers/kintsugi-ai.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Bitcoin](/images/customers/bitcoin.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![LanceDB](/_next/image?url=%2Fimages%2Fcustomers%2Flancedb.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) Run times went down from \~25 mins to \~9 mins. Even bazel cache load time was faster by 30% Wow. These macOS runners are a LOT faster than GitHub's. Previous builds would take 30 mins, now they're done in 8! Tried out pretty much every provider in the market, and this one works the best out of the box //FAQ ## Frequently asked questions How do I redeem the $50 in free credits? Sign up at then add promo code simonw50 on the billing settings page at The credits are applied to your account immediately. Do I need a credit card to get started? No. Sign up, redeem your credits, and run your first builds without entering a card. Will my existing GitHub Actions workflows work? Yes. WarpBuild runners are fully compatible with GitHub Actions and come with the standard tooling preinstalled. The only change is the runs-on label in your workflow file. Which operating systems and architectures are supported? Linux, macOS, and Windows, on x86-64 and ARM64\. macOS runners have unlimited concurrency, so Mac jobs do not queue behind each other. Can we run WarpBuild in our own cloud? Yes. Bring Your Own Cloud deployments run WarpBuild runners inside your own AWS, GCP, or Azure account, with static IPs and custom machine images. See for details. Is WarpBuild ready for enterprise use? Yes. WarpBuild is SOC2 Type 2 attested with ephemeral, fully isolated VMs, SSO and access controls, and 24x7 support backed by SLAs. Teams at Braintrust, Raycast, OpenRouter, Comcast, Jane Street, and more run their CI on WarpBuild. ## Stop waiting on CI. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Claim $50 in Credits](https://app.warpbuild.com) --- # GitHub Actions Solutions | WarpBuild URL: https://www.warpbuild.com/solutions Description: Language, framework, and tool guides for GitHub Actions: sizing, caching, and configuration per stack. # GitHub Actions Solutions Build and test guides per language, framework, and tool on GitHub Actions. * [Running AI Coding Agents on GitHub ActionsAI coding agents run on GitHub Actions like any other job. WarpBuild snapshot runners boot an Ubuntu job from a prepared disk so agents skip toolchain setup.](/solutions/ai-agents-github-actions) * [Android Emulator Tests on GitHub ActionsAndroid emulator tests need /dev/kvm. Append nested-virtualization.enabled=true to a Linux x86-64 warp- label, then add the documented KVM udev step.](/solutions/android-emulator-tests-github-actions) * [Speeding Up Android Builds on GitHub ActionsRun Android emulator jobs on WarpBuild x86-64 runners by appending nested-virtualization.enabled=true to a warp- label and adding a KVM permissions step.](/solutions/android-github-actions) * [Angular Builds on GitHub ActionsThe Angular CLI switches off its disk cache when CI is set. Turn it back on, restore .angular/cache beside the npm store, and size warp- runners per phase.](/solutions/angular-github-actions) * [GitOps Deployments with Argo CD and GitHub ActionsAn Argo CD release splits in two: the GitHub Actions workflow builds by digest and commits the manifest, and the in-cluster controller applies it.](/solutions/argocd-deployments-github-actions) * [AWS CDK Pipelines on GitHub ActionsRun cdk synth and cdk diff on pull requests and cdk deploy on merge with warp- runners: OIDC role assumption, a cached toolchain, serialized asset publishing.](/solutions/aws-cdk-github-actions) * [Running Bazel on GitHub Actions with Remote CacheSet --remote\_cache in a checked-in bazelrc, pin the repository cache and output base on the runner, and keep both warm on WarpBuild snapshot runners.](/solutions/bazel-github-actions) * [Bun Builds and Tests on GitHub ActionsBun jobs on GitHub Actions stall on a cold install cache. Cache \~/.bun/install/cache with WarpBuilds/cache keyed on bun.lock and size warp- runners for tests.](/solutions/bun-github-actions) * [Conda Environments on GitHub ActionsConda jobs on GitHub Actions spend most of a cold run in the solver. Cache the environment keyed on environment.yml, then size the runner for the suite.](/solutions/conda-github-actions) * [C++ Builds on GitHub Actions: Caching and SizingFast C++ builds on GitHub Actions come from a persistent ccache, warp- runners sized for parallel compiles, and tamed link steps. Cost math inside.](/solutions/cpp-github-actions) * [Cypress Test Suites on GitHub ActionsCache the Cypress binary folder with the npm store on one key, split specs across a warp- runner matrix, and size headless Chrome jobs at 4 vCPU or more.](/solutions/cypress-github-actions) * [Database Migrations in GitHub ActionsRun database migrations in GitHub Actions as two jobs: a migration test against a fresh service container database, and a gated production apply job.](/solutions/database-migrations-github-actions) * [dbt Pipelines on GitHub ActionsStop rebuilding every dbt model on each pull request. Use state comparison, deferral, cached dbt packages, and small warp- Linux runners priced per minute.](/solutions/dbt-github-actions) * [Deno Projects on GitHub ActionsDeno jobs on GitHub Actions start with an empty DENO\_DIR. Pin it, restore it with WarpBuilds/cache keyed on deno.lock, and size warp- runners for deno test.](/solutions/deno-github-actions) * [Django Test Suites on GitHub ActionsDjango tests on GitHub Actions lose time to migration replay and 2 vCPU runners. Run manage.py test --parallel on a warp- label with a Postgres service.](/solutions/django-github-actions) * [Docker Builds on GitHub Actions: Remote BuildersDocker builds crawl on GitHub Actions because the layer cache dies with the runner. Move the build to a WarpBuild remote builder profile in one swap.](/solutions/docker-builds-github-actions) * [Docker Compose Integration Tests on GitHub ActionsRun Docker Compose integration tests on GitHub Actions with a separate image pull step, health-gated startup, and runner sizing from real per-minute rates.](/solutions/docker-compose-integration-tests-github-actions) * [.NET Builds on GitHub Actions.NET builds on GitHub Actions run on WarpBuild Windows runners from 4 to 32 vCPU at $0.016 to $0.128 per minute, with NuGet caching through actions/cache.](/solutions/dotnet-github-actions) * [Electron App Builds on GitHub ActionsBuild Electron installers on GitHub Actions with a three-platform matrix across WarpBuild Linux, Windows, and macOS runners, with per-platform caching.](/solutions/electron-github-actions) * [Elixir and Phoenix Builds on GitHub ActionsElixir builds on GitHub Actions stall on cold deps, \_build recompiles, and Dialyzer PLT rebuilds. Cache each separately on a warp- runner and size for mix test.](/solutions/elixir-github-actions) * [Embedded Firmware Builds on GitHub ActionsEmbedded firmware on GitHub Actions needs a cached cross toolchain, a board matrix emitting per-target artifacts, and runners sized per leg. YAML and rates.](/solutions/embedded-firmware-github-actions) * [Fastlane Lanes on GitHub ActionsRun fastlane lanes on GitHub Actions macOS runners with cached bundler and CocoaPods installs, ephemeral signing keychains, and warp- label sizing.](/solutions/fastlane-github-actions) * [Flutter Builds on GitHub ActionsSplit a Flutter pipeline across two runner labels: Android artifacts on Linux x64 at $0.016 per minute and iOS artifacts on macOS at $0.08 per minute.](/solutions/flutter-github-actions) * [GitHub Actions Observability and Runner MetricsGitHub Actions observability means per-job CPU, memory, disk, and queue metrics. WarpBuild collects them from an agent on every runner and exposes them by API.](/solutions/github-actions-observability) * [Go Builds on GitHub Actions: Sizing and CachingCache GOMODCACHE and GOCACHE with WarpBuilds/cache keyed on go.sum, then size warp- runners from 4 to 16 vCPU for go build and go test -race jobs.](/solutions/go-github-actions) * [Godot Game Builds on GitHub ActionsGodot exports on GitHub Actions run on WarpBuild Linux, macOS, and Windows runners with a cached export template directory and rates from $0.004 per minute.](/solutions/godot-github-actions) * [Gradle Build Caches on GitHub Actions RunnersGradle build caching on GitHub Actions: configure a remote build cache, restore the Gradle User Home, and size warp- runners so cached tasks stay warm.](/solutions/gradle-github-actions) * [Haskell Builds on GitHub ActionsHaskell builds on GitHub Actions need a cabal store cache keyed by GHC version, a warp- Linux label sized for parallel GHC, and tests split from the build.](/solutions/haskell-github-actions) * [Helm Chart Pipelines on GitHub ActionsA Helm chart pipeline on GitHub Actions lints, renders, and installs the chart into a kind cluster, then packages and pushes it to an OCI registry on a tag.](/solutions/helm-charts-github-actions) * [iOS Builds on GitHub Actions with macOS RunnersRun iOS builds on GitHub Actions macOS runners with xcodebuild, DerivedData and SPM caching, and code signing, sized across WarpBuild 6 and 12 vCPU labels.](/solutions/ios-github-actions) * [Faster Java Builds on GitHub ActionsFaster Java builds on GitHub Actions come from bigger warp- runners, a warm Maven repository cache, and parallel Surefire forks. Sizing and cost math inside.](/solutions/java-github-actions) * [Kotlin Multiplatform Builds on GitHub ActionsKotlin Multiplatform on GitHub Actions needs Linux and macOS runners. Workflow YAML, warp- labels, Gradle and konan caching, sizing, and per-minute rates.](/solutions/kotlin-multiplatform-github-actions) * [Kubernetes Deploy Jobs on GitHub ActionsKubernetes deployments on GitHub Actions split into a build job and a rollout job. Here are the warp- labels, sizes, per-minute rates and transfer costs.](/solutions/kubernetes-deployments-github-actions) * [Laravel Test Suites on GitHub ActionsLaravel suites on GitHub Actions stall on cold Composer installs, per-class migrations, and serial tests. Fix all three with warp- labels and sizing.](/solutions/laravel-github-actions) * [Maven Build Cache on GitHub ActionsCache Maven on GitHub Actions with WarpBuilds/setup-java, cache: maven and a pom.xml dependency path, then map mvn -T threads onto warp- runner sizes.](/solutions/maven-github-actions) * [Monorepo Pipelines on GitHub ActionsSplit monorepo GitHub Actions work into a change detection job and per-shape matrix shards on warp- labels sized from 2 to 16 vCPU, with warm snapshot runners.](/solutions/monorepo-github-actions) * [moon Monorepo Builds on GitHub Actionsmoon ci selects tasks by hash across changed projects. Cache .moon/cache and the proto toolchain, shard with --job, and size warp- runners per shard.](/solutions/moon-github-actions) * [Next.js Builds on GitHub ActionsRestore .next/cache beside the package manager store, key it on the lockfile plus a source hash, and size warp- runners from 4 to 16 vCPU for next build.](/solutions/nextjs-github-actions) * [Nix Builds on GitHub ActionsA Nix build on GitHub Actions is slow when the store starts empty. Add your own substituter, size the runner for eval, and boot warm stores from snapshots.](/solutions/nix-builds-github-actions) * [Node.js Builds on GitHub Actions Without Cold CachesCache the npm, pnpm, or yarn store with WarpBuilds/cache keyed on the lockfile hash, then size warp- runners from 4 to 16 vCPU for installs and tests.](/solutions/nodejs-github-actions) * [Nx Affected Builds on GitHub ActionsRun nx affected on GitHub Actions with correct base and head SHAs, restore .nx/cache with WarpBuilds/cache, and size warp- runners to the affected graph.](/solutions/nx-github-actions) * [Pants Builds on GitHub ActionsCache the Pants lmdb\_store and named\_caches with WarpBuilds/cache@v1 on a warp- Linux label, or boot a snapshot runner that already holds both warm on disk.](/solutions/pants-github-actions) * [Faster PHP Builds on GitHub ActionsPHP builds on GitHub Actions stall on cold Composer installs and 2 vCPU runners. Fix both with warp- runner labels, WarpBuilds/cache, and correct sizing.](/solutions/php-github-actions) * [Playwright Test Suites on GitHub ActionsShard Playwright across warp- runners on GitHub Actions, cache the browser binaries, merge the blob reports, and size each shard from real per-minute rates.](/solutions/playwright-github-actions) * [pnpm Cache on GitHub Actions: Store and WorkspacesCache the pnpm store with WarpBuilds/setup-node keyed on pnpm-lock.yaml, then map pnpm --filter onto a matrix of warp- runner sizes for each workspace package.](/solutions/pnpm-github-actions) * [Protobuf and gRPC Codegen on GitHub ActionsProtobuf codegen on GitHub Actions: cache buf modules and protoc plugin binaries, fail pull requests on generated-code drift, and size warp- runner labels.](/solutions/protobuf-grpc-builds-github-actions) * [Pulumi Pipelines on GitHub ActionsRun pulumi preview on pull requests and pulumi up on merge with warp- runners: OIDC token exchange, cached provider plugins, serialized stack applies.](/solutions/pulumi-github-actions) * [Faster Python Test Suites on GitHub ActionsPython tests run slow on GitHub Actions when pytest is starved for vCPUs and caches miss. Fix both with WarpBuild warp- runners, sizing, and cache config.](/solutions/python-github-actions) * [Qt Application Builds on GitHub ActionsBuild Qt 6 apps on GitHub Actions with a three-platform matrix, a cached Qt install and ccache per leg, and warp- runner sizes priced per minute.](/solutions/qt-github-actions) * [Rails Test Suites on GitHub ActionsRails suites on GitHub Actions lose minutes to cold gem installs, per-worker schema loads, and serial runs. Workflow YAML, worker sizing, and real rates.](/solutions/rails-github-actions) * [React Native Builds on GitHub ActionsRun React Native builds on GitHub Actions by splitting Android onto warp-ubuntu-latest-x64-8x and iOS onto warp-macos-15-arm64-6x, with per-platform caches.](/solutions/react-native-github-actions) * [Fast Ruby Builds on GitHub ActionsRuby builds slow down on GitHub Actions from cold gem caches and 2 vCPU runners. Fix both with warp- labels, bundler-cache, and parallel RSpec.](/solutions/ruby-github-actions) * [Fast Rust Builds on GitHub ActionsFast Rust builds on GitHub Actions come from bigger warp- runners, warm cargo and target caches, and less link work. Sizing tables and cost math inside.](/solutions/rust-github-actions) * [Scala and sbt Builds on GitHub ActionsScala and sbt builds on GitHub Actions speed up with larger warp- runners, a warm coursier cache, and snapshot runners that carry zinc incremental state.](/solutions/scala-github-actions) * [Selenium Test Suites on GitHub ActionsPin the browser and its driver as one version pair, cache what Selenium downloads, then run parallel sessions per shard across a warp- runner matrix.](/solutions/selenium-github-actions) * [Serverless Deployments from GitHub ActionsOne package job builds the deployment artifact and every environment ships those exact bytes. The warp- labels, the OIDC workflow, and the transfer math.](/solutions/serverless-deployments-github-actions) * [SonarQube Scans on GitHub ActionsSonarQube scans on GitHub Actions need full git history, a warm analyzer cache, and heap for the scanner JVM. Workflow YAML, runner sizing, and cost math.](/solutions/sonarqube-scans-github-actions) * [Spring Boot Builds on GitHub ActionsBuild and test Spring Boot on GitHub Actions with a warm Maven cache, reused container images, and warp- runners sized to heap per fork. Rates and YAML inside.](/solutions/spring-boot-github-actions) * [Storybook Builds and Visual Tests on GitHub ActionsBuild a static Storybook on GitHub Actions, cache node\_modules/.cache and the browser binaries, run image snapshots, and size warp- runners by memory.](/solutions/storybook-github-actions) * [Svelte Builds on GitHub ActionsCache the package manager store, node\_modules/.vite, and .svelte-kit so svelte-check and vite build share one restore, then size warp- runners per phase.](/solutions/svelte-github-actions) * [Swift Package Builds on GitHub ActionsRun swift build and swift test on GitHub Actions with WarpBuild macOS runners. Workflow YAML, 6x vs 12x sizing, SwiftPM cache keys, and per-minute costs.](/solutions/swift-github-actions) * [Tauri Desktop Builds on GitHub ActionsBuild Tauri v2 installers on GitHub Actions with a three-platform matrix, cached Rust and front-end state, and warp- runner sizes with per-minute rates.](/solutions/tauri-github-actions) * [Terraform Pipelines on GitHub ActionsRun terraform plan and apply as GitHub Actions jobs on warp- runners: cache the provider plugin directory, size 2x or 4x labels, reach private state backends.](/solutions/terraform-github-actions) * [Turborepo on GitHub Actions: Cache ConfigurationTurborepo caches task outputs by hash. Configure the remote cache, persist .turbo with WarpBuilds/cache, and size warp- runners for turbo run concurrency.](/solutions/turborepo-github-actions) * [Unity Builds on GitHub ActionsRun Unity builds on GitHub Actions with WarpBuild Windows runners from 4 to 32 vCPU, 256GB SSDs, $0.016 to $0.128 per minute, and a cached Library folder.](/solutions/unity-github-actions) * [Unreal Engine Builds on GitHub ActionsUnreal Engine builds on GitHub Actions run on WarpBuild Windows runners at 16 and 32 vCPU with 256GB SSDs, priced at $0.064 and $0.128 per minute.](/solutions/unreal-engine-github-actions) * [Vue Builds on GitHub ActionsCache the package manager store beside the Vite cache directory, run type checks and component tests as separate steps, and size warp- runners for vite build.](/solutions/vue-github-actions) * [WebAssembly Builds on GitHub ActionsWebAssembly builds on GitHub Actions need the wasm target installed, a cached target directory, and a runtime for tests. Workflow YAML and runner rates inside.](/solutions/webassembly-github-actions) * [Yocto and Embedded Builds on GitHub ActionsYocto builds on GitHub Actions need 32 vCPUs plus a persistent sstate-cache and downloads directory. Snapshot runners carry both between runs on warp- labels.](/solutions/yocto-github-actions) * [Zig Builds on GitHub ActionsZig builds on GitHub Actions lose minutes to cold compiler and package caches. Cross-target matrix YAML on warp- labels, cache keys, sizing, and rates.](/solutions/zig-github-actions) --- # Running AI Coding Agents on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/ai-agents-github-actions Description: AI coding agents run on GitHub Actions like any other job. WarpBuild snapshot runners boot an Ubuntu job from a prepared disk so agents skip toolchain setup. # Running AI Coding Agents on GitHub Actions AI coding agents run on GitHub Actions like any other job. WarpBuild snapshot runners boot an Ubuntu job from a prepared disk so agents skip toolchain setup. Last verified: 2026-08-13 ## [Overview](#overview)Copy Anchor Link An AI coding agent runs on GitHub Actions the same way a human-triggered build does: a trigger starts a workflow, the job lands on a runner, and the exit code decides whether the change survives. What breaks under agent traffic is the environment, because an agent retries far more often than a person does, and every attempt on a stock runner reinstalls the same toolchain, refetches the same dependencies, and reseeds the same fixtures before it does one second of useful work. Agent traffic on GitHub Actions arrives in three shapes, and they stress different parts of the setup. **The agent runs inside the job.** A `workflow_dispatch`, an `issue_comment`, or a label event starts a workflow whose steps invoke an agent CLI on the runner. The agent edits files, runs the test command, reads the failures, and edits again, all inside one job. Environment setup happens once per job, and the agent loop happens inside it. **The agent runs elsewhere and drives workflows.** The agent lives in a hosted session or on a developer machine and dispatches workflows through the API, then polls for the result. Each attempt is a fresh job, so environment setup happens on every attempt. **The agent opens pull requests that go through normal checks.** The agent produces a branch and the existing pull request workflows grade it. Volume goes up because a machine opens more pull requests than a team does, and each one runs the full check matrix. The second and third shapes are where per-job setup time turns into the dominant cost. A job that spends 15 minutes installing packages, restoring fixtures, and pulling images before running a 4 minute test suite is spending most of its billed minutes on work that was identical on the previous attempt. Snapshot runners remove that repetition. A snapshot captures the runner VM disk at a chosen point in a workflow, and later jobs boot from that disk instead of from the base image, so packages, dependency trees, git working trees, and pulled container images are already there. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only. Putting a snapshot label on a BYOC, Windows, or macOS runner does nothing: the label is silently ignored and the job runs normally. The full behavior is documented on the [snapshot runners docs page](/docs/ci/features/snapshot-runners). An agent workflow can target any of them; the snapshot path in this page applies to the Ubuntu labels. Adaptive made WarpBuild snapshot runners the default environment for its AI code agents. The numbers from that deployment are in the Proof section below. ## [Configuration](#configuration)Copy Anchor Link The pattern is two workflows. One produces the snapshot from a trusted branch. The other consumes it on every agent attempt. ### [The workflow that produces the environment](#the-workflow-that-produces-the-environment)Copy Anchor Link This one runs on `main`, boots from the base image with `snapshot.enabled=true`, installs everything an agent attempt would otherwise install, strips credentials, and saves the disk under a stable alias. Copy Text ``` name: agent-env on: push: branches: [main] schedule: - cron: "0 6 * * *" jobs: build-agent-env: runs-on: warp-ubuntu-latest-x64-4x;snapshot.enabled=true steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version: 22 - name: Install dependencies run: npm ci - name: Build once so the agent starts from a warm tree run: npm run build - name: Pull service images used by the test suite run: | docker pull postgres:16 docker pull redis:7 - name: Seed test fixtures run: ./scripts/seed-fixtures.sh - name: Remove credentials before the snapshot is captured run: | rm -rf $HOME/.ssh $HOME/.aws $HOME/.npmrc git clean -ffdx -e node_modules - name: Save snapshot uses: WarpBuilds/snapshot-save@v1 with: alias: "agent-env" fail-on-error: true wait-timeout-minutes: 60 ``` Two details in that file matter more than they look. The cleanup step runs before `snapshot-save`, because anything on disk at capture time is on disk for every job that later boots from the alias. WarpBuild provisions runners at the organization level and GitHub can hand a snapshot-booted runner to a different job in the same organization, so treat a snapshot as shared state and never let a cloud credential into it. On a public repository the alias is reachable from a pull request workflow, which raises the same point with less ambiguity. The `git clean -ffdx -e node_modules` line keeps the dependency tree while removing everything else untracked. Drop the exclusion if your agent runs `npm ci` anyway and you only want system packages and pulled images in the snapshot. ### [The workflow the agent runs](#the-workflow-the-agent-runs)Copy Anchor Link This one boots from the alias with `snapshot.key`, so the runner starts with the tree the previous workflow left behind. Copy Text ``` name: agent-attempt on: workflow_dispatch: inputs: task: description: Task description for the agent required: true issue_comment: types: [created] permissions: contents: write pull-requests: write jobs: agent: if: >- github.event_name == 'workflow_dispatch' || startsWith(github.event.comment.body, '/agent ') runs-on: warp-ubuntu-latest-x64-4x;snapshot.key=agent-env timeout-minutes: 30 steps: - uses: actions/checkout@v5 - name: Confirm the runner booted from the snapshot run: echo "snapshot=${WARPBUILD_SNAPSHOT_KEY:-none}" - name: Refresh anything that drifted since the snapshot run: npm ci --prefer-offline - name: Run the agent env: AGENT_API_KEY: ${{ secrets.AGENT_API_KEY }} run: ./scripts/run-agent.sh "${{ inputs.task || github.event.comment.body }}" - name: Test the agent output run: npm test - name: Open a pull request with the result if: success() run: ./scripts/open-pr.sh ``` The `runs-on` value is a single string: the runner label, a semicolon, then the snapshot directive. `snapshot.enabled=true` turns the feature on and always boots from the base image, which is what the producer workflow wants. `snapshot.key=` turns the feature on and boots from the existing snapshot for that alias, falling back to the base image when no snapshot exists yet. A runner booted from a snapshot exports `WARPBUILD_SNAPSHOT_KEY` with the alias, which is the cheapest way to assert in the job that the fast path actually happened. Two limits belong in the design rather than in a postmortem. Snapshots are deleted after 15 days, so the producer workflow needs a schedule that fires more often than that; the daily cron above is deliberate. And `/tmp` does not survive, because the directory is cleaned on reboot and a snapshot boot is a reboot. Keep anything the agent should find under `$HOME` or inside the workspace. ### [Giving the agent a read path into the fleet](#giving-the-agent-a-read-path-into-the-fleet)Copy Anchor Link An agent that opens pull requests eventually needs to answer questions about its own jobs: which attempt failed, which runner label it used, how long the queue was. WarpBuild hosts a Model Context Protocol server at `https://mcp.warpbuild.com/mcp`. Generate an API key with the CI scope from the [dashboard API keys page](https://app.warpbuild.com/settings/api-keys), then point the MCP host at the server: Copy Text ``` { "mcpServers": { "warpbuild": { "url": "https://mcp.warpbuild.com/mcp", "headers": { "Authorization": "Bearer " } } } } ``` The setup steps are in the [MCP support docs](/docs/ci/mcp). Scope the key to CI, keep it in the secret store the agent already uses, and rotate it on the same schedule as the rest of the agent's credentials. ## [Sizing](#sizing)Copy Anchor Link Agent loops produce many short jobs rather than a few long ones, so the sizing question is per-job overhead rather than peak throughput. Start from the Linux x64 catalog, taken from the WarpBuild [pricing page](/pricing) and checked on 2026-08-13. | Runner label | vCPU | RAM | Storage | USD per minute | | -------------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Linux ARM64 labels carry the same vCPU and RAM shapes at $0.003, $0.006, $0.012, $0.024, and $0.048 per minute. Runners bill per minute, so a shorter job is a smaller bill with no rounding surprise at the tail. ### [Against GitHub-hosted list prices](#against-github-hosted-list-prices)Copy Anchor Link Every row below states both per-minute prices and the computed difference. GitHub rates come from the published [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the shapes from the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), both checked on 2026-08-13. | WarpBuild label | Shape | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | Difference | | -------------------------- | --------------- | -------------------- | ----------------------------------- | ----------------- | --------------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | ubuntu-latest, private repositories | $0.006 | 33 percent lower list price | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent lower list price | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent lower list price | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent lower list price | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064 | 32-core Linux larger runner | $0.082 | 22 percent lower list price | GitHub gives public repositories a 4 vCPU, 16 GB shape at no charge, so the first row compares the private-repository shape that paying teams actually run. ### [Where the snapshot fee breaks even](#where-the-snapshot-fee-breaks-even)Copy Anchor Link Snapshots carry two line items on top of runner minutes. A restore bills $0.04 for each job that boots from a snapshot. Storage bills $0.025 per snapshot-hour, which is $0.60 per day and $18.00 per 30-day month for one alias kept alive continuously. The restore fee is a fixed amount, so its break-even point moves with the runner label. Divide $0.04 by the per-minute rate to get the setup time a snapshot has to remove before it pays for itself. | Runner label | USD per minute | Setup minutes worth $0.04 | | -------------------------- | -------------- | ------------------------- | | warp-ubuntu-latest-x64-2x | $0.004 | 10.00 | | warp-ubuntu-latest-x64-4x | $0.008 | 5.00 | | warp-ubuntu-latest-x64-8x | $0.016 | 2.50 | | warp-ubuntu-latest-x64-16x | $0.032 | 1.25 | | warp-ubuntu-latest-x64-32x | $0.064 | 0.63 | Read that table the practical way. On a 2 vCPU label, a snapshot that saves 3 minutes of `npm ci` costs more than it returns. On a 16 vCPU label running a fixture-heavy suite, the same 3 minutes clears the bar with room to spare. The bigger the label, the smaller the setup time a snapshot needs to remove. ### [A worked monthly model](#a-worked-monthly-model)Copy Anchor Link Take an agent loop that dispatches 3,000 attempts a month, on `warp-ubuntu-latest-x64-4x` at $0.008 per minute. Assume 15 minutes of environment setup on a base-image boot, 4 minutes of agent and test work, and 1 minute budgeted for the snapshot boot, which the docs put at 45 to 60 seconds. One alias stays alive for the whole month. | Line | Base image every attempt | Snapshot boot | | --------------------------- | ------------------------ | ------------- | | Setup minutes per job | 15 | 0 | | Work minutes per job | 4 | 4 | | Boot minutes per job | 0 | 1 | | Runner cost per job | $0.152 | $0.040 | | Snapshot restore per job | $0.000 | $0.040 | | Per job total | $0.152 | $0.080 | | 3,000 jobs | $456.00 | $240.00 | | Snapshot storage, one alias | $0.00 | $18.00 | | Monthly total | $456.00 | $258.00 | At these inputs the snapshot column lands $198.00 lower per month. Substitute your own setup time and attempt volume before deciding anything; the arithmetic is the point, and the shape of the answer flips when setup is short or volume is low. Every cost figure on this page carries the per-minute rate it came from, a source, and the date it was checked. ## [Bottlenecks](#bottlenecks)Copy Anchor Link ### [Environment setup](#environment-setup)Copy Anchor Link This is the one that matters for agent traffic, and it is the reason the snapshot pattern exists. Setup work is identical across attempts by construction, so paying for it on every attempt is paying for a constant. The failure mode to watch is a producer workflow that stops running. Snapshots are deleted after 15 days, so an alias whose producer broke three weeks ago is quietly falling back to base-image boots at full setup cost, and nothing in the job output announces it. The `WARPBUILD_SNAPSHOT_KEY` echo in the consumer workflow above turns that into a visible line in the log. ### [Snapshot drift](#snapshot-drift)Copy Anchor Link A snapshot is a point in time. A lockfile change on `main` after the snapshot was captured means the boot disk carries a stale dependency tree, and the agent works against the wrong versions until the next producer run. Two habits keep that under control: run the producer on every push to `main` plus a daily cron, and keep a cheap refresh step such as `npm ci --prefer-offline` in the consumer workflow so a drifted tree corrects itself instead of failing strangely. ### [State that does not survive a boot](#state-that-does-not-survive-a-boot)Copy Anchor Link The snapshot supplies a disk. It does not supply running processes. No service from the producer job is up, no port is listening, and no container is running when the consumer job starts, so anything the agent depends on has to be started by the job itself. `/tmp` is cleaned on reboot, and the job's GitHub identity is fresh on every run: new registration, new token, new secrets from the workflow. ### [Credentials inside the snapshot](#credentials-inside-the-snapshot)Copy Anchor Link An agent job usually carries more credentials than a normal build, because it writes branches and opens pull requests. Every one of those is a reason to keep the cleanup step in the producer workflow. Capture the snapshot after removing `$HOME/.ssh`, `$HOME/.aws`, registry tokens, and untracked files, and let the consumer job receive its credentials from GitHub secrets at run time. ### [Platform limits](#platform-limits)Copy Anchor Link Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only. BYOC runs on AWS, GCP, and Azure, and BYOC runners are the right choice when agent jobs need to sit inside your own account and network, but snapshot labels there are silently ignored. The same applies to Windows and macOS labels. If an agent workflow spans platforms, keep the snapshot path on the Ubuntu jobs and size the others normally. ### [Reading what the agent actually did](#reading-what-the-agent-actually-did)Copy Anchor Link Once attempts are running at volume, the interesting questions are aggregate: which job regressed, which label queues, which attempt burned 30 minutes before timing out. CI observability collects per-runner utilization and correlates it with GitHub Actions logs, and [GitHub Actions observability and runner metrics](/solutions/github-actions-observability) covers what it records and how to query it. Pair that with the MCP server above and the agent can answer those questions in its own session rather than waiting for a person to open a dashboard. ## [Proof](#proof)Copy Anchor Link Adaptive builds financial infrastructure for construction, and it made WarpBuild snapshot runners the default environment for its AI code agents, with tests, packages, integrations, and secrets pre-loaded in each environment. Adaptive reports a 60 percent reduction in manual engineering intervention per feature after that change. The full write-up is on the [Adaptive case study](/customers/adaptive). That deployment is the reference implementation for everything above: a prepared environment produced once, consumed by every agent attempt, with the engineering time that used to go into per-attempt scaffolding going somewhere else. Agents run with credentials and touch source code, so the compliance question arrives early. SSO is available for a flat $250 per month, whatever the user count, listed on the [pricing page](/pricing). Three companion pages go deeper than this one does: * [Snapshot runners for GitHub Actions](/runners/snapshot) covers the label syntax, what a snapshot holds, and the storage and restore line items in detail. * [Connect the WarpBuild MCP server to an AI assistant](/guides/github-actions-mcp-server) covers the full MCP host setup. * [Can AI agents run GitHub Actions jobs?](/answers/can-ai-agents-run-github-actions-jobs) is the short answer version of this page. ## [FAQ](#faq)Copy Anchor Link ### [Can an AI coding agent run its own GitHub Actions jobs?](#can-an-ai-coding-agent-run-its-own-github-actions-jobs)Copy Anchor Link Yes. An agent job is an ordinary GitHub Actions job: a trigger such as `workflow_dispatch`, `issue_comment`, or a label event starts it, the job runs on a runner label you choose, and the exit code is the signal the agent reads. On WarpBuild the label is a `warp-` label, and the agent process runs as a step inside that job with whatever credentials the workflow grants it. ### [How do I stop an agent from reinstalling the toolchain on every attempt?](#how-do-i-stop-an-agent-from-reinstalling-the-toolchain-on-every-attempt)Copy Anchor Link Boot the job from a snapshot. Add `snapshot.key=` to the `runs-on` label and the runner starts from a disk captured by an earlier job, with packages, dependency trees, checkouts, and pulled container images already present. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only, so BYOC, Windows, and macOS labels ignore the snapshot label silently and the job runs normally without it. ### [What does the snapshot restore fee mean for short agent jobs?](#what-does-the-snapshot-restore-fee-mean-for-short-agent-jobs)Copy Anchor Link A restore bills $0.04 for each job that boots from a snapshot, and snapshot storage bills $0.025 per snapshot-hour. At $0.008 per minute on `warp-ubuntu-latest-x64-4x`, $0.04 is the price of 5 minutes of runner time, so the fee pays for itself once the snapshot removes more than 5 minutes of setup from the job. On `warp-ubuntu-latest-x64-2x` at $0.004 per minute the same fee is 10 minutes of runner time. ### [How does an agent query the WarpBuild runner fleet directly?](#how-does-an-agent-query-the-warpbuild-runner-fleet-directly)Copy Anchor Link WarpBuild hosts a Model Context Protocol server at `https://mcp.warpbuild.com/mcp`. Generate an API key with the CI scope from the dashboard, add the server URL to your MCP host with an `Authorization` Bearer header carrying that key, and the agent can drive the WarpBuild API from its own session. --- # Android Emulator Tests on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/android-emulator-tests-github-actions Description: Android emulator tests need /dev/kvm. Append nested-virtualization.enabled=true to a Linux x86-64 warp- label, then add the documented KVM udev step. # Android Emulator Tests on GitHub Actions Android emulator tests need /dev/kvm. Append nested-virtualization.enabled=true to a Linux x86-64 warp- label, then add the documented KVM udev step. Last verified: 2026-08-13 An Android emulator test job on GitHub Actions needs hardware acceleration through /dev/kvm, and on WarpBuild you get it by appending `nested-virtualization.enabled=true` to a Linux x86-64 `warp-` label. The device then needs a udev permission rule before the emulator starts, which is standard Android emulator setup on all GitHub and GitHub-compatible runners rather than a WarpBuild-specific workaround. This page covers the runner classes that expose /dev/kvm and the ones that ignore the label, a complete sharded instrumented test workflow, how to size a runner so the emulator has headroom above the test process, the log signature that tells you acceleration is missing, and the per-minute arithmetic against GitHub-hosted list prices. ## [Overview](#overview)Copy Anchor Link An instrumented Android test boots a full Android system image. On a Linux x86-64 runner that image runs under QEMU, and QEMU reaches useful speed only when the kernel exposes /dev/kvm to it. Without the device the emulator still starts. It switches to software emulation, and a suite that normally finishes well inside the job timeout starts running long enough to hit it. Two conditions have to hold before an emulator job is accelerated. The runner has to sit on hardware that passes the virtualization extensions into the guest, and the `runner` user has to be allowed to open /dev/kvm. The first is a runner class question and the second is a permissions question. A job fails quietly when either one is missing. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. Nested virtualization is a Linux x86-64 feature on cloud runners, requested per job through a dynamic label. This is the support matrix from the [nested virtualization reference](/docs/ci/features/nested-virtualization): | Runner class | Nested virtualization | How it is enabled | | -------------------------- | --------------------- | -------------------------------------------------------------- | | Cloud: Linux x86-64 | Yes | nested-virtualization.enabled=true appended to the warp- label | | Cloud: Linux ARM64 | No | Label is silently ignored | | Cloud: Windows | No | Label is silently ignored | | Cloud: macOS | No | Label is silently ignored | | BYOC: AWS (Linux x86-64) | Yes | Auto-enabled when all instance types support it | | BYOC: GCP (Linux x86-64) | Yes | Auto-enabled when all instance types support it | | BYOC: Azure (Linux x86-64) | Yes | Auto-enabled on supported VM sizes | | BYOC: ARM64 and Windows | No | Not available | The rows that cost teams time are the silent ones. Putting `nested-virtualization.enabled=true` on an ARM64, Windows, or macOS label leaves the job running as usual, with no warning anywhere in the log. The label is ignored, the job runs without /dev/kvm, and the workflow file looks correct while every emulator run falls back to software emulation. BYOC behaves differently. BYOC runs on AWS, GCP, and Azure, and nested virtualization is enabled automatically when every instance type selected for the runner set supports it. Mixing supporting and non-supporting instance types for availability turns the feature off for the whole set, which keeps behavior consistent across jobs. Keep emulator runner sets on a uniform instance family, and pin that family in code where you can: Terraform support exists for BYOC on AWS. Every label, size, and per-minute rate lives in the [cloud runner catalog](/docs/ci/cloud-runners), and the full rate card is on the [pricing page](/pricing). For the rest of an Android pipeline, including assemble and unit test jobs, see [Android builds on GitHub Actions](/solutions/android-github-actions). For the short version of the support question, see [can GitHub Actions run an Android emulator](/answers/can-github-actions-run-android-emulator). Teams running Detox or Espresso inside a JavaScript app should read [React Native builds on GitHub Actions](/solutions/react-native-github-actions) for the two-platform split. ## [Configuration](#configuration)Copy Anchor Link Dynamic labels attach to the `runs-on` value with a semicolon: Copy Text ``` runs-on: warp-ubuntu-latest-x64-16x;nested-virtualization.enabled=true ``` WarpBuild then provisions that runner on hardware that supports nested virtualization and exposes the virtualization extensions to the guest, which makes /dev/kvm available. The permission step comes next. Default device permissions are `crw-rw---- root:kvm`, which stops the `runner` user from opening the device, so the workflow adds a udev rule before the emulator starts: Copy Text ``` - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm ``` That step is required on all GitHub and GitHub-compatible runners, and the same snippet appears in the [common issues guide](/docs/ci/common-issues) as the first thing to check on a slow Android job. Here is a complete instrumented test workflow with two shards, AVD caching, and a device-creation step that only runs on a cache miss: Copy Text ``` name: android-instrumented-tests on: pull_request: push: branches: [main] concurrency: group: android-emulator-${{ github.ref }} cancel-in-progress: true jobs: instrumented-tests: runs-on: warp-ubuntu-latest-x64-16x;nested-virtualization.enabled=true strategy: fail-fast: false matrix: shard: [0, 1] steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: '17' - uses: WarpBuilds/gradle-actions/setup-gradle@v5 - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Confirm the device is writable run: | ls -l /dev/kvm test -w /dev/kvm - name: Restore the AVD id: avd-cache uses: WarpBuilds/cache@v1 with: path: | ~/.android/avd/* ~/.android/adb* key: avd-api34-google_apis-x86_64 - name: Create the AVD and save a snapshot if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 target: google_apis arch: x86_64 force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: false script: echo "AVD created" - name: Run instrumented tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 target: google_apis arch: x86_64 force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true script: > ./gradlew connectedDebugAndroidTest --build-cache -Pandroid.testInstrumentationRunnerArguments.numShards=2 -Pandroid.testInstrumentationRunnerArguments.shardIndex=${{ matrix.shard }} ``` Five details in that file matter. **The label goes on the job that boots the emulator.** Assemble and unit test jobs have no KVM requirement, so they run on plain `warp-` labels. Adding the dynamic label to jobs that do not need it constrains scheduling for nothing. **The device is created once and restored after that.** The creation step is guarded by the cache-hit output, so it runs on the first execution of a new key and stays skipped afterwards. The key names the API level, the target, and the architecture, because changing any of the three makes the saved device unusable. **`-no-snapshot-save` on the test run keeps the cached snapshot stable.** Without it every shard writes back a snapshot carrying whatever state the tests left behind, and the cache entry drifts away from the clean device you meant to save. **Cache writes go to WarpBuild cache with no extra configuration.** WarpBuild cache is enabled by default on Linux runners, and `WarpBuilds/cache@v1` plus `WarpBuilds/gradle-actions/setup-gradle@v5` route through it while accepting the same inputs as their upstream counterparts. **The `test -w /dev/kvm` check converts a silent fallback into a failed step.** It costs nothing and it turns the most common misconfiguration on this page into a clear error at the top of the log instead of a long test run at the bottom of it. ## [Sizing](#sizing)Copy Anchor Link These are the Linux x86-64 rows from the [cloud runner catalog](/docs/ci/cloud-runners). Ubuntu 22.04 and 26.04 labels exist at the same rates. | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 | An emulator job runs two workloads on one machine. The emulator is a virtual machine holding its own vCPUs and RAM for the guest Android system, while Gradle, adb, and the instrumentation runner work beside it on the host. Sizing that counts only the test process leaves the emulator competing with the compiler for the same cores, and the symptom is usually a timeout inside the test harness rather than a build that reads as slow. AVD boot is the other fixed cost. A job that creates a device from a cold system image pays for the unpack and the first full boot before the first assertion runs, and that cost repeats on every shard and every matrix leg. Caching the AVD directory turns creation into a restore and lets the emulator come up from a saved snapshot, which is what the two-step pattern in the workflow above buys you. Boot time also sets the floor on how far sharding pays: each extra shard adds another restore and another boot, so splitting a short suite into many shards adds fixed cost per job faster than it removes wall clock. | Emulator layout | Label | Reasoning | | ------------------------------------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | One emulator, single-module app | warp-ubuntu-latest-x64-4x | The shape used in the docs example. It runs one device, and it leaves little room for the compile and package work that happens before installation. | | One emulator beside a Gradle daemon | warp-ubuntu-latest-x64-8x | 8 vCPU and 32GB cover the device plus connectedDebugAndroidTest, which still assembles the app and the test APK before it installs anything. | | Two emulators, or one emulator and a wide module graph | warp-ubuntu-latest-x64-16x | Two devices double the memory floor and the vCPU claim, and a wide module graph wants cores the emulator is not holding. | | Four emulators or several API levels in one job | warp-ubuntu-latest-x64-32x | 32 vCPU and 128GB keep four devices and the host tooling apart. Price this against a matrix of four 8x jobs before choosing it. | The 2x label has no headroom for this workload and belongs on lint or small library jobs. ### [Against GitHub-hosted list prices](#against-github-hosted-list-prices)Copy Anchor Link GitHub publishes per-minute rates on the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and runner shapes on its [hosted runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Both were checked on 2026-08-13\. These are the rows an emulator pipeline touches: | Shape | WarpBuild label | WarpBuild rate | GitHub-hosted equivalent | GitHub rate | Lower list price | | -------------------- | -------------------------- | -------------- | --------------------------- | ----------- | ---------------- | | 4 vCPU, 16GB Linux | warp-ubuntu-latest-x64-4x | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent | | 8 vCPU, 32GB Linux | warp-ubuntu-latest-x64-8x | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent | | 16 vCPU, 64GB Linux | warp-ubuntu-latest-x64-16x | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent | | 32 vCPU, 128GB Linux | warp-ubuntu-latest-x64-32x | $0.064 | 32-core Linux larger runner | $0.082 | 22 percent | Take a repository that runs the workflow above on every pull request, 500 times a month, with two shards of 18 minutes each on the 16 vCPU label. Durations are held equal on both platforms so the table isolates the rate: | Line | Minutes | WarpBuild rate | WarpBuild cost | GitHub-hosted rate | GitHub-hosted cost | | ------------------------- | ------- | -------------- | -------------- | ------------------ | ------------------ | | Shard 0 (16 vCPU) | 18 | $0.032/min | $0.576 | $0.042/min | $0.756 | | Shard 1 (16 vCPU) | 18 | $0.032/min | $0.576 | $0.042/min | $0.756 | | **Per pipeline run** | 36 | **$1.152** | **$1.512** | | | | **Per month at 500 runs** | 18,000 | **$576.00** | **$756.00** | | | The monthly gap is $180.00 with durations held equal. Full rates are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **1\. Missing hardware acceleration.** This is the first thing to rule out, and it has an exact log signature. The emulator action's `ProbeKVM` check fails, then the action launches the emulator with `-accel off`, which means pure software emulation. In the job log you will see: Copy Text ``` ProbeKVM: This user doesn't have permissions to use KVM (/dev/kvm). Disabling Linux hardware acceleration. ``` followed by an emulator command line containing `-accel off`. Two causes produce that signature. Either the job is on a runner class that has no /dev/kvm, which means the dynamic label was placed on an ARM64, Windows, or macOS label and ignored, or the device exists and the permissions step is missing. Check the runner class first, then the udev step. After both are in place those messages disappear and tests run with hardware acceleration. **2\. Cold AVD creation.** Creating a device from a downloaded system image, unpacking it, and taking it through a first boot happens before any test runs. Cache `~/.android/avd/*` and `~/.android/adb*`, keep the key pinned to the API level, target, and architecture, and run the dedicated creation step only on a cache miss. **3\. Emulator contention with the build.** `connectedDebugAndroidTest` compiles the app and the test APK, packages both, installs them over adb, and only then runs tests. All of that shares a machine with the running device. When a job is sized for the compile alone, installation and instrumentation slow down together, so size for both workloads and confirm the choice against measured utilization. **4\. Boot completion and adb behavior.** A device that reports ready before the package manager has settled produces install failures that read as flaky tests. Run the emulator with animations disabled, no window, no audio, and no camera, as in the workflow above, and let the emulator action own the boot wait rather than adding a manual sleep. Failures that survive that treatment belong in the flake triage flow described in [finding and fixing flaky GitHub Actions jobs](/guides/flaky-github-actions-jobs). When a job is still slow after those four, measure instead of guessing. WarpBuild's CI observability correlates OpenTelemetry system metrics from the runner agent with GitHub Actions job logs, which shows whether an emulator job is CPU-bound, memory-bound, or waiting on the network. The Action Debugger pauses a workflow and opens an SSH session on the runner, which is the fastest way to check /dev/kvm permissions, read the live emulator command line, and inspect adb state on the machine that is actually running the job. ## [Proof](#proof)Copy Anchor Link The migration cost is the label plus the dynamic suffix. Linux x86-64 runner images carry the same tooling as GitHub-hosted images, so the Android SDK, platform tools, Gradle, and `reactivecircus/android-emulator-runner` behave as they do today. ## [FAQ](#faq)Copy Anchor Link **Which WarpBuild runners support nested virtualization?** Cloud Linux x86-64 runners support it through the `nested-virtualization.enabled=true` label. Cloud ARM64, Windows, and macOS runners do not. On BYOC, AWS, GCP, and Azure enable it automatically when every instance type selected for the runner set supports it. **My emulator job has the nested virtualization label and is still slow. What do I check?** Read the job log for `ProbeKVM`. The signature of a missing device is `ProbeKVM` reporting that the user has no permission to use /dev/kvm, followed by a line disabling Linux hardware acceleration and an emulator command line containing `-accel off`. Check the runner class first, then the KVM permissions step. **What size runner should an Android emulator job use?** Start at `warp-ubuntu-latest-x64-8x` for one emulator beside a Gradle daemon, and move to `warp-ubuntu-latest-x64-16x` when the module graph is wide or two devices share the runner. The emulator claims its own vCPUs and RAM, so size above what the test process alone needs. **What does an emulator test job cost on WarpBuild runners?** `warp-ubuntu-latest-x64-8x` is $0.016 per minute and `warp-ubuntu-latest-x64-16x` is $0.032 per minute. --- # Speeding Up Android Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/android-github-actions Description: Run Android emulator jobs on WarpBuild x86-64 runners by appending nested-virtualization.enabled=true to a warp- label and adding a KVM permissions step. # Speeding Up Android Builds on GitHub Actions Run Android emulator jobs on WarpBuild x86-64 runners by appending nested-virtualization.enabled=true to a warp- label and adding a KVM permissions step. Last verified: 2026-08-13 Android emulator jobs on GitHub Actions need hardware acceleration through /dev/kvm, and on WarpBuild you enable it by appending `nested-virtualization.enabled=true` to an x86-64 `warp-` runner label and adding one KVM permissions step before the emulator starts. This page covers the exact workflow configuration, runner sizing for assemble, unit test, and instrumented test jobs, the four bottlenecks that dominate Android build time, and a worked cost model against GitHub-hosted runner list prices. ## [Overview](#overview)Copy Anchor Link Android is one of the heaviest workloads you can put on GitHub Actions. A typical pipeline compiles Kotlin across many Gradle modules, runs JVM unit tests, then boots a full Android system image inside an emulator for instrumented tests. Each stage stresses a different resource: compilation wants cores and memory, unit tests want JVM warm-up amortized away, and the emulator wants KVM virtualization support on the host. The emulator is the part teams get wrong most often. The Android emulator runs x86-64 system images at useful speed only when it can use hardware acceleration through /dev/kvm. When /dev/kvm is missing or unreadable, the emulator silently switches to software emulation, and instrumented test jobs that should take minutes take much longer or time out. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. For Android emulator work the requirement is specific: nested virtualization is available on Linux x86-64 cloud runners through a dynamic label, and it is not currently supported on ARM64 runners, so emulator jobs must run on x86-64 labels. The full support matrix is in the [nested virtualization docs](/docs/ci/features/nested-virtualization). Assemble and unit test jobs have no KVM requirement, so they run on any Linux runner size. The [cloud runner catalog](/docs/ci/cloud-runners) lists every label, size, and per-minute rate, and the [pricing page](/pricing) carries the full rate card. This page pairs with the [Gradle tuning guide](/solutions/gradle-github-actions) for build-level configuration and the [general GitHub Actions speed playbook](/guides/speed-up-github-actions) for workflow-level patterns that apply beyond Android. ## [Configuration](#configuration)Copy Anchor Link WarpBuild runners are drop-in replacements for GitHub-hosted runners, so migration is a label change. The one Android-specific addition is the nested virtualization dynamic label, which is appended to the `runs-on` value with a semicolon: Copy Text ``` runs-on: warp-ubuntu-latest-x64-16x;nested-virtualization.enabled=true ``` WarpBuild provisions that runner on hardware that supports nested virtualization and exposes the virtualization extensions to the guest, which makes /dev/kvm available. If you put the label on an unsupported runner type, it is silently ignored and the job runs without nested virtualization, so a job that seems fine but runs slowly is worth checking for this mistake first. One more step is required after /dev/kvm exists. The default device permissions (`crw-rw---- root:kvm`) prevent the `runner` user from opening it, so the workflow needs a udev rule before the emulator starts. This step is required on all GitHub and GitHub-compatible runners; it is standard Android emulator setup rather than a WarpBuild-specific workaround. Here is a complete three-job Android workflow with Gradle caching and AVD caching: Copy Text ``` name: android on: pull_request: push: branches: [main] jobs: assemble: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: 17 - uses: gradle/actions/setup-gradle@v4 - run: ./gradlew assembleDebug --build-cache unit-tests: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: 17 - uses: gradle/actions/setup-gradle@v4 - run: ./gradlew testDebugUnitTest --build-cache instrumented-tests: runs-on: warp-ubuntu-latest-x64-16x;nested-virtualization.enabled=true steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: 17 - uses: gradle/actions/setup-gradle@v4 - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: Cache AVD uses: actions/cache@v4 with: path: | ~/.android/avd/* ~/.android/adb* key: avd-api-34 - name: Run instrumented tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: 34 arch: x86_64 disable-animations: true script: ./gradlew connectedDebugAndroidTest --build-cache ``` Three details matter in this file. **The Android SDK is preinstalled.** WarpBuild Linux x86-64 runner images carry the same tooling as GitHub-hosted runner images, so the SDK, platform tools, and common build dependencies are already on disk. Your workflow avoids installing the SDK from scratch on every run. **Gradle caching comes from two layers.** `gradle/actions/setup-gradle` persists the Gradle wrapper, dependency caches, and configuration cache data between runs, while `--build-cache` lets Gradle reuse task outputs. WarpBuild cache is enabled by default on Linux runners, so `actions/cache` and `setup-gradle` write to WarpBuild's cache backend without any workflow changes. **AVD caching skips device re-creation.** Caching `~/.android/avd/*` and `~/.android/adb*` lets `reactivecircus/android-emulator-runner` restore an existing virtual device and boot from its snapshot instead of creating the device from a cold system image on every run. On BYOC, the label is unnecessary. BYOC runs on AWS, GCP, and Azure, and nested virtualization is automatically enabled on a BYOC runner set when all of the selected instance types support it. If you mix supporting and non-supporting instance types, it stays off for consistency across jobs, so keep emulator runner sets on a uniform instance family. ## [Sizing](#sizing)Copy Anchor Link These are the Linux x86-64 sizes and rates, from the [cloud runner catalog](/docs/ci/cloud-runners). Ubuntu 22.04 and 26.04 labels exist at the same rates. | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 | For Android work, the useful range is 8x to 16x: | Job type | Recommended label | Reasoning | | ------------------ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Assemble | warp-ubuntu-latest-x64-8x | Kotlin and Java compilation parallelizes across modules; 8 vCPU and 32GB covers most multi-module apps. Move to 16x when the module graph is wide enough to keep 16 cores busy. | | Unit tests | warp-ubuntu-latest-x64-8x | JVM tests scale with maxParallelForks; each fork wants a core and real memory. 16x pays off when the suite is large and well parallelized. | | Instrumented tests | warp-ubuntu-latest-x64-16x | The emulator claims several cores and gigabytes of memory for itself while Gradle, adb, and the test orchestrator run beside it. 16x also leaves room for two emulators in a sharded matrix. | The 2x and 4x sizes are too small for emulator work: once the emulator takes its share, Gradle is left starved. They remain reasonable for lint-only or small library jobs. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes list prices for its hosted runners on the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13: Linux 8-core at $0.022 per minute and Linux 16-core at $0.042 per minute for private repositories. Public repositories get free minutes on GitHub's standard runners, so this model applies to private repositories. Take a pipeline that runs 400 times per month: a 10 minute assemble job on 8 vCPU, an 8 minute unit test job on 8 vCPU, and a 25 minute instrumented test job on 16 vCPU. Holding job durations equal on both platforms: | Job | Minutes | WarpBuild rate | WarpBuild cost | GitHub-hosted rate | GitHub-hosted cost | | ---------------------------- | ------- | -------------- | -------------- | ------------------ | ------------------ | | Assemble (8 vCPU) | 10 | $0.016/min | $0.16 | $0.022/min | $0.22 | | Unit tests (8 vCPU) | 8 | $0.016/min | $0.128 | $0.022/min | $0.176 | | Instrumented tests (16 vCPU) | 25 | $0.032/min | $0.80 | $0.042/min | $1.05 | | **Per pipeline run** | 43 | **$1.088** | **$1.446** | | | | **Per month at 400 runs** | 17,200 | **$435.20** | **$578.40** | | | The difference at these volumes is $143.20 per month with durations held equal on both platforms. Full rates are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four bottlenecks account for most slow Android pipelines on GitHub Actions. **1\. Emulator boot time.** Creating an AVD from a cold system image and booting it dominates short instrumented test suites. The fix is snapshot reuse: cache the AVD directory as shown above so the emulator boots from a saved snapshot, run with `disable-animations: true`, and keep the emulator headless. `reactivecircus/android-emulator-runner` supports a dedicated snapshot-generation run whose only job is to create the AVD and save the snapshot for later cache hits. **2\. KVM availability.** Without /dev/kvm, the emulator action's `ProbeKVM` check fails and it silently launches the emulator with `-accel off`, which means pure software emulation. The log symptoms are explicit: `ProbeKVM: This user doesn't have permissions to use KVM (/dev/kvm).`, then `Disabling Linux hardware acceleration.`, and an emulator command line containing `-accel off`. The fix on WarpBuild is the two-part configuration above: the `nested-virtualization.enabled=true` dynamic label on an x86-64 runner, plus the KVM permissions step. After both are in place those messages disappear and tests run with hardware acceleration. **3\. Gradle daemon cold start.** WarpBuild runners are ephemeral VMs, so every job starts a fresh Gradle daemon and a cold JVM. You can win most of that time back with the configuration cache, the remote-friendly build cache via `--build-cache`, and `gradle/actions/setup-gradle` persisting dependency and wrapper caches between runs. Compilation avoids repetition even though the process starts cold. **4\. SDK and AVD image downloads.** System images, platform packages, and emulator binaries are large downloads when fetched inside the job. WarpBuild x86-64 images ship the same preinstalled tooling as GitHub-hosted images, which removes the SDK bootstrap, and the AVD cache removes repeated system image unpacking. Anything your build fetches beyond the preinstalled set belongs in a cache key. When a pipeline is still slow after these four are handled, measure before guessing. WarpBuild's CI observability correlates OpenTelemetry system metrics from the runner agent with GitHub Actions job logs, which shows whether an instrumented test job is CPU-bound, memory-bound, or waiting on the network. For interactive debugging, the Action Debugger pauses a workflow and opens an SSH session on the runner, so you can inspect /dev/kvm permissions, emulator flags, and Gradle daemon state on the live machine. ## [FAQ](#faq)Copy Anchor Link **Can I run the Android emulator on WarpBuild ARM64 runners?** No. Nested virtualization is not currently supported on ARM64 runners, so emulator jobs must use x86-64 labels such as `warp-ubuntu-latest-x64-8x`. Plain assemble and unit test jobs run fine on ARM64 if your toolchain supports it. **Do I still need the KVM permissions step on WarpBuild runners?** Yes. Default /dev/kvm permissions block the runner user on all GitHub and GitHub-compatible runners, so add the udev rules step before starting the emulator. Without it the emulator falls back to software emulation. **What does an Android pipeline cost on WarpBuild runners?** `warp-ubuntu-latest-x64-8x` is $0.016 per minute and `warp-ubuntu-latest-x64-16x` is $0.032 per minute. **What happens if I put the nested virtualization label on an unsupported runner?** The label is silently ignored and the job runs without /dev/kvm. That includes ARM64, Windows, and macOS runners. Emulator jobs belong on Linux x86-64 labels. --- # Angular Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/angular-github-actions Description: The Angular CLI switches off its disk cache when CI is set. Turn it back on, restore .angular/cache beside the npm store, and size warp- runners per phase. # Angular Builds on GitHub Actions The Angular CLI switches off its disk cache when CI is set. Turn it back on, restore .angular/cache beside the npm store, and size warp- runners per phase. Last verified: 2026-08-13 To make `ng build` fast on GitHub Actions, set the Angular CLI cache `environment` to `all` so the CLI writes its disk cache while `CI` is set, then restore `.angular/cache` and the package manager store at the start of the job. On WarpBuild runners both halves are workflow edits: `runs-on` picks a warp- label from 2 to 32 vCPU, and [WarpBuilds/cache](https://github.com/WarpBuilds/cache) is a drop-in replacement for `actions/cache@v4`. This page covers the Angular cache setting that most workflows miss, the two cache entries an Angular repository needs, a workflow with the test run split from the production build, sizing for each phase, and the list-price arithmetic on a stated volume. ## [Overview](#overview)Copy Anchor Link The Angular CLI keeps a persistent build cache on disk at `.angular/cache`, holding the compiler and bundler output that lets a rebuild skip work whose inputs did not move. The `cli.cache` block in `angular.json` carries three options: `enabled`, `path`, and `environment`. `environment` is the one that decides whether GitHub Actions ever sees a warm cache. It takes `local`, `ci`, or `all`, and it defaults to `local`, which means the CLI turns the disk cache off whenever the `CI` environment variable is present. GitHub Actions sets `CI` to `true` on every runner, listed in the [variables reference](https://docs.github.com/en/actions/reference/workflows-and-actions/variables). So an Angular repository that restores `.angular/cache` without changing that option restores a directory the CLI then declines to read. Run `ng cache info` in the job to print the resolved setting and the on-disk size, documented under the [ng cache command](https://angular.dev/cli/cache). Two directories are worth carrying between runs, and they change on different schedules. The package manager store moves when the lockfile moves. `.angular/cache` moves whenever source files move, which is every pull request. An Angular pipeline then has three phases with different resource shapes: **Install** rebuilds `node_modules` from the store. Bounded by disk and network, not by cores. **Compile and type check** runs the Angular compiler over the whole program. This pass is largely single threaded, so cores do little for it and heap size does. **Bundle and minify** splits across a worker pool sized from the machine CPU count, and it is the phase that repays a larger runner. Angular work belongs on the Linux x64 sizes, both because they run to 32 vCPU and because WarpBuild Cache is enabled by default on Linux runners. ## [Configuration](#configuration)Copy Anchor Link First, the `angular.json` change. Without it the workflow below restores a cache the CLI ignores: Copy Text ``` { "cli": { "cache": { "enabled": true, "environment": "all", "path": ".angular/cache" } } } ``` Then the workflow. The test job runs on a 4 vCPU runner and the production build on an 8 vCPU runner, so the two phases stop sharing one machine size. The package manager store is handled by [WarpBuilds/setup-node](https://github.com/WarpBuilds/setup-node), one of the [cache-enabled setup actions](/docs/ci/features/caching/setup-actions) that route dependency caching through WarpBuild Cache. Copy Text ``` name: angular on: pull_request: push: branches: [main] jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm cache-dependency-path: package-lock.json - run: npm ci - name: Restore Angular cache uses: WarpBuilds/cache@v1 with: path: .angular/cache key: angular-test-${{ runner.os }}-${{ hashFiles('package-lock.json', 'angular.json', 'tsconfig*.json') }} restore-keys: | angular-test-${{ runner.os }}- - run: npx ng test --watch=false --browsers=ChromeHeadless --progress=false build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm cache-dependency-path: package-lock.json - run: npm ci - name: Restore Angular cache uses: WarpBuilds/cache@v1 with: path: .angular/cache key: angular-build-${{ runner.os }}-${{ hashFiles('package-lock.json', 'angular.json', 'tsconfig*.json') }}-${{ github.sha }} restore-keys: | angular-build-${{ runner.os }}-${{ hashFiles('package-lock.json', 'angular.json', 'tsconfig*.json') }}- angular-build-${{ runner.os }}- - run: npx ng build --configuration production ``` Three details in that key design earn their place. The hash covers the lockfile plus `angular.json` and the tsconfig files. The lockfile carries the compiler and builder versions, and the two configuration files change build output without moving a dependency, so both belong in the key. The two jobs use different key prefixes. `ng test` and `ng build --configuration production` produce different cache contents, and pointing both jobs at one key makes each run overwrite the other job's entry. The build key ends in `github.sha` so every run saves a fresh entry, and the ladder underneath falls back to the newest entry on the same dependency and configuration set, then to the newest entry for the runner. A partial hit is safe here: the CLI keys each unit of work internally and redoes only what changed. Cache entries are scoped to key, version, and branch, and they expire after 7 days of last use, so seed the entry by running the workflow on `main` and let branch builds reach it through `restore-keys`. On hosted runners the cache is metered at $0.20 per GB-month of storage and $0.0001 per write or restore, and it is included at no charge on BYOC runners. ## [Sizing](#sizing)Copy Anchor Link WarpBuild Linux x64 runners carry 4GB of memory per core across the range documented under [cloud runners](/docs/ci/cloud-runners): | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | **The production build takes 8 vCPU.** The compile pass holds one core whatever the label says, and the bundling and minification pass spreads across the rest. On 4 vCPU that second pass becomes the visible part of the job for an application past a few hundred components. Above 8 vCPU the returns depend on how much of the build is bundling, so read the phase timings from the build log before moving up. **The Karma job takes 4 vCPU.** `ng test` drives one headless browser by default, so extra cores sit idle while memory does the work: Chrome plus the compiled test bundle is what pushes a small runner into swapping. The Linux x64 images carry the same tooling as GitHub-hosted runners, so Chrome is already installed, as listed in the [Ubuntu 24.04 image manifest](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md). Splitting a large suite across parallel jobs is the lever that a bigger single runner cannot pull, and the [guide to sizing runners for Node test suites](/guides/sizing-runners-for-node-test-suites) covers where that trade lands. ### [Worked cost model](#worked-cost-model)Copy Anchor Link Take the pipeline above at 600 pull request runs a month, with a 5 minute build job and a 4 minute test job: | Line item | Rate | Monthly minutes | Monthly cost | | ----------------------------------------------- | ------------------------------ | --------------- | ------------ | | Build job, warp-ubuntu-latest-x64-8x | $0.016/min | 3,000 | $48.00 | | Test job, warp-ubuntu-latest-x64-4x | $0.008/min | 2,400 | $19.20 | | Cache storage and operations (1.5GB, 3,600 ops) | $0.20/GB-month plus $0.0001/op | n/a | $0.66 | | Total | 5,400 | $67.86 | | Against GitHub list prices for the same shape: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), which is 27 percent lower list price ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). On the 3,000 build minutes above, that is $48.00 against $66.00. Every size and platform rate sits on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **The CLI cache disabled by its own default.** This is the first thing to check, because the symptom looks like a cache action problem. The restore step reports a hit, the build still does full work, and the cause is the `environment` value described in the overview. `ng cache info` in the job prints the answer in one line. **A key that never repeats.** Hashing a directory glob that covers generated files, or every file under `src`, mints a fresh key on every push and turns the ladder into a cold start. Hash the lockfile and the configuration files instead, and let the CLI decide what inside the directory is still valid. **Install from the registry.** When `npm ci` runs without a warm store, the wall time tracks the dependency count rather than the change. The store setup shown above fixes it, and the [Node.js on GitHub Actions](/solutions/nodejs-github-actions) page covers the npm, pnpm, and yarn variants. The same store and framework cache split applies to a [Vue build](/solutions/vue-github-actions). When the responsible phase is unclear, WarpBuild CI observability correlates runner system metrics with GitHub Actions job logs, which separates a compile pass pinning one core from a bundling pass using all of them. The Action Debugger pauses the workflow and opens an SSH session on the runner, so you can read `.angular/cache` on the machine itself. Snapshot runners, remote Docker builders, an MCP server, and the Action Debugger are part of the same product surface. ## [Proof](#proof)Copy Anchor Link Every cost number here is list-price arithmetic against GitHub's published rates, with the source linked and the date stated, so you can re-run it against your own minute counts. Public repositories running warp- labels are citable evidence, and reading a `runs-on` line takes a few seconds. The Trigger.dev TypeScript monorepo runs its end-to-end matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x`, which you can read in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml) (checked on 2026-08-13). ## [FAQ](#faq)Copy Anchor Link ### [Why is the Angular cache empty on every GitHub Actions run?](#why-is-the-angular-cache-empty-on-every-github-actions-run)Copy Anchor Link The Angular CLI cache option `environment` defaults to `local`, which disables the disk cache whenever the `CI` environment variable is set, and GitHub Actions sets `CI` to `true` on every runner. Set `environment` to `all` in the `cli.cache` block of `angular.json`, then restore and save `.angular/cache` with a cache action so the directory survives the runner being destroyed. ### [What should the .angular/cache key hash?](#what-should-the-angularcache-key-hash)Copy Anchor Link The lockfile plus `angular.json` and the tsconfig files. The lockfile covers the compiler and builder versions, and the two configuration files cover the build options that change the output without touching a dependency. Add a `restore-keys` ladder so a dependency bump restores the previous directory instead of starting cold. ### [What runner size does an Angular build need?](#what-runner-size-does-an-angular-build-need)Copy Anchor Link `warp-ubuntu-latest-x64-8x` for the production build, where bundling and minification spread across workers, and `warp-ubuntu-latest-x64-4x` for a Karma job that drives one headless browser. Move the build to 16x only after the log shows the bundling phase holding all 8 cores. --- # GitOps Deployments with Argo CD and GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/argocd-deployments-github-actions Description: An Argo CD release splits in two: the GitHub Actions workflow builds by digest and commits the manifest, and the in-cluster controller applies it. # GitOps Deployments with Argo CD and GitHub Actions An Argo CD release splits in two: the GitHub Actions workflow builds by digest and commits the manifest, and the in-cluster controller applies it. Last verified: 2026-08-13 An Argo CD release splits a deployment across two systems: the GitHub Actions workflow builds the image, resolves its digest, and commits that digest into a manifest in a config repository, and the Argo CD controller running inside the cluster picks the commit up and applies it. The workflow therefore holds a registry credential and a git write token, while a kubeconfig never enters the run, which is what takes cluster credentials out of GitHub Actions entirely. This page gives the responsibility split job by job, a workflow file that builds by digest and commits the manifest change with no cluster credentials present, the verification job that waits for the rollout and reports its result back to the run, runner sizes and per-minute rates for each of the three jobs, and the failure modes that appear once every merge goes through this path. ## [Overview](#overview)Copy Anchor Link Argo CD follows the GitOps model, where the desired configuration is pushed to git first and the cluster state then syncs to what git says ([automation from CI pipelines](https://argo-cd.readthedocs.io/en/stable/user-guide/ci%5Fautomation/)). The consequence for GitHub Actions is a hard boundary. With an automated sync policy configured, pipelines no longer need direct access to the Argo CD API server to perform the deployment, and the pipeline instead makes a commit and push to the repository that tracks the manifests ([automated sync policy](https://argo-cd.readthedocs.io/en/stable/user-guide/auto%5Fsync/)). Here is where each piece of work lands and which credential it needs. | Step | Runs in | Credential it holds | | --------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------ | | Build the image and push it to the registry | GitHub Actions job | Short lived registry credential from OpenID Connect | | Resolve the immutable digest of what was pushed | GitHub Actions job | None | | Rewrite the image reference in the manifest and commit it | GitHub Actions job | Git write token scoped to the config repository | | Detect the new commit | Argo CD repo server in the cluster | Argo CD's own repository credential | | Apply the manifests and reconcile drift | Argo CD application controller | In-cluster service account | | Report synced and healthy | Argo CD API server | Read scoped project token, used only by the verify job | Two facts about that middle row decide how the pipeline feels. Argo CD polls repositories every three minutes to detect changes to the manifests, and the API server can be configured to receive webhook events from the git provider to remove that delay ([webhook configuration](https://argo-cd.readthedocs.io/en/stable/operator-manual/webhook/)). Argo CD's own guidance is to keep the Kubernetes manifests in a repository separate from the application source ([best practices](https://argo-cd.readthedocs.io/en/stable/user-guide/best%5Fpractices/)), which means the commit step usually writes to a second repository and therefore needs a credential that `GITHUB_TOKEN` cannot provide. The GitHub Actions side of this is three Linux jobs. All three jobs here belong on Linux; the label catalog is on the [cloud runners page](/docs/ci/cloud-runners). The pipeline that applies manifests directly with `kubectl` or `helm` instead, and the private cluster reach it needs, is covered on [Kubernetes deploy jobs on GitHub Actions](/solutions/kubernetes-deployments-github-actions). ## [Configuration](#configuration)Copy Anchor Link The workflow below has a build job and a promote job. The build job produces a digest. The promote job checks out the config repository, rewrites the image reference, and pushes. Nothing in either job can talk to the cluster. Copy Text ``` name: release-api on: push: branches: [main] permissions: id-token: write contents: read jobs: build: runs-on: warp-ubuntu-latest-x64-4x outputs: image: ${{ steps.ecr.outputs.registry }}/api@${{ steps.push.outputs.digest }} steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-build aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - name: Build and push id: push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ${{ steps.ecr.outputs.registry }}/api:${{ github.sha }} profile-name: "api-images-x64" promote: needs: build runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 with: repository: acme/cluster-config ref: main ssh-key: ${{ secrets.CONFIG_REPO_DEPLOY_KEY }} persist-credentials: true - name: Point the overlay at the new digest working-directory: overlays/production run: kustomize edit set image acme/api=${{ needs.build.outputs.image }} - name: Commit and push run: | git config user.name "release-bot" git config user.email "release-bot@acme.example" git commit -am "api: ${{ needs.build.outputs.image }}" for attempt in 1 2 3; do git pull --rebase && git push && exit 0 sleep 5 done exit 1 ``` Four details in that file carry the design. The promote job consumes a digest through a job output, so the manifest records the exact bytes that were built and a retag upstream cannot change what the cluster runs. [What an image digest identifies](/glossary/image-digest) covers why that reference is stable, and [build once and deploy the same artifact everywhere](/guides/build-once-deploy-many-github-actions) covers the promotion pattern this enables across environments. `kustomize edit set image` is the manifest edit Argo CD's own pipeline documentation uses, alongside a `kubectl patch --local` form for plain YAML ([automation from CI pipelines](https://argo-cd.readthedocs.io/en/stable/user-guide/ci%5Fautomation/)). Either one keeps the commit to a single line of change, which keeps the diff reviewable and the revert trivial. The push is retried on a rebase. Every service in the fleet writes to the same config repository, so two releases finishing seconds apart will collide on a non fast-forward push, and the loop above is the cheapest fix. The credential is a deploy key scoped to the config repository alone. The workflow-level `permissions` block stays at `contents: read`, and no step receives a token that can write to the source repository ([automatic token authentication](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication)). Each WarpBuild runner is a virtual machine created for one job and destroyed when the job ends, with its own encrypted storage volume, and WarpBuild does not access or store build secrets ([security documentation](/docs/ci/security)), so the deploy key lives as long as the promote job does. ### [Waiting for the rollout and reporting back](#waiting-for-the-rollout-and-reporting-back)Copy Anchor Link A push that nobody watches is a deployment nobody has confirmed. The verify job below downloads the `argocd` CLI from the API server, which keeps the client version aligned with the server, and blocks until the application is both synced and healthy ([argocd app wait](https://argo-cd.readthedocs.io/en/stable/user-guide/commands/argocd%5Fapp%5Fwait/)). Copy Text ``` verify: needs: [build, promote] runs-on: warp-ubuntu-latest-x64-2x env: ARGOCD_SERVER: argocd.internal.acme.example ARGOCD_AUTH_TOKEN: ${{ secrets.ARGOCD_PROJECT_TOKEN }} steps: - name: Install the CLI from the API server run: | curl -sSL -o /usr/local/bin/argocd \ https://$ARGOCD_SERVER/download/argocd-linux-amd64 chmod +x /usr/local/bin/argocd - name: Wait for the rollout run: argocd app wait api --sync --health --timeout 600 - name: Report the deployed digest if: always() run: | echo "### api rollout" >> $GITHUB_STEP_SUMMARY echo "image: ${{ needs.build.outputs.image }}" >> $GITHUB_STEP_SUMMARY argocd app get api -o json \ | jq -r '"sync: \(.status.sync.status) health: \(.status.health.status)"' \ >> $GITHUB_STEP_SUMMARY ``` The token in `ARGOCD_AUTH_TOKEN` is a project scoped JWT, and Argo CD RBAC decides what it may read ([RBAC configuration](https://argo-cd.readthedocs.io/en/stable/operator-manual/rbac/)). A read scoped token is enough for `app wait` and `app get`, so the verify job observes the rollout without gaining the ability to trigger one. Writing the result to `$GITHUB_STEP_SUMMARY` puts the deployed digest and the final sync and health status on the run page, which is where anyone investigating a bad release looks first. ## [Sizing](#sizing)Copy Anchor Link Three jobs with three different shapes. Rates are from the [pricing page](/pricing), billed per minute, checked on 2026-08-13. | Label | vCPU | RAM | Storage | USD per minute | Job it fits | | --------------------------- | ---- | ----- | --------- | -------------- | --------------------------------------------------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | Promote and verify | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | Image build for a single service | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | Image build with a heavy compile stage | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | 150GB SSD | $0.006 | The same build when the image publishes an arm64 manifest | The promote job runs a checkout, a one line file edit, and a push. It is a 2 vCPU job at every fleet size, because the config repository does not grow with the number of services deploying to it. The verify job spends nearly all of its runtime blocked on the Argo CD API, so it belongs on the same row for the same reason. ### [Worked model for a release pipeline](#worked-model-for-a-release-pipeline)Copy Anchor Link Assumptions: 12 services, each merging to `main` and releasing on average once a weekday, 22 weekdays a month, so 264 releases. Each build job holds the runner 6 minutes, each promote job 1 minute, and each verify job 4 minutes of waiting. Substitute your own counts from the workflow run history. | Line | Arithmetic | Monthly | | ------------ | -------------------- | ------- | | Build jobs | 264 x 6 min x $0.008 | $12.67 | | Promote jobs | 264 x 1 min x $0.004 | $1.06 | | Verify jobs | 264 x 4 min x $0.004 | $4.22 | | Total | $17.95 | | The build line is the one with a GitHub-hosted equivalent. Those 1,584 build minutes list at $0.012 per minute on the 4-core Linux larger runner, which is $19.01 ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). Stated as list price arithmetic: `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13. The verify line is worth reading twice. It buys nothing except confirmation, it grows with rollout duration rather than with build complexity, and it is the first line that inflates when someone raises a timeout after a flaky release. ## [Bottlenecks](#bottlenecks)Copy Anchor Link ### [The polling window sits inside billed runner time](#the-polling-window-sits-inside-billed-runner-time)Copy Anchor Link Argo CD polls every three minutes by default, so a verify job that starts the instant the promote job finishes can spend most of its life waiting for the controller to notice a commit it already has. Configure the git webhook to the Argo CD API server and that window collapses to the round trip ([webhook configuration](https://argo-cd.readthedocs.io/en/stable/operator-manual/webhook/)). Until then, the timeout on `argocd app wait` needs headroom for polling plus the rollout itself. ### [A commit made by GITHUB\_TOKEN starts nothing](#a-commit-made-by-github%5Ftoken-starts-nothing)Copy Anchor Link Events triggered by the repository's `GITHUB_TOKEN` do not create a new workflow run ([triggering a workflow from a workflow](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow)). Teams that keep manifests in the same repository and expect the promote commit to start a validation workflow find that nothing fires. A deploy key or GitHub App installation token restores that behavior, and it is also the credential a separate config repository requires. Treat it as a write path into production configuration and scope it to that one repository. ### [Concurrent promotions collide on one file](#concurrent-promotions-collide-on-one-file)Copy Anchor Link Twelve services pushing to one config repository will race. The rebase retry in the promote job above absorbs the common case. Splitting manifests so each service owns its own overlay file keeps the rebases textual rather than semantic, which is what stops a retry from silently reverting another service's release. ### [Healthy means whatever the health check says](#healthy-means-whatever-the-health-check-says)Copy Anchor Link Argo CD assesses health per resource kind and supports custom health checks for resources it does not understand natively ([resource health](https://argo-cd.readthedocs.io/en/stable/operator-manual/health/)). A custom resource with no health definition can report healthy the moment it is applied, which turns `argocd app wait` into a check that the manifest arrived. Sync phases and waves also extend the wait, because later waves start only after earlier ones report healthy ([sync phases and waves](https://argo-cd.readthedocs.io/en/stable/user-guide/sync-waves/)). Both are reasons the verify job's timeout is a deployment decision rather than a formality. ### [Floating tags quietly break the model](#floating-tags-quietly-break-the-model)Copy Anchor Link A manifest that references `api:latest` gives Argo CD nothing to diff, so the commit that was supposed to trigger a rollout changes no bytes in the repository and the controller has no work to do. Digest references are what make the commit meaningful. The alternative path, where a component watches the registry and writes the update itself rather than the pipeline doing it, is [Argo CD Image Updater](https://argocd-image-updater.readthedocs.io/en/stable/), and it moves the same decision out of GitHub Actions rather than removing it. ### [What a bigger runner does not fix](#what-a-bigger-runner-does-not-fix)Copy Anchor Link A manifest the API server rejects, a readiness probe pointed at the wrong port, or a project RBAC rule that denies the sync fails the same way on every runner size. Faster machines shorten the loop on those failures without preventing them. ## [Proof](#proof)Copy Anchor Link Public repositories running `warp-` labels show these job shapes in production and the workflow file is one click away. [kintsugi-tax/killbill-kintsugi-plugin](https://github.com/kintsugi-tax/killbill-kintsugi-plugin/blob/main/.github/workflows/release.yml) runs its release workflow on `warp-ubuntu-latest-x64-2x`, which is the small-runner shape the promote and verify jobs use here (checked on 2026-08-13). [triggerdotdev/trigger.dev](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml) runs its end-to-end matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x`, the build-sized row in the table above (checked on 2026-08-13). Both labels come from the catalog on the [cloud runners page](/docs/ci/cloud-runners). Every cost figure above carries its arithmetic, a source link, and a checked-on date, and the same per-minute rates appear on the [pricing page](/pricing). Argo CD behavior on this page is cited to the upstream documentation rather than restated from memory, so re-check those links before quoting the polling interval or the wait semantics internally. ## [FAQ](#faq)Copy Anchor Link **Does an Argo CD pipeline need cluster credentials in GitHub Actions?** No. With an automated sync policy the workflow pushes a manifest commit to the config repository and the Argo CD controller inside the cluster applies it, so the pipeline needs no direct access to the Argo CD API server and no kubeconfig. The credentials the workflow does hold are a registry credential for the image push and a git write token scoped to the config repository. **How long after the workflow pushes does Argo CD deploy the change?** Argo CD polls repositories every three minutes to detect manifest changes, so an unassisted push waits up to that long before the controller starts syncing. Configuring the API server to receive webhook events from the git provider removes the polling delay, which matters when a GitHub Actions job is billed per minute while it waits for the rollout. **What does an Argo CD release pipeline cost on WarpBuild runners?** The image build job runs on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, and the manifest commit and rollout verification jobs run on `warp-ubuntu-latest-x64-2x` at $0.004 per minute, billed per minute of runtime. --- # AWS CDK Pipelines on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/aws-cdk-github-actions Description: Run cdk synth and cdk diff on pull requests and cdk deploy on merge with warp- runners: OIDC role assumption, a cached toolchain, serialized asset publishing. # AWS CDK Pipelines on GitHub Actions Run cdk synth and cdk diff on pull requests and cdk deploy on merge with warp- runners: OIDC role assumption, a cached toolchain, serialized asset publishing. ## [Overview](#overview)Copy Anchor Link A CDK pipeline on GitHub Actions is two jobs against one cloud assembly: a synth job that runs `cdk synth` and `cdk diff` on every pull request, and a deploy job that runs on merge, publishes the assets the assembly references, and executes the CloudFormation change. Moving that pipeline to WarpBuild is a one-line `runs-on` change plus a cache step for the Node dependency tree the CDK app compiles from, because a CDK job splits its wall time between compiling TypeScript, uploading assets, and waiting on CloudFormation. Three properties of the CDK deployment model shape the workflow file. **Synth produces an artifact worth keeping.** `cdk synth` writes a cloud assembly to `cdk.out`: one CloudFormation template per stack plus an asset manifest per stack naming every file and container image the template refers to by content hash. `cdk diff --app cdk.out` and `cdk deploy --app cdk.out` both read that directory instead of re-running the app, which is what makes the merge deterministic. The template that was reviewed is the template that ships. **Assets publish before the stack updates.** A `lambda.Function` with bundled code, a `DockerImageAsset`, and a `BucketDeployment` source all become entries in the asset manifest. The CLI uploads file assets to the bootstrap staging bucket and pushes image assets to the bootstrap ECR repository, then creates and executes a change set that points at those object keys and image tags. Assets are addressed by hash, so an unchanged bundle is skipped on the next deploy and only changed artifacts move bytes. **Bootstrapping decides the permission model.** `cdk bootstrap` creates the `CDKToolkit` stack with a staging bucket, an ECR repository, and five IAM roles named with the `hnb659fds` qualifier by default: `file-publishing-role`, `image-publishing-role`, `deploy-role`, `lookup-role`, and `cfn-exec-role`. The role your workflow assumes needs `sts:AssumeRole` on those roles and almost nothing else, because the CLI assumes them itself and CloudFormation acts as `cfn-exec-role`. The full role list is in the [AWS CDK bootstrapping guide](https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html). CDK pipelines live on the Linux labels, and the rest of this page uses them. Per-size rates for every label are on the [pricing page](/pricing). ## [Configuration](#configuration)Copy Anchor Link This workflow synthesizes and diffs on pull requests, uploads the cloud assembly, then publishes assets and deploys on merge. Both jobs exchange the GitHub OIDC token for AWS credentials, and the deploy job is serialized on the stack it targets. Copy Text ``` name: cdk on: pull_request: paths: - "infra/**" push: branches: [main] paths: - "infra/**" env: AWS_REGION: us-east-1 jobs: synth: runs-on: warp-ubuntu-latest-x64-4x concurrency: group: cdk-synth-${{ github.ref }} cancel-in-progress: true permissions: contents: read id-token: write pull-requests: write defaults: run: working-directory: infra steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore the CDK toolchain cache uses: WarpBuilds/cache@v1 with: path: | /home/runner/.npm infra/.cdk.staging key: ${{ runner.os }}-cdk-${{ hashFiles('infra/package-lock.json') }} restore-keys: | ${{ runner.os }}-cdk- - run: npm ci - name: Exchange the GitHub OIDC token for AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/cdk-github-readonly aws-region: ${{ env.AWS_REGION }} - run: npx cdk synth --all - run: npx cdk diff --app cdk.out --all - uses: actions/upload-artifact@v4 with: name: cloud-assembly-${{ github.sha }} path: infra/cdk.out retention-days: 5 deploy: if: github.ref == 'refs/heads/main' needs: synth runs-on: warp-ubuntu-latest-x64-4x environment: production concurrency: group: cdk-deploy-production-AppStack cancel-in-progress: false permissions: contents: read id-token: write defaults: run: working-directory: infra steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - uses: actions/download-artifact@v4 with: name: cloud-assembly-${{ github.sha }} path: infra/cdk.out - name: Exchange the GitHub OIDC token for AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/cdk-github-deploy aws-region: ${{ env.AWS_REGION }} - name: Publish file and image assets run: npx cdk-assets publish -p cdk.out/AppStack.assets.json - name: Deploy the reviewed cloud assembly run: > npx cdk deploy --app cdk.out AppStack --require-approval never --progress events ``` Five details in that file earn their place. **The two jobs assume different IAM roles.** `cdk-github-readonly` needs `sts:AssumeRole` on the lookup role so `cdk diff` can read the deployed template and resolve context. `cdk-github-deploy` needs the publishing and deploy roles as well. Scope each trust policy on the OIDC `sub` claim: the read role on `repo:myorg/myrepo:pull_request`, the deploy role on `repo:myorg/myrepo:ref:refs/heads/main`. A pull request from a fork then cannot reach a role that mutates infrastructure even if the workflow file is edited in that branch. GitHub documents the claim shapes in its [OpenID Connect hardening guide](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect), and the exchange itself is covered in [OIDC token exchange](/glossary/oidc-token-exchange). **`--app cdk.out` appears on both diff and deploy.** Without it the CLI re-executes the app, re-resolves `fromLookup` context, and re-runs bundling. Any of those can produce a template that no longer matches the diff a reviewer approved. **The asset publish step is separate from the deploy step.** `cdk deploy` publishes anything outstanding on its own, so the explicit `cdk-assets publish` call is there to give the upload phase its own timing line in the job view and to fail before the change set is created. On a repository with large Lambda bundles or container images, that step is usually the longest one in the job. **The concurrency group is keyed on the stack rather than on the ref.** CloudFormation rejects an update against a stack already in `UPDATE_IN_PROGRESS`, and a group keyed on `github.ref` puts a `workflow_dispatch` run and a `main` push in different groups even though they target the same stack. `cancel-in-progress: false` queues the second run instead of leaving a half-applied change set. **`--require-approval never` is required for an unattended job.** The CLI otherwise blocks on any change that widens IAM permissions or security group rules and waits for a terminal answer that never arrives. The `environment: production` gate is where human approval belongs, and the wider version of that argument is in [deployment jobs on GitHub Actions](/guides/deployment-jobs-github-actions). Each WarpBuild runner is a fresh virtual machine with its own encrypted storage volume, created on demand and destroyed after the job, which is what keeps a deploy role's temporary credentials off any machine a later job can reach. The details are in the [security documentation](/docs/ci/security). ## [Sizing](#sizing)Copy Anchor Link CDK sizing differs from the usual infrastructure-as-code shape. `npm ci` and `cdk synth` compile the app and walk the construct tree, so the synth job is CPU bound and rewards cores, while the deploy job waits on uploads and on CloudFormation. Shapes below come from the [cloud runners documentation](/docs/ci/cloud-runners) and rates from the [pricing page](/pricing). | Runner label | vCPU | Memory | Storage | Price per minute | | --------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 | | warp-ubuntu-latest-arm64-8x | 8 | 32GB | 150GB SSD | $0.012 | **warp-ubuntu-latest-x64-4x** at $0.008 per minute is the default for the synth job. Four cores cover a TypeScript compile, `esbuild` bundling for several `NodejsFunction` constructs, and a construct tree of a few thousand nodes. **warp-ubuntu-latest-x64-8x** at $0.016 per minute pays for itself when the app builds container images inline or synthesizes twenty or more stacks in one pass. **warp-ubuntu-latest-x64-2x** at $0.004 per minute is enough for a deploy job that only uploads a small bundle and polls CloudFormation. Splitting the labels by job is worth doing when the deploy job routinely runs longer than ten minutes. ARM64 lowers the rate at every size, and `esbuild` bundling produces the same JavaScript either way. One prerequisite: a `DockerImageAsset` built on an ARM64 runner produces an ARM64 image, so a Lambda function or ECS task definition configured for `x86_64` fails after a label switch unless the build targets the platform explicitly. ### [Worked cost model and asset upload](#worked-cost-model-and-asset-upload)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the 4-core Linux larger runner is $0.012 per minute and the standard `ubuntu-latest` runner for private repositories is $0.006 per minute at 2 vCPU. Take a platform team with 18 CDK stacks running 2,600 jobs a month at an average of 6 minutes each. That is 15,600 runner minutes. | Scenario | Rate | Monthly minutes | Monthly cost | | ---------------------------------------- | ---------- | --------------- | ------------ | | GitHub-hosted 4-core Linux larger runner | $0.012/min | 15,600 | $187.20 | | warp-ubuntu-latest-x64-4x | $0.008/min | 15,600 | $124.80 | | warp-ubuntu-latest-arm64-4x | $0.006/min | 15,600 | $93.60 | | Toolchain cache storage and operations | see below | n/a | $1.40 | The per-minute arithmetic at the 4 vCPU size: $0.012 minus $0.008 is $0.004, and $0.004 divided by $0.012 is 33 percent lower list price (GitHub pricing, checked on 2026-08-13). Cache metering is small enough to state exactly: storage is $0.20 per GB-month and each write or restore operation is $0.0001, so a 2.5GB toolchain cache with 9,000 operations a month adds $1.40\. Every rate here carries its source and the date it was checked, and the same numbers appear on the [pricing page](/pricing). Asset publishing carries two separate costs. The upload minutes are billed at the runner rate above, and a larger label does not shorten them because the step is bandwidth bound. The published objects then sit in the bootstrap staging bucket and the bootstrap ECR repository in your own AWS account and accrue storage charges there until they are removed, which is what `cdk gc` and an ECR lifecycle policy exist for. A repository deploying twice a day with a 90MB image asset writes roughly 5GB of new image layers a month into ECR. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Cold dependency installs.** `npm ci` in a CDK app pulls `aws-cdk-lib`, `constructs`, the CLI, and the bundler on every fresh machine. Restoring the npm cache keyed on `package-lock.json` is the single largest fixed saving in the pipeline and the reason the cache step exists. **Unresolved context.** `Vpc.fromLookup` and its siblings call AWS at synth time through the lookup role and write the answers to `cdk.context.json`. When that file is absent from the repository, every synth pays for the lookups, and a job without lookup credentials synthesizes dummy values that produce a template different from the deployed one. Commit `cdk.context.json` and refresh it with `cdk context --clear` when the underlying resources move. **Docker image assets built inline.** Building an image on the runner puts a full container build inside a job sized for a TypeScript compile. WarpBuild remote Docker builders take that build off the runner and keep a persistent layer cache across jobs, which suits a repository whose CDK app ships several image assets. **Rollbacks holding a job open.** A failed update enters `UPDATE_ROLLBACK_IN_PROGRESS` and the CLI keeps polling until the stack settles, which can take as long as the deploy did. Sizing up changes nothing there, so the lever is the label rate rather than the label size. **Wide synth matrices at review time.** A monorepo that previews 18 stacks on one pull request wants 18 jobs at once. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts dynamically, so matrix width stays a sizing decision rather than a quota question. **Opaque failures.** When a synth job slows down and the cause is unclear, WarpBuild's CI observability correlates system metrics from the runner agent with GitHub Actions job logs, which separates a job waiting on an upload from one that ran out of memory during bundling. The Action Debugger pauses a workflow and opens an SSH session on the runner, so you can inspect `cdk.out`, the asset manifest, and role assumption on the machine itself. ## [Proof](#proof)Copy Anchor Link Public OSS repositories running `warp-` labels are citable evidence, and you can read the `runs-on` line yourself instead of taking a claim on trust. The Trigger.dev end-to-end suite runs its matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x` in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml), checked on 2026-08-13\. That is the same 4x Linux label the synth job above uses. The cheapest check is your own app. [Signup](https://app.warpbuild.com/) includes $10 free credits, which covers 1,250 minutes on `warp-ubuntu-latest-x64-4x`. Point the synth job at a `warp-` label, leave the deploy job where it is, and compare the two timing views for the same commit against the same cloud assembly. The second run is the one worth reading, because the toolchain cache is warm by then. Teams that want the deploy job inside their own AWS account rather than on hosted runners can run the same workflow on [BYOC runners on AWS](/byoc/aws), and teams comparing the CDK pattern against a saved plan file will find the parallel in [Terraform pipelines on GitHub Actions](/solutions/terraform-github-actions). ## [FAQ](#faq)Copy Anchor Link **How does a GitHub Actions job get credentials for cdk deploy without an access key?** Grant the job `id-token: write`, assume an IAM role with `aws-actions/configure-aws-credentials@v4`, and give that role `sts:AssumeRole` on the `cdk-hnb659fds-*` bootstrap roles in the target account. The CDK CLI assumes the file publishing, image publishing, and deploy roles itself from those credentials, so no long-lived key is stored in the repository. **Why should cdk deploy run with --app cdk.out?** Pointing the CLI at the cloud assembly the synth job uploaded means the deploy applies the exact template that was reviewed on the pull request. Re-running the app at deploy time re-resolves context lookups and re-runs bundling, which can produce a template that differs from the one in the diff. **What runner size does a CDK app need?** Synth compiles the app and walks the construct tree, so it is CPU bound and `warp-ubuntu-latest-x64-4x` at $0.008 per minute is the usual default. Deploy is bound by asset upload bandwidth and CloudFormation convergence, so a larger label there buys little unless the app builds Docker image assets. --- # Running Bazel on GitHub Actions with Remote Cache | WarpBuild URL: https://www.warpbuild.com/solutions/bazel-github-actions Description: Set --remote_cache in a checked-in bazelrc, pin the repository cache and output base on the runner, and keep both warm on WarpBuild snapshot runners. # Running Bazel on GitHub Actions with Remote Cache Set --remote\_cache in a checked-in bazelrc, pin the repository cache and output base on the runner, and keep both warm on WarpBuild snapshot runners. Last verified: 2026-08-13 Point Bazel at a remote cache from GitHub Actions by setting `--remote_cache` in a checked-in rc file that the workflow selects with `--config=ci`, then pin the repository cache and the output base to paths that live on the runner. On WarpBuild the same job runs on a `warp-ubuntu-latest-x64-16x` label and can boot from a snapshot that already holds the output base and the repository cache, so the remote cache only has to serve action results. Those two mechanisms solve different problems, and teams usually ship one and wonder why the build is still slow. This page covers the rc file, the workflow, how the remote cache and runner-local state overlap, how to map `--jobs` to a runner size, and what the whole thing costs per month. ## [Overview](#overview)Copy Anchor Link A Bazel build on a fresh GitHub Actions runner pays for four things before it compiles anything: fetching external repositories, loading and analyzing the build graph, materializing the execution root, and executing actions. A remote cache addresses exactly one of those four. Understanding which one keeps you from buying the wrong fix. Bazel keeps four distinct pieces of state, and only one of them has a network form. | Layer | Flag | What it holds | Shared across machines | | ---------------- | --------------------- | ---------------------------------------------------------------- | ---------------------- | | Repository cache | \--repository\_cache | External archives keyed by SHA-256 | No, local disk only | | Disk cache | \--disk\_cache | Action results and output blobs | No, local disk only | | Remote cache | \--remote\_cache | The same action results and output blobs | Yes | | Output base | \--output\_user\_root | Analysis cache, action graph, symlink forest, persistent workers | No, local disk only | The disk cache and the remote cache are the overlap. Both store the same two things: an action cache entry keyed by the action key (command line, input digests, environment, execution platform) and the content-addressed blobs that entry points at. When both flags are set, Bazel treats the disk cache as a local tier in front of the remote cache. It checks disk, then the remote endpoint, and on a remote hit it writes the result back to disk so the next action in the same build reads it locally. A hit in either tier means the action never runs. The repository cache and the output base have no remote equivalent. External archives downloaded by `http_archive`, a Maven resolver, or an npm repository rule land in the repository cache on disk and stay there. The output base holds the analysis cache: the loaded packages, the configured targets, and the action graph. On a runner that has never seen this workspace, Bazel reloads and reanalyzes everything even when every single action then resolves to a remote cache hit. On a large monorepo that pass is measured in tens of seconds to minutes and it does not shrink as your remote cache hit rate improves. That is the gap. The remote cache removes action execution. [Snapshot runners](/runners/snapshot) remove repository fetching and analysis, because they carry the whole runner disk forward, output base included. A Bazel workspace that builds Linux servers and an iOS app can therefore point both jobs at `warp-` labels. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, and snapshot labels on any other runner type are silently ignored and the job runs normally. Plan the macOS half of a Bazel build around the remote cache and a pinned repository cache. One more layout decision matters before the configuration. Bazel's remote cache is chatty and latency sensitive, so the distance between the runner and the cache endpoint shows up in wall clock time. BYOC runs on AWS, GCP, and Azure if you would rather keep the runners in the same account and region as the cache bucket. Terraform support exists for BYOC on AWS. ## [Configuration](#configuration)Copy Anchor Link Keep the rc file in the repository so a developer can reproduce a cache hit locally, and keep the runner-specific absolute paths in a second rc file that only the workflow loads. That split avoids hardcoding `/home/runner` into the file your team uses on laptops. The checked-in workspace `.bazelrc` holds the hermeticity settings that decide whether two machines agree on an action key at all. .bazelrc Copy Text ``` common --enable_bzlmod build --incompatible_strict_action_env build --experimental_repository_cache_hardlinks build --remote_cache_compression build --remote_timeout=600 build --keep_going test --test_output=errors ``` `--incompatible_strict_action_env` is the flag that decides your cache hit rate. Without it Bazel passes the client `PATH` into action environments, so an action key computed on a runner with a different `PATH` will never match one computed on a laptop. `--remote_cache_compression` is the Bazel 7 and later spelling; older releases call it `--experimental_remote_cache_compression`. The runner rc file carries the absolute paths and the endpoint. Load it with `bazel --bazelrc=ci/.bazelrc build`, which is why the startup flag works from here. ci/.bazelrc Copy Text ``` startup --output_user_root=/home/runner/.cache/bazel build:ci --repository_cache=/home/runner/.cache/bazel-repo build:ci --disk_cache=/home/runner/.cache/bazel-disk build:ci --remote_cache=grpcs://bazel-cache.internal.example.com:443 build:ci --remote_instance_name=main build:ci --remote_upload_local_results=true build:ci --remote_download_outputs=toplevel build:ci --announce_rc build:ci --show_timestamps build:ci --color=no build:ci --curses=no build:ci-pr --config=ci build:ci-pr --noremote_upload_local_results ``` Three choices in that file are worth stating out loud. `--output_user_root` is pinned because Bazel otherwise derives the output base from a platform default, and on macOS that default sits under `/var/tmp`. Snapshot runners do not persist `/tmp`, which is cleaned on reboot, and booting from a snapshot is a reboot. Pinning the output base under the runner home puts the analysis cache inside the snapshot. `--remote_download_outputs=toplevel` keeps intermediate outputs in the remote content store instead of writing every one of them to the runner disk. That cuts download bytes on a warm cache and it also means less of the build lands in the snapshot, so read the tradeoff before turning it on for a workspace whose test steps consume intermediate artifacts. Use `--remote_download_outputs=all` on the job that produces release artifacts. Pull request jobs read the cache and do not write to it. A fork or an unreviewed branch that can write action results into the shared cache is a supply chain problem, and `--noremote_upload_local_results` closes it while still giving pull requests full read hits from `main`. ### [The workflow](#the-workflow)Copy Anchor Link This is the whole job. The `main` branch boots from the base image and writes a fresh snapshot at the end; every other branch boots from that snapshot. Copy Text ``` name: bazel on: push: branches: [main] pull_request: concurrency: group: bazel-${{ github.ref }} cancel-in-progress: true jobs: build-and-test: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-16x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-16x;snapshot.key=bazel-main' }} steps: - uses: actions/checkout@v5 - name: Report snapshot state run: echo "booted from snapshot ${WARPBUILD_SNAPSHOT_KEY:-none}" - name: Build env: CACHE_TOKEN: ${{ secrets.BAZEL_CACHE_TOKEN }} run: | bazel --bazelrc=ci/.bazelrc build //... \ --config=${{ github.ref == 'refs/heads/main' && 'ci' || 'ci-pr' }} \ --remote_header=x-api-key=${CACHE_TOKEN} \ --jobs=16 - name: Test env: CACHE_TOKEN: ${{ secrets.BAZEL_CACHE_TOKEN }} run: | bazel --bazelrc=ci/.bazelrc test //... \ --config=${{ github.ref == 'refs/heads/main' && 'ci' || 'ci-pr' }} \ --remote_header=x-api-key=${CACHE_TOKEN} \ --jobs=16 - name: Trim the disk cache before snapshotting if: github.ref == 'refs/heads/main' run: | bazel --bazelrc=ci/.bazelrc shutdown du -sh /home/runner/.cache/bazel-disk find /home/runner/.cache/bazel-disk -type f -atime +3 -delete - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "bazel-main" fail-on-error: true wait-timeout-minutes: 60 ``` The token arrives through `--remote_header` on the command line rather than through the rc file, because anything written to disk before `snapshot-save` runs ends up inside the snapshot. The cleanup step exists for the same reason. WarpBuild provisions runners at the organization level and GitHub may hand a snapshot-backed runner to a different job in the organization, so remove credentials before capturing. Two details from the [snapshot runner docs](/docs/ci/features/snapshot-runners) shape the branch logic above. `snapshot.enabled=true` always boots from the base image and lets you capture a fresh snapshot, while `snapshot.key=` boots from the existing snapshot for that alias and falls back to the base image when none exists. Snapshots are deleted after 15 days, so the `main` job doubles as the refresher. If `main` is quiet, add a scheduled trigger so the alias never expires. Drive the snapshot lifecycle from your own tooling if a scheduled workflow is the wrong place for it. The [WarpBuild cache](/docs/ci/features/caching) is a separate mechanism from the Bazel remote cache and the two do not conflict. Use `WarpBuilds/cache@v1` for the things Bazel does not manage, such as a toolchain tarball or a container image layer, and leave the Bazel disk cache to the snapshot. The general pattern behind that split is written up in [persistent caches on GitHub Actions runners](/guides/persistent-caches-github-actions). ## [Sizing](#sizing)Copy Anchor Link Bazel is a graph scheduler, so runner sizing is a question about the width of your action graph rather than the size of your repository. `--jobs` sets how many actions Bazel runs at once. Its default is the host CPU count, which is a reasonable floor and a bad ceiling once the remote cache is warm. The rule that holds across workspaces: on a cold cache, every action slot is doing compiler work, so `--jobs` should equal the vCPU count. On a warm cache, most action slots are waiting on a network download from the remote content store, so `--jobs` can run one and a half to two times the vCPU count without starving anything. | Label | vCPU | RAM | Storage | Cold cache \--jobs | Warm cache \--jobs | USD per minute | | ---------------------------- | ---- | ------ | --------- | ------------------ | ------------------ | -------------- | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | 8 | 12 to 16 | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | 16 | 24 to 32 | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | 32 | 48 to 64 | $0.064 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | 150GB SSD | 8 | 12 to 16 | $0.012 | | warp-ubuntu-latest-arm64-16x | 16 | 64 GB | 150GB SSD | 16 | 24 to 32 | $0.024 | | warp-ubuntu-latest-arm64-32x | 32 | 128 GB | 150GB SSD | 32 | 48 to 64 | $0.048 | Every Ubuntu size carries 4 GB of RAM per vCPU, which is the number that decides whether `--jobs` at the vCPU count is safe. A C++ or Rust link step can hold several gigabytes on its own, so a graph with many concurrent links will hit memory before it hits CPU. Cap the estimate explicitly rather than letting Bazel guess: Copy Text ``` build:ci --local_ram_resources=HOST_RAM*0.7 build:ci --local_cpu_resources=HOST_CPUS ``` JVM language rules add a second consumer. Persistent workers for Java, Kotlin, and Scala each hold a JVM heap for the life of the Bazel server, on top of the Bazel server's own heap. On a 16 vCPU runner with 64 GB, four workers at 8 GB each plus a 8 GB server heap leaves roughly 24 GB for actions, which is enough for `--jobs=16`. ci/.bazelrc (JVM rules) Copy Text ``` build:ci --worker_max_instances=4 startup --host_jvm_args=-Xmx8g ``` A Gradle build in the same repository has the same daemon and heap question with different flag names; [running Gradle builds on GitHub Actions](/solutions/gradle-github-actions) covers that side. Storage is the other constraint. Ubuntu runners carry a 150GB SSD. A snapshot-backed runner accumulates disk cache entries across every run that boots from the alias, and the disk cache has no automatic bound in older Bazel releases. The `find` prune in the workflow above keeps it stable; recent Bazel releases also expose `--experimental_disk_cache_gc_max_size` if you prefer Bazel to manage it. Concurrency sizing is the easy part. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. A monorepo that fans out to forty target-shard jobs on a busy afternoon runs them together instead of queueing behind itself. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Cache misses that should have been hits.** Run the same commit twice on two different runners and compare the process summary line. If the second run shows local executions where you expected remote hits, the action keys disagree. The usual causes are an unpinned `PATH` (fixed by `--incompatible_strict_action_env`), an `--action_env` entry carrying a timestamp or a commit SHA, a toolchain resolved from the host instead of a registered hermetic toolchain, and a mismatch of execution platform between an x64 runner and an ARM64 runner. Cache entries do not cross architectures, so a `warp-ubuntu-latest-arm64-16x` job and a `warp-ubuntu-latest-x64-16x` job maintain separate key spaces by design. **Analysis time on a cold runner.** This is the cost the remote cache cannot touch. Read it directly with `--profile` and `bazel analyze-profile`, which reports the loading and analysis phases separately from execution. If those phases add up to more than a minute, a snapshot alias is the fix. **Snapshot boot overhead.** Boot times for snapshot runners can be slower than the default runners and take 45 to 60 seconds. Snapshot restore is billed at $0.04 per job. Both are fixed costs you pay before Bazel starts, so the snapshot only pays off when it removes more work than that. On a 16x runner at $0.032 per minute, the $0.04 restore fee equals 75 seconds of runner time; on an 8x at $0.016 per minute it equals 150 seconds; on a 32x at $0.064 per minute it equals about 38 seconds. Compare that number against the loading plus analysis plus fetch time from your profile before enabling the alias everywhere. **Repository fetch churn.** A workspace that resolves external dependencies over the network every run will show a long fetch phase even with a warm remote cache. Pin `--repository_cache` so the archives survive, and pin every `http_archive` with a `sha256` so the cache can actually key on them. An unpinned archive is refetched every time. **Remote cache throughput.** Large outputs move slowly over a long link. `--remote_cache_compression` and `--remote_download_outputs=toplevel` both cut bytes on the wire. Placing the runner near the cache cuts latency, which matters more than bandwidth for a graph with tens of thousands of small actions. BYOC runs on AWS, GCP, and Azure if the cache lives in your own account. **Blind spots during the build.** CI observability reports system metrics from the runner agent correlated with the job logs, which tells you whether a 20 minute build was CPU bound, memory bound, or waiting on the network. That distinction decides whether the answer is a bigger label or a lower `--jobs`. When a cache miss only reproduces on the runner, the Action Debugger pauses the workflow and opens an SSH session on the machine so you can run `bazel aquery` against the real execution root. ## [Proof](#proof)Copy Anchor Link Bazel reports its own cache behavior, so verification takes one line of output rather than a stopwatch. Every build ends with a process summary: Copy Text ``` INFO: 4128 processes: 3611 remote cache hit, 214 internal, 303 linux-sandbox. ``` The ratio of remote cache hits to executed processes is the number to track over time. Push it into a metric and watch it after every toolchain bump, because a toolchain change that breaks hermeticity shows up here days before anyone notices the build got slower. Force a full rebuild with `--noremote_accept_cached` when you want to confirm that uploads are landing, and read the loading and analysis phases from `bazel analyze-profile` to see what the snapshot is saving you. ### [A worked monthly cost model](#a-worked-monthly-cost-model)Copy Anchor Link Take a Bazel monorepo running 1,200 GitHub Actions builds per month across pull requests and `main`, at a median of 11 minutes of wall clock each on 16 vCPU. That is 13,200 runner minutes. | Line item | Rate | Quantity | Monthly USD | | ---------------------------------- | ------------------------ | ----------------------- | ----------- | | warp-ubuntu-latest-x64-16x minutes | $0.032 per minute | 13,200 minutes | $422.40 | | Snapshot restore | $0.04 per job | 1,200 jobs | $48.00 | | Snapshot storage | $0.025 per snapshot-hour | 2 aliases for 730 hours | $36.50 | | Cache storage | $0.20 per GB-month | 30 GB | $6.00 | | Cache write and restore operations | $0.0001 per operation | 2,400 operations | $0.24 | | **Total** | **$513.14** | | | The same 13,200 minutes on a GitHub-hosted 16-core Linux x64 larger runner costs 13,200 multiplied by $0.042, which is $554.40, and GitHub-hosted runners have no snapshot equivalent to fold in. GitHub list prices for Linux x64 larger runners were checked on 2026-08-13 at [github.com/pricing](https://github.com/pricing) and in the [GitHub Actions billing documentation](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions). The full size ladder is broken down in [what larger GitHub Actions runners cost](/guides/github-actions-larger-runners-cost). | vCPU | WarpBuild label | WarpBuild USD per minute | GitHub-hosted Linux x64 USD per minute | Difference per minute | Lower list price | | ---- | -------------------------- | ------------------------ | -------------------------------------- | --------------------- | ---------------- | | 8 | warp-ubuntu-latest-x64-8x | $0.016 | $0.022 | $0.006 | 27 percent | | 16 | warp-ubuntu-latest-x64-16x | $0.032 | $0.042 | $0.010 | 24 percent | | 32 | warp-ubuntu-latest-x64-32x | $0.064 | $0.082 | $0.018 | 22 percent | Those rows hold minutes constant on both sides. The snapshot line items pay for themselves through shorter jobs. If your profile shows 90 seconds of loading, analysis, and repository fetch on a cold runner, and the snapshot removes it, 1,200 jobs times 1.5 minutes is 1,800 minutes, which at $0.032 per minute is $57.60 of runner time against $84.50 of snapshot restore and storage. Move the workspace to a 32x label, where minutes cost more and the fixed restore fee stays flat, and the same 1,800 minutes are worth $115.20 against the same $84.50\. Sizing and snapshot economics move together, which is why the sizing table above is worth revisiting after any large change to the action graph. See the [full per-minute rate list on the pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link ### [Do I still need a remote cache if I use snapshot runners?](#do-i-still-need-a-remote-cache-if-i-use-snapshot-runners)Copy Anchor Link Yes. The remote cache shares action results across every runner, every branch, and every developer machine, so a job that never ran before can still skip execution. A snapshot carries one runner's local disk forward, which removes analysis and repository fetch work but gives you nothing another machine already computed. Run both. ### [Which Bazel directories survive a snapshot?](#which-bazel-directories-survive-a-snapshot)Copy Anchor Link Everything on disk except `/tmp`, which is cleaned on reboot and a snapshot boot is a reboot. Pin the output base with the startup flag `--output_user_root` and the repository cache with `--repository_cache` to paths under the runner home so both land inside the snapshot. ### [Can I use snapshot runners for macOS Bazel builds?](#can-i-use-snapshot-runners-for-macos-bazel-builds)Copy Anchor Link No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. Snapshot labels on macOS, Windows, or BYOC runners are silently ignored and the job runs normally without snapshot behavior, so rely on the remote cache and a pinned repository cache there. ### [What \--jobs value should I use on a 16 vCPU runner?](#what---jobs-value-should-i-use-on-a-16-vcpu-runner)Copy Anchor Link Start at `--jobs=16` so every compile action gets a full core. Once the remote cache is warm and most actions resolve to downloads instead of compiles, raise it to 24 or 32 to keep the network busy while the CPUs wait. --- # Bun Builds and Tests on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/bun-github-actions Description: Bun jobs on GitHub Actions stall on a cold install cache. Cache ~/.bun/install/cache with WarpBuilds/cache keyed on bun.lock and size warp- runners for tests. # Bun Builds and Tests on GitHub Actions Bun jobs on GitHub Actions stall on a cold install cache. Cache \~/.bun/install/cache with WarpBuilds/cache keyed on bun.lock and size warp- runners for tests. Last verified: 2026-08-13 Bun builds on GitHub Actions are fast once the install cache is warm and slow every time it is cold, because each job starts on a fresh virtual machine with an empty `~/.bun/install/cache`. Restore that directory with `WarpBuilds/cache@v1` keyed on `bun.lock`, run `bun install --frozen-lockfile`, and put the job on a `warp-` runner sized for what `bun test` and `bun build` actually use. This page covers the workflow configuration for both installation paths, the sizing call between 2, 4, and 8 vCPU runners with the list-price arithmetic, and the three bottlenecks that dominate Bun pipelines. ## [Overview](#overview)Copy Anchor Link Bun keeps every downloaded package in a global install cache, at `~/.bun/install/cache` by default. `bun install` resolves the lockfile against that cache and links packages into `node_modules` rather than refetching them, which is why a second local install finishes almost immediately and a first install on a clean machine does not. A GitHub Actions job is always a clean machine. Nothing from the previous run survives, so the global cache is empty at the start of every job and `bun install` fetches the full dependency set from the registry before the first test runs. Restoring that one directory between jobs is the single highest-value change to a Bun workflow. There is no WarpBuild fork of a Bun setup action, so the install cache is handled explicitly. Two paths work: * Install Bun with the upstream `oven-sh/setup-bun` action and cache `~/.bun/install/cache` with [WarpBuilds/cache@v1](https://github.com/WarpBuilds/cache), which is a drop-in replacement for `actions/cache@v4` and stores entries in WarpBuild's cache backend. * Install Bun through [WarpBuilds/mise-action@v2](https://github.com/WarpBuilds/mise-action) when the repository already pins its runtimes in `mise.toml`. That action caches the tools mise installs, so the Bun binary itself comes back warm. The two paths combine. mise brings back the runtime, `WarpBuilds/cache` brings back the packages, and neither one covers the other. Bun work belongs on the Linux sizes, and moving a job onto them is a one-line change to `runs-on`. One limit from the [caching documentation](/docs/ci/features/caching): WarpBuild Cache is not supported on Windows runners, so keep Bun cache steps on Linux jobs. ## [Configuration](#configuration)Copy Anchor Link The workflow below installs Bun, restores the global install cache keyed on `bun.lock`, and runs typecheck, build, and test on a 4 vCPU WarpBuild runner. Copy Text ``` name: bun-ci on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Restore Bun install cache uses: WarpBuilds/cache@v1 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} restore-keys: | ${{ runner.os }}-bun- - run: bun install --frozen-lockfile - run: bun run typecheck - run: bun run build - run: bun test ``` Four details in that file carry the behavior. `path` points at the global install cache rather than `node_modules`. A restored `node_modules` is tied to the exact dependency tree and platform that produced it, while the install cache is keyed by package and version, so `bun install` decides what to link out of it on every run. `key` hashes `bun.lock`, so the entry rolls exactly when the dependency set changes. Repositories still on the binary lockfile hash `**/bun.lockb` instead. Hashing `package.json` is the common mistake: a version bump in a script field then throws away a perfectly good cache. `restore-keys` is what keeps dependency-update pull requests cheap. When the exact key misses, the action restores the most recent entry with the same prefix, and the install fetches only the packages that changed rather than the whole tree. `--frozen-lockfile` makes the install fail instead of silently rewriting `bun.lock`, which keeps the cache key honest and keeps the build reproducible. Three behaviors from the [caching documentation](/docs/ci/features/caching) shape how the entries age. The cache is scoped to key, version, and branch, so an entry saved on a feature branch is separate from the one on `main`; seed from `main` and let branch builds pick it up through `restore-keys`. The version part is a hash over the compression tool and the list of cached paths, which is why a cache written on a macOS runner cannot restore on a Linux runner. Entries expire 7 days after last use, so active repositories stay warm and abandoned branches stop costing storage without any cleanup job. For repositories that pin the Bun version through mise, swap the setup step and keep the cache step: Copy Text ``` - uses: WarpBuilds/mise-action@v2 with: version: 2024.10.0 cache: true - name: Restore Bun install cache uses: WarpBuilds/cache@v1 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} restore-keys: | ${{ runner.os }}-bun- ``` With `mise.toml` holding the pin: Copy Text ``` [tools] bun = "1.2" ``` The full input list for every WarpBuild setup action is in the [setup actions documentation](/docs/ci/features/caching/setup-actions). Cache usage is metered, and the rates are small enough to state exactly: storage is $0.20 per GB-month, and each write or restore operation is $0.0001\. Cache storage and operations are free on BYOC runners. ## [Sizing](#sizing)Copy Anchor Link Bun repositories run well on the smaller Linux sizes. These are the rows that matter: | Runner label | OS | vCPU | Memory | Storage | Price per minute | | --------------------------- | ------------ | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16GB | 150GB SSD | $0.006 | Size each phase for what it can use. **Install** is network and disk work. With a warm cache it is mostly linking, and cores past 2 change little. If install is still the long pole, the fix is the cache configuration above rather than a bigger machine. **Typecheck** runs `tsc` on a single core, so vCPU count does nothing for it. Memory is the constraint that bites on a large program: 8GB on 2x covers most projects, and a heap failure is the signal to move that job to 4x. **Bundle** with `bun build` walks the module graph and writes outputs. The graph walk does not scale linearly with cores, so 4x is the sensible ceiling for most bundles. Watch memory instead, especially when the build emits source maps for a large entrypoint or produces a standalone executable with `bun build --compile`. **Tests** are the phase where sizing gets misread. `bun test` runs its files in one process instead of spawning a worker per core, so an 8 vCPU machine does not shorten a single test job by itself. Extra cores pay off when the suite starts real dependencies, a Postgres container, a browser, or several server processes, and when the suite is split across a matrix so several jobs run at once. Sharding is covered under bottlenecks below. The ARM64 row is worth a run if the application also ships on ARM. Bun publishes native ARM64 builds, and `warp-ubuntu-latest-arm64-4x` costs $0.006 per minute against $0.008 for the x64 machine of the same shape. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners at [github.com/pricing](https://github.com/pricing) and in the [minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, standard `ubuntu-latest` for private repositories (2 vCPU) is $0.006 per minute, and the Linux larger runners are $0.012 for 4 vCPU and $0.022 for 8 vCPU. | Machine | vCPU | Per-minute rate | Source | | --------------------------------- | ---- | --------------- | ------------------------------------- | | GitHub-hosted standard Linux | 2 | $0.006 | GitHub list price, checked 2026-08-13 | | warp-ubuntu-latest-x64-2x | 2 | $0.004 | WarpBuild pricing | | GitHub-hosted Linux larger runner | 4 | $0.012 | GitHub list price, checked 2026-08-13 | | warp-ubuntu-latest-x64-4x | 4 | $0.008 | WarpBuild pricing | | GitHub-hosted Linux larger runner | 8 | $0.022 | GitHub list price, checked 2026-08-13 | | warp-ubuntu-latest-x64-8x | 8 | $0.016 | WarpBuild pricing | Stated as list-price arithmetic: warp-ubuntu-latest-x64-2x (2 vCPU, 8 GB) costs $0.004 per minute against $0.006 per minute for GitHub-hosted ubuntu-latest (2 vCPU, 8 GB on private repositories): 33 percent lower list price. warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. At 8 vCPU the pair is $0.016 against $0.022: 27 percent lower list price. GitHub list prices checked on 2026-08-13. Take a concrete repository: 1,500 jobs a month at 4 minutes each on a 4 vCPU machine with a warm install cache, which is 6,000 runner minutes. | Scenario | Rate | Monthly minutes | Monthly cost | | ----------------------------------------------- | ---------- | --------------- | ------------ | | GitHub-hosted Linux larger runner, 4 vCPU | $0.012/min | 6,000 | $72.00 | | warp-ubuntu-latest-x64-4x, 4 vCPU | $0.008/min | 6,000 | $48.00 | | Cache storage and operations (800MB, 3,000 ops) | see above | n/a | $0.46 | Cache metering adds $0.16 for storage and $0.30 for operations, so the WarpBuild column lands at $48.46\. Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Three problems account for most slow Bun pipelines on GitHub Actions. **A cold install cache.** Without a restored `~/.bun/install/cache`, every job downloads the full dependency tree before anything else happens, and the cost scales with the size of the lockfile. Verify the fix from the job log: a warm run shows the cache step restoring an entry and `bun install` finishing without a long resolution phase. A cache that always misses usually means the key hashes a file that changes on every commit, or that the entry was only ever written on a feature branch and never on `main`. The same question across every JavaScript package manager is answered in [how do I cache node modules in GitHub Actions](/answers/how-do-i-cache-node-modules-in-github-actions). **Native module builds.** Packages with native addons, the `node-gyp` and node-api family, install a prebuilt binary when one exists for the platform and Bun version and compile from source when it does not. Bun blocks lifecycle scripts by default and runs them only for packages listed in `trustedDependencies` in `package.json`, so the compile happens for exactly the packages you have trusted. Keep that list short and pin the Bun version in the workflow, because a runtime bump changes the ABI and sends every trusted package back through a source build. When a compile is unavoidable, it uses multiple cores, which is one reason install-heavy repositories with native dependencies run better on 4x than on 2x. **Workspace-wide test runs that never shard.** In a Bun workspace, `bun test` from the root walks every package and runs the whole suite in one job, so wall time is the sum of every package's tests no matter how large the runner. Split the run across a matrix by path, and let every shard restore the same install cache: Copy Text ``` test: runs-on: warp-ubuntu-latest-x64-2x strategy: matrix: package: [api, web, worker] steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 - uses: WarpBuilds/cache@v1 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} restore-keys: | ${{ runner.os }}-bun- - run: bun install --frozen-lockfile - run: bun test packages/${{ matrix.package }} ``` Three shards on 2x spend roughly the same runner minutes as one job doing all the work, while wall time drops to the slowest package. Total minutes rise a little because each shard repeats checkout and install, which is one more reason the warm cache matters. When a job is slow and the cause is unclear, WarpBuild's CI observability reports system metrics from the runner agent alongside GitHub Actions job logs, which separates a CPU-bound test phase from an install stuck on the network. For a closer look, the Action Debugger pauses a workflow and opens a session on the runner, so you can inspect `~/.bun/install/cache` on the machine itself and confirm what the restore actually wrote. ## [Proof](#proof)Copy Anchor Link Public OSS repositories running `warp-` labels are citable evidence, and the workflow files are open to read. The Trigger.dev end-to-end suite runs its matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x` in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml) (checked on 2026-08-13). If the repository mixes Bun with other JavaScript package managers across a monorepo, the [Node.js builds on GitHub Actions](/solutions/nodejs-github-actions) page covers the store-caching decisions for the rest of the tree, and [pnpm store caching on GitHub Actions](/solutions/pnpm-github-actions) covers the equivalent key and path for a pnpm workspace. ## [FAQ](#faq)Copy Anchor Link **What should a Bun cache key hash?** The lockfile. Hash `bun.lock` on Bun 1.2 and later, or `bun.lockb` on repositories still using the binary lockfile. Add a `restore-keys` prefix so a dependency bump restores the previous cache instead of starting from an empty install cache. **Is there a WarpBuilds/setup-bun action?** No. Install Bun with the upstream `oven-sh/setup-bun` action or through `WarpBuilds/mise-action@v2`, then cache `~/.bun/install/cache` yourself with `WarpBuilds/cache@v1`, which is a drop-in replacement for `actions/cache@v4`. **What runner size does bun test need?** Start at `warp-ubuntu-latest-x64-2x` for a single package and move to 4x or 8x when tests start a database, a browser, or several server processes. Extra vCPUs shorten a `bun test` job only when the suite is split across a matrix. **Can a Bun cache saved on macOS restore on Linux?** No. The cache version is a hash over the compression tool and the list of cached paths, and those differ across operating systems, so a macOS entry and a Linux entry stay separate caches even under the same key. --- # Conda Environments on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/conda-github-actions Description: Conda jobs on GitHub Actions spend most of a cold run in the solver. Cache the environment keyed on environment.yml, then size the runner for the suite. # Conda Environments on GitHub Actions Conda jobs on GitHub Actions spend most of a cold run in the solver. Cache the environment keyed on environment.yml, then size the runner for the suite. Last verified: 2026-08-13 A conda job on GitHub Actions spends most of a cold run building the environment rather than running tests, because `conda env create` fetches channel repodata and solves the dependency graph before a single package is downloaded. Cache the environment keyed on the hash of `environment.yml`, pin an explicit lockfile when the solve stays slow, and put the suite on a `warp-` label sized for the tests instead of the solver. ## [Overview](#overview)Copy Anchor Link A conda job runs four phases before the first test executes. It downloads repodata for every channel in the environment file, solves the requested specs against that repodata, downloads the resolved packages, and links them into the environment directory. Only the last two phases move data; the first two are the ones that surprise people. The solve dominates a cold run when the environment file lists loose version ranges across `conda-forge` and `defaults` together. Channel repodata for a large channel is tens of megabytes of index that has to be parsed before the resolver starts, and the resolver itself is close to serial, so buying more vCPUs does not shorten it. The libmamba solver, distributed as [conda-libmamba-solver](https://github.com/conda/conda-libmamba-solver), cuts the resolution cost substantially compared with the classic solver, and it still runs on every job that has no lockfile and no restored environment. WarpBuild runners register against your organization under `warp-` labels, run as ephemeral virtual machines allocated for one job, and carry the same tooling as GitHub-hosted images, so `setup-miniconda`, `setup-micromamba`, and your `environment.yml` behave the same way. The label list and sizes are in the [cloud runners documentation](/docs/ci/cloud-runners). The rest of this page covers a working cached workflow, the sizing question for solve time against test time, the bottlenecks that a bigger runner does not fix, and the option of a prepared machine when the solve stays expensive. For the pip and uv side of a Python codebase see [faster Python test suites on GitHub Actions](/solutions/python-github-actions). ## [Configuration](#configuration)Copy Anchor Link The workflow below runs on `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB, 150GB SSD). It restores the environment from cache, activates it through a login shell, and runs the suite. Copy Text ``` name: conda-tests on: push: branches: [main] pull_request: defaults: run: shell: bash -el {0} jobs: test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - name: Create or restore the environment uses: mamba-org/setup-micromamba@v2 with: environment-file: environment.yml environment-name: app cache-environment: true cache-downloads: true - name: Show what got resolved run: micromamba list - name: Run tests run: pytest -n auto --dist worksteal ``` Three details in that job carry the weight. `shell: bash -el {0}` is the activation contract. Conda writes its activation hook into the login shell profile, so a step running under the default non-login shell finds the base interpreter rather than your environment. Both [setup-micromamba](https://github.com/mamba-org/setup-micromamba) and [setup-miniconda](https://github.com/conda-incubator/setup-miniconda) document the login shell as a requirement, and setting it once under `defaults` applies it to every `run` step in the job. `cache-environment: true` keys the cache on the contents of `environment.yml`, so the entry survives until a spec changes and invalidates the moment one does. That is the strategy that pays: keying on the environment file makes a warm run skip repodata, solve, download, and link together, while keying on a branch name or a date leaves you restoring an environment that no longer matches the file. `micromamba list` after creation is a two-second step that saves debugging sessions later. When a warm run and a cold run disagree, the printed package list tells you whether the cache handed back a stale environment. If your project is on `setup-miniconda`, cache the package directory instead and swap in the WarpBuild cache action: Copy Text ``` - name: Cache conda packages uses: WarpBuilds/cache@v1 with: path: ~/conda_pkgs_dir key: ${{ runner.os }}-conda-${{ hashFiles('environment.yml') }} restore-keys: | ${{ runner.os }}-conda- - uses: conda-incubator/setup-miniconda@v3 with: environment-file: environment.yml activate-environment: app channel-priority: strict use-only-tar-bz2: true ``` `WarpBuilds/cache` is a drop-in replacement for `actions/cache`, so `path`, `key`, and `restore-keys` behave the same way. The cache is enabled by default on Linux runners, entries expire 7 days after last use, storage is $0.20 per GB-month, and each write, restore, or list operation is $0.0001\. Details are in the [caching documentation](/docs/ci/features/caching). The `use-only-tar-bz2` input in the setup-miniconda README exists because the package cache is keyed by archive format, and mixing `.conda` and `.tar.bz2` artifacts produces misses that look random. ### [What each strategy actually removes](#what-each-strategy-actually-removes)Copy Anchor Link | Strategy | What a warm run skips | What it still pays for | Invalidation key | | ----------------------------------------------------------------------- | ------------------------------- | ----------------------------------------- | ----------------------- | | conda env create with no cache | nothing | repodata, solve, download, link | none | | Package cache on \~/conda\_pkgs\_dir | package downloads | repodata revalidation, solve, link | hash of environment.yml | | Environment cache (cache-environment: true) | repodata, solve, download, link | cache restore time | hash of environment.yml | | Explicit lockfile via [conda-lock](https://github.com/conda/conda-lock) | repodata, solve | download and link unless also cached | hash of the lockfile | | Snapshot runner or prebuilt container image | all four phases | refresh cadence for the snapshot or image | rebuild schedule | The row that gets skipped most often is the lockfile. A package cache does nothing for solve time, so a project with a slow solve and a healthy cache still pays the solve on every job. Generating `conda-lock.yml` and passing it as the `environment-file` removes resolution from the job entirely and makes the run reproducible across machines. ## [Sizing](#sizing)Copy Anchor Link Solve time and test time want different machines. The solve is close to serial and bounded by parsing and resolution, so it barely improves above 4 vCPUs. Package extraction and link are disk-bound, and every Linux size ships 150GB SSD storage. Test execution is the phase that scales with cores. Size for the tests, then remove the solve with caching rather than paying for cores it cannot use. Rates are from the [pricing page](/pricing), billed per minute, checked on 2026-08-13. | Label | vCPU | RAM | Fits | USD per minute | | --------------------------- | ---- | ----- | -------------------------------------------------------- | -------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | environment build jobs, lint, small suites | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | the default for a cached environment plus pytest -n auto | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | large scientific suites holding arrays per worker | $0.032 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | the same job where every channel publishes linux-aarch64 | $0.012 | The ARM64 row is worth checking before you take it. `conda-forge` publishes `linux-aarch64` builds for most of the scientific stack, and a smaller channel or an internal channel may not, in which case the solve fails or falls back to a source build. Run `micromamba create --dry-run` against the ARM64 platform once to confirm before switching a whole matrix. ### [What the phases cost](#what-the-phases-cost)Copy Anchor Link Take a job where a cold environment build costs 4 minutes, a restored environment costs 1 minute, and the suite takes 6 minutes on 8 vCPUs, across 300 runs a month. Substitute your own two measurements. GitHub's per-minute list price for the 8-core Linux larger runner is $0.022, per the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Setup | Wall clock | Rate per minute | Cost per run | Cost per month | | ----------------------------------------------------- | ---------- | --------------- | ------------ | -------------- | | GitHub hosted larger runner, 8 vCPU, cold environment | 10 min | $0.022 | $0.220 | $66.00 | | warp-ubuntu-latest-x64-8x, cold environment | 10 min | $0.016 | $0.160 | $48.00 | | warp-ubuntu-latest-x64-8x, cached environment | 7 min | $0.016 | $0.112 | $33.60 | | warp-ubuntu-latest-arm64-8x, cached environment | 7 min | $0.012 | $0.084 | $25.20 | Two things fall out. The cache is the larger lever, since it removes 3 of 10 minutes from every run at a carrying cost of about $0.40 a month for a 2 GB environment cache plus $0.06 for 600 cache operations. And the equal-shape rows differ on rate: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13. To confirm a sizing choice rather than guess at it, WarpBuild CI observability reports CPU and memory percentiles from the runner agent next to the job, which shows whether the solve phase leaves seven cores idle while the suite saturates all eight. ## [Bottlenecks](#bottlenecks)Copy Anchor Link ### [Solve time on every cold run](#solve-time-on-every-cold-run)Copy Anchor Link This is the conda-specific one. Loose specs such as `python>=3.10` and `numpy` across two channels give the resolver a large search space, and the cost lands before any test runs. Tighten the specs in `environment.yml`, set `channel-priority: strict` so the resolver stops considering lower-priority channels for a package it already found, and generate a lockfile when the solve still runs long. A lockfile is the only fix that removes the phase rather than shortening it. ### [Cache misses that look random](#cache-misses-that-look-random)Copy Anchor Link An environment cache keyed on `environment.yml` misses whenever the file changes, which is correct. It also misses when the key omits the platform, so an x64 job and an ARM64 job fight over one entry. Put `${{ runner.os }}` and the architecture in the key, and add a `restore-keys` prefix so a spec bump restores the previous environment and updates the delta rather than rebuilding from scratch. ### [Pip installs layered on top of conda](#pip-installs-layered-on-top-of-conda)Copy Anchor Link Environments that list a `pip:` section under dependencies run a second resolver after the conda solve finishes, and the pip half is invisible to conda's cache. Pin those entries with hashes or move them into the lockfile, and keep the pip cache directory in the same cache entry as the environment so both halves warm together. The same keying rules apply to the pure-pip toolchains; [how to cache uv or poetry environments](/answers/how-do-i-cache-uv-or-poetry-environments) covers the paths and keys for those. ### [When the solve stays expensive](#when-the-solve-stays-expensive)Copy Anchor Link Some environments resolve slowly no matter how they are pinned, usually because of a large internal channel or a matrix that spans several Python versions. Two options remove the build from the job entirely. A snapshot runner captures the machine after the environment is built and boots later jobs from that snapshot. Add `snapshot.key=` to the label, and jobs on that alias start with the environment already on disk: Copy Text ``` jobs: test: runs-on: warp-ubuntu-latest-x64-8x;snapshot.key=conda-env ``` Snapshots are supported on WarpBuild Cloud Ubuntu runners, boot in 45 to 60 seconds, and are deleted after 15 days, and `/tmp` does not persist across the boot. Clean credentials with `rm -rf $HOME/.ssh $HOME/.aws` before the `WarpBuilds/snapshot-save` step, since anyone running a job under the alias inherits the disk. The [snapshot runners documentation](/docs/ci/features/snapshot-runners) covers the save action and the security notes in full, and the [snapshot runner page](/runners/snapshot) covers the labels and where the feature fits. The other option is a container image with the environment baked in, built once and pulled by the job through the `container` key. That gives you the same skip with an artifact you can version and scan, at the cost of an image build pipeline and a pull on every job. The [guide to container jobs on GitHub Actions](/guides/container-jobs-github-actions) covers the tradeoff. When an environment resolves on a laptop and fails in the job, the Action Debugger pauses the workflow and opens an SSH session on the live runner so you can run `micromamba repoquery` against the real machine. ## [Proof](#proof)Copy Anchor Link Public repositories are checkable evidence for the pattern on this page. The closest published cases are Python jobs with compiled dependencies running on `warp-` labels, which stress the same download and link path as a conda environment build. [nobodywho-ooo/nobodywho](https://github.com/nobodywho-ooo/nobodywho) runs its Python lint and test jobs on `warp-ubuntu-latest-x64-4x` and its macOS wheel build on `warp-macos-latest-arm64-6x`, with dependency caching keyed on a lockfile, in [python-ci.yml](https://github.com/nobodywho-ooo/nobodywho/blob/main/.github/workflows/python-ci.yml) (checked on 2026-08-13). [lance-format/lance](https://github.com/lance-format/lance) runs its `Python macOS 3.14 ARM` job on `warp-macos-14-arm64-6x`, building the macOS wheel and running the suite, in [python.yml](https://github.com/lance-format/lance/blob/main/.github/workflows/python.yml) (checked on 2026-08-13). Both labels come from the catalog on the [cloud runners page](/docs/ci/cloud-runners). Moving a conda job over is a one-line change to `runs-on` plus the cache action swap. ## [FAQ](#faq)Copy Anchor Link **How do I cache a conda environment in GitHub Actions?** Cache the environment directory and key it on the hash of `environment.yml`, so the entry invalidates the moment the file changes. `setup-micromamba` does this with `cache-environment: true`, and `setup-miniconda` pairs with a cache action on `~/conda_pkgs_dir`. On WarpBuild runners, swap `actions/cache` for `WarpBuilds/cache` and the inputs stay the same. **Why is `conda env create` so slow on a cold GitHub Actions run?** A cold run pays for four phases: repodata download, dependency solve, package download, and link into the environment. The solve dominates when the environment file lists loose version ranges across several channels, and it is close to serial, so a larger runner does not shorten it. An explicit lockfile removes the solve entirely. **What does a conda job cost on WarpBuild runners?** --- # C++ Builds on GitHub Actions: Caching and Sizing | WarpBuild URL: https://www.warpbuild.com/solutions/cpp-github-actions Description: Fast C++ builds on GitHub Actions come from a persistent ccache, warp- runners sized for parallel compiles, and tamed link steps. Cost math inside. # C++ Builds on GitHub Actions: Caching and Sizing Fast C++ builds on GitHub Actions come from a persistent ccache, warp- runners sized for parallel compiles, and tamed link steps. Cost math inside. Last verified: 2026-08-13 C++ builds get fast on GitHub Actions through three levers: a compiler cache that survives between runs, enough vCPUs to keep parallel compilation fed, and a link step kept off the critical path. WarpBuild covers the first two directly, with warp- labeled runners from 2 to 32 vCPUs billed per minute and a cache service that keeps a ccache directory warm across jobs. Switching takes one line. Change `runs-on` from a GitHub-hosted label to a warp- label, persist `CCACHE_DIR` through a cache step, and the same CMake project builds on a machine sized for the compile graph. The sections below cover the exact configuration, how to pick `-j` against each size's memory ceiling, and the arithmetic behind the bill. ## [Overview](#overview)Copy Anchor Link A C++ build spends GitHub Actions minutes in four places: compiling third-party dependencies, compiling your own translation units, linking binaries, and running tests. Compilation is embarrassingly parallel, since every translation unit compiles independently. Linking is the serial tail, one process per binary, and it is where memory pressure concentrates. Two properties of C++ make hosted runner choice matter more than in most ecosystems. First, the language recompiles a lot: every `.cpp` file re-parses every header it includes, so a header touch can invalidate hundreds of translation units. Second, a fresh virtual machine has an empty compiler cache, so without persistence every push pays for a full rebuild of code that has been stable for months. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. For most C++ pipelines the Linux runners do the work: [the runner catalog](/docs/ci/cloud-runners) lists Ubuntu 22.04, 24.04, and 26.04 images on x64 and Ubuntu 24.04 and 26.04 on ARM64, each in sizes from 2 to 32 vCPUs with 150GB SSDs. The images carry the same tooling as GitHub-hosted runners, so gcc, clang, CMake, and Ninja are already present. Runners are ephemeral VMs, freshly allocated per job and destroyed afterward, which is exactly why the compiler cache has to live outside the machine. That external home is WarpBuild Cache, enabled by default on all Linux runners. The [cache documentation](/docs/ci/features/caching) covers the general mechanics; the next section applies them to ccache and CMake specifically. ## [Configuration](#configuration)Copy Anchor Link Here is a working C++ pipeline on WarpBuild runners. The build and test job runs on 16 vCPUs with ccache persisted through [WarpBuilds/cache](https://github.com/WarpBuilds/cache), and a light format check runs on 4 vCPUs. Copy Text ``` name: cpp on: push: branches: [main] pull_request: env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 2G jobs: build-and-test: runs-on: warp-ubuntu-latest-x64-16x steps: - uses: actions/checkout@v4 - run: sudo apt-get update && sudo apt-get install -y ccache - name: Restore compiler cache id: ccache uses: WarpBuilds/cache/restore@v1 with: path: ${{ github.workspace }}/.ccache key: ${{ runner.os }}-ccache-${{ github.sha }} restore-keys: | ${{ runner.os }}-ccache- - name: Configure run: > cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache - name: Build run: cmake --build build --parallel 16 - name: Test run: ctest --test-dir build --output-on-failure --parallel 16 - name: Show cache stats run: ccache -s - name: Save compiler cache if: github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: ${{ github.workspace }}/.ccache key: ${{ steps.ccache.outputs.cache-primary-key }} format: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: | find src include -name '*.cpp' -o -name '*.h' \ | xargs clang-format --dry-run --Werror ``` Four details matter here. **The compiler launcher does the caching.** `CMAKE_C_COMPILER_LAUNCHER` and `CMAKE_CXX_COMPILER_LAUNCHER` wrap every compile invocation in ccache, so an unchanged translation unit becomes a hash lookup instead of a compile. No build system rewrite is involved, and the same flags work for Makefile generators. **The cache key ends in `github.sha`, and `restore-keys` does the real matching.** A pull request never hits the exact key, falls back to the `${{ runner.os }}-ccache-` prefix, and restores the newest main-branch cache. The save step runs only on pushes to main, which keeps the cache count low and the entries authoritative. **`CCACHE_MAXSIZE` caps what you store and ship.** A 2GB ceiling is enough for most mid-size codebases, keeps restore time short, and bounds the cache storage line on the bill. Raise it if `ccache -s` shows evictions on warm runs. Cache entries expire after 7 days without use. **Dependencies cache separately.** If the project pulls third-party code through vcpkg or Conan, persist `~/.cache/vcpkg` or the Conan home directory with another `WarpBuilds/cache@v1` step keyed on the manifest lockfile. Rebuilding Boost from source on every push is the single most expensive habit a C++ pipeline can have. For aarch64 targets, swap the label to an ARM64 size such as `warp-ubuntu-latest-arm64-8x` and the same configuration compiles natively with no cross toolchain. The [Linux ARM64 runner page](/runners/linux-arm64) lists the available sizes and rates. ## [Sizing](#sizing)Copy Anchor Link The Linux x64 catalog, with per-minute rates from the [WarpBuild pricing page](/pricing): | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Every size carries 4 GB of memory per vCPU. That ratio is the number to hold in your head when picking `-j`, because a C++ compile job is a memory consumer as much as a CPU consumer. Setting `--parallel` equal to the vCPU count budgets about 4 GB per compile process on any of these machines, which covers ordinary translation units comfortably and template-heavy ones adequately. **warp-ubuntu-latest-x64-8x** with `--parallel 8` is the starting point for a mid-size codebase. Eight concurrent compile jobs inside a 32 GB ceiling leaves headroom for the linker and the build system itself. This is the size to measure on before paying for more. **warp-ubuntu-latest-x64-16x** with `--parallel 16` fits large codebases and template-heavy ones. Instantiation-dense translation units, the kind that include Eigen or Boost.Spirit, can peak at 2 to 4 GB per compile process, and 16 jobs at that peak still fit under the 64 GB ceiling. Sanitizer builds also belong here: ASan instrumentation grows both compile memory and test memory, and the wider ceiling absorbs it. **warp-ubuntu-latest-x64-32x** with `--parallel 32` pays off on wide dependency graphs, big generated-code trees, and release matrices under deadline. At 128 GB the ceiling stops being the constraint for compilation. Check the CPU chart before moving up: late in a build the graph narrows to a few final targets plus their links, and idle cores at 32 vCPUs cost the same as busy ones. Two refinements to plain `-j`: **Cap link parallelism separately.** Linking a binary with full debug info can consume many gigabytes on its own, and a build that schedules four such links concurrently can hit the memory ceiling even though compilation never did. With the Ninja generator, CMake job pools express this directly: configure with `-DCMAKE_JOB_POOLS=link=2 -DCMAKE_JOB_POOL_LINK=link` to allow at most two concurrent links while compilation keeps using every core. **Watch for OOM kills before upsizing blindly.** A compile step that dies with `internal compiler error: Killed` is the kernel reclaiming memory. Either lower `--parallel` below the vCPU count or move up one size to raise the ceiling. The 150GB SSD is rarely the limit, but a build tree plus a large ccache plus a vcpkg tree can approach it on monorepos, and `df -h` in a debug step settles the question quickly. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes list prices for its hosted runners: the standard Linux runner meters at $0.006 per minute, and Linux larger runners meter at $0.012 for 4 vCPU, $0.022 for 8 vCPU, $0.042 for 16 vCPU, and $0.082 for 32 vCPU. Rates are from the [GitHub Actions billing documentation](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions) and [github.com/pricing](https://github.com/pricing), checked on 2026-08-13. Take a C++ project whose pull request pipeline consumes 25,000 runner-minutes per month on 16 vCPU machines, storing 20 GB of ccache and dependency cache and performing 8,000 cache operations: | Line item | Rate | Volume | Monthly cost | | ----------------------------------------- | --------------------- | ---------------- | ------------ | | GitHub-hosted Linux 16 vCPU larger runner | $0.042 per minute | 25,000 minutes | $1,050.00 | | warp-ubuntu-latest-x64-16x | $0.032 per minute | 25,000 minutes | $800.00 | | WarpBuild cache storage | $0.20 per GB-month | 20 GB | $4.00 | | WarpBuild cache operations | $0.0001 per operation | 8,000 operations | $0.80 | The WarpBuild total is $804.80 against $1,050.00 on GitHub-hosted larger runners for the same minutes, a difference of $245.20 per month. As a standalone rate comparison: warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13\. The model holds minutes equal on both sides. The full breakdown of GitHub's larger runner tiers is in the [guide to GitHub Actions larger runner costs](/guides/github-actions-larger-runners-cost). Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four failure modes account for most slow C++ pipelines on GitHub Actions. **Cold compiler cache.** Every runner starts as a fresh VM, so without a persisted `CCACHE_DIR` each job compiles the entire tree from scratch, including the vast majority of translation units the commit never touched. The fix is the restore and save pair above. Two things quietly invalidate a warm cache: a compiler upgrade, since ccache keys on the compiler, and flag drift between jobs, since a `-O2` object never satisfies a `-O3` request. Pin the toolchain and keep flags identical across jobs that share a key. **Link step serialization.** The linker is one process per binary regardless of vCPU count, so a pipeline that compiles in two minutes can still spend five inside `ld`. Mitigations stack: switch to `mold` or `lld` through `-DCMAKE_LINKER_TYPE=MOLD` or the equivalent flags, split debug info out of the link with `-gsplit-dwarf`, and consolidate small test executables so there are fewer binaries to link. Memory helps too, which is a reason the 64 GB ceiling on the 16x size matters for binaries with heavy debug info. **Header-heavy translation units.** When a common header pulls in a template library, every including translation unit pays the parse and instantiation cost again. Precompiled headers through CMake's `target_precompile_headers` cut the repeated parsing, and unity builds through `CMAKE_UNITY_BUILD=ON` amortize it across batched sources. One interaction to know: ccache needs `CCACHE_SLOPPINESS=pch_defines,time_macros` set before compiles that consume a precompiled header will cache. Include hygiene is the durable fix, and tools such as include-what-you-use keep the graph from regrowing. **Cross-compilation matrices.** A matrix that builds x64 and aarch64 from one x64 job pays twice: the cross toolchain forfeits the native ccache history, and any tests that run under emulation crawl. The cheaper shape is one native job per architecture, with `warp-ubuntu-latest-arm64-8x` compiling and testing aarch64 directly at $0.012 per minute. The [Linux ARM64 runner page](/runners/linux-arm64) covers the native ARM64 setup in detail. Telling these apart is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so one busy core during a long link looks visibly different from all cores saturated through compilation, and a memory ceiling shows up as a plateau before an OOM kill. For a stuck or misbehaving job, the Action Debugger pauses the workflow and opens an SSH session on the live runner, which is the fastest way to inspect a hung compile or read `ccache -s` in place. Workflow-level tactics, including job fan-out and test sharding, are covered in the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions), and the same bottleneck analysis applies with different weights on the [fast Rust builds page](/solutions/rust-github-actions). ## [Proof](#proof)Copy Anchor Link The largest public C++ evidence is Bitcoin Core. The [bitcoin/bitcoin GitHub Actions workflow](https://github.com/bitcoin/bitcoin/blob/master/.github/workflows/ci.yml) routes its Linux jobs across warp- labels sized to the job: lint on `warp-ubuntu-latest-x64-2x`, the macOS cross builds on `warp-ubuntu-latest-x64-4x`, the TSan and BSD cross-compilation matrix on `warp-ubuntu-latest-x64-8x` with the ASan job pinned to `warp-ubuntu-2404-x64-8x`, and the fuzz and MSan jobs on `warp-ubuntu-latest-x64-16x`, with ccache directories restored and saved between runs (checked on 2026-08-13). The workflow file is open to read, size choices and cache keys included. ## [FAQ](#faq)Copy Anchor Link **Which WarpBuild runner size should a C++ project start with?** Start on warp-ubuntu-latest-x64-8x at $0.016 per minute and build with `--parallel 8`. Move the build job to warp-ubuntu-latest-x64-16x when the CPU chart in WarpBuild's CI observability shows all 8 vCPUs saturated through compilation, and keep clang-format and other light checks on warp-ubuntu-latest-x64-4x. **Does ccache work with WarpBuild cache?** Yes. Point `CCACHE_DIR` at a workspace path, restore and save that directory with WarpBuilds/cache@v1, and set `CMAKE_C_COMPILER_LAUNCHER` and `CMAKE_CXX_COMPILER_LAUNCHER` to ccache. WarpBuilds/cache is a drop-in replacement for actions/cache@v4. **Can I build C++ for ARM64 on GitHub Actions with WarpBuild?** Yes. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. Labels such as warp-ubuntu-latest-arm64-8x compile aarch64 code natively, with rates starting at $0.003 per minute for the 2 vCPU size. **Is WarpBuild SOC 2 compliant?** The audit evidence is published at [trust.warpbuild.com](https://trust.warpbuild.com/). --- # Cypress Test Suites on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/cypress-github-actions Description: Cache the Cypress binary folder with the npm store on one key, split specs across a warp- runner matrix, and size headless Chrome jobs at 4 vCPU or more. # Cypress Test Suites on GitHub Actions Cache the Cypress binary folder with the npm store on one key, split specs across a warp- runner matrix, and size headless Chrome jobs at 4 vCPU or more. Last verified: 2026-08-13 ## [Overview](#overview)Copy Anchor Link To run Cypress tests on GitHub Actions, restore the Cypress binary folder and the npm store from one cache key, split the spec files across a matrix of jobs, and give each job at least 4 vCPU so headless Chrome has room to work. A default single-job setup downloads the Cypress binary on every run and then walks the whole spec list serially in one browser, which is what turns a ten-minute suite into a thirty-minute pull request gate. Cypress holds more open at once than most test runners. A single `cypress run` invocation keeps the Cypress Node process alive, plus a full browser process tree: a renderer for the test window, a renderer for the application under test, and the browser's own utility and networking processes. Most projects also serve the application from the same machine, and when video recording is enabled ffmpeg runs on that machine too. All of it shares one runner's memory budget. Cypress work belongs on the Linux x64 sizes. Those images carry the same tooling as GitHub-hosted runners, so Chrome is already installed and `--browser chrome` works without an install step, and WarpBuild Cache is enabled by default on Linux runners, which is where the binary cache lands. Moving an existing job across is a one-line change to `runs-on`. One boundary before the configuration. This page covers Cypress: its separate binary cache, its spec-level parallelism, and its browser memory profile. A different browser automation stack has a different worker model and a different download path, and that configuration lives on the [Playwright test suites on GitHub Actions](/solutions/playwright-github-actions) page. ## [Configuration](#configuration)Copy Anchor Link Two directories need caching and they sit in different places. `npm ci` rebuilds `node_modules` from tarballs in the npm store at `~/.npm`. The Cypress binary is not in `node_modules` at all: the `cypress` package postinstall downloads a platform-specific binary into `~/.cache/Cypress/`, and the npm package shells out to it. Cache one without the other and the job either refetches every package or fails at `cypress run` with a message about no version of Cypress being installed. Put both paths on one cache entry, keyed on the lockfile so a Cypress version bump rolls the binary and the dependency set together: Copy Text ``` name: cypress on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore npm store and Cypress binary uses: WarpBuilds/cache@v1 with: path: | ~/.npm ~/.cache/Cypress key: ${{ runner.os }}-cypress-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-cypress- - run: npm ci - run: npx cypress install - run: npx cypress verify - run: npm run build - uses: actions/upload-artifact@v4 with: name: app-dist path: dist retention-days: 1 e2e: needs: build runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: - shard: 0 runner: warp-ubuntu-latest-x64-4x - shard: 1 runner: warp-ubuntu-latest-x64-4x - shard: 2 runner: warp-ubuntu-latest-x64-4x - shard: 3 runner: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore npm store and Cypress binary uses: WarpBuilds/cache/restore@v1 with: path: | ~/.npm ~/.cache/Cypress key: ${{ runner.os }}-cypress-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-cypress- - run: npm ci - uses: actions/download-artifact@v4 with: name: app-dist path: dist - name: Select specs for this shard id: specs run: | list=$(find cypress/e2e -name '*.cy.ts' | sort \ | awk "NR % 4 == ${{ matrix.shard }}" | paste -sd,) echo "list=$list" >> "$GITHUB_OUTPUT" - name: Run Cypress run: | npx cypress run \ --browser chrome \ --spec "${{ steps.specs.outputs.list }}" ``` The `build` job is what warms the cache. `npx cypress install` is a no-op when the binary is already in the restored folder and a download when it is not, so the step is safe to leave in permanently and it gives you one log line that tells you whether the cache hit. `npx cypress verify` fails fast on a corrupted or partially restored binary instead of letting four shards each hit the same error twenty minutes later. The shards use `WarpBuilds/cache/restore@v1` rather than the combined action so that four jobs do not race to write the same key. `fail-fast: false` keeps a single broken shard from cancelling the other three, which matters when you want the full failure list from one run instead of one failure at a time. Two cache behaviors from the [caching documentation](/docs/ci/features/caching) shape how this performs. The cache is scoped to key, version, and branch, so seed it on `main` and let branch builds fall back through `restore-keys`. Entries expire 7 days after last use, so an active repository stays warm on its own. Cache usage is metered at $0.20 per GB-month of storage and $0.0001 per write or restore operation, which for a Cypress repository with a 2GB entry and 8,000 operations a month comes to roughly $1.20. If your Cypress job runs inside a `container:`, `HOME` differs from the runner's home directory and the binary lands somewhere the cache paths above do not cover. Set `CYPRESS_CACHE_FOLDER` to an explicit path inside the workspace and cache that path instead. Keep Cypress caching on Linux jobs: WarpBuild Cache is not supported on Windows runners. ## [Sizing](#sizing)Copy Anchor Link WarpBuild Linux x64 runners scale from 2 to 32 vCPU with 4GB of memory per core. These are the sizes that matter for Cypress, with the nearest GitHub-hosted shape and its published list price beside each one: | Runner label | vCPU | Memory | Storage | Per minute | Nearest GitHub-hosted shape | GitHub list price | | -------------------------- | ---- | ------ | --------- | ---------- | ------------------------------------------------------ | ----------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | ubuntu-latest, 2 vCPU and 8 GB on private repositories | $0.006 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | 4-core Linux larger runner | $0.012 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | 8-core Linux larger runner | $0.022 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | 16-core Linux larger runner | $0.042 | WarpBuild rates come from the [cloud runners documentation](/docs/ci/cloud-runners). GitHub list prices come from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. The 2x size is where headless Chrome runs into trouble. Eight gigabytes has to cover the Cypress Node process, two renderer processes, the browser's supporting processes, and the application under test, and Cypress keeps DOM snapshots for its time travel debugger in memory as a spec progresses. On Linux, `/dev/shm` is a tmpfs sized from system memory, and Chrome shares memory between renderers through it, so a smaller runner lowers that ceiling as well as the total. The failure mode is a renderer crash mid-spec with a message about the browser process closing unexpectedly, which reads like a flaky test and is a resource problem. That makes `warp-ubuntu-latest-x64-4x` at 4 vCPU and 16GB the practical floor for a Cypress shard, at $0.008 per minute. Move a shard to `warp-ubuntu-latest-x64-8x` at 32GB when the job also starts a dev server and a database in service containers, when specs are long enough that snapshot accumulation matters, or when video recording is on and ffmpeg competes with the browser for cores. Buying beyond 8x for a single Cypress job rarely pays. `cypress run` works through its spec list one file at a time in a single browser instance, so there is no in-process worker pool to feed. A 16 vCPU runner leaves most of those cores idle for the length of the run. The lever that uses more cores is the matrix: four shards on 4x finish in roughly the wall time of the slowest shard while spending about the same total minutes as one serial job on 4x. The mechanics of splitting and fanning out are covered in the [GitHub Actions matrix builds guide](/guides/github-actions-matrix-builds). ### [Worked cost model](#worked-cost-model)Copy Anchor Link Take a suite of 240 spec files that runs 48 minutes end to end on one machine. Split four ways it becomes four shards of about 13 minutes each, with a 4-minute build job in front of them. Three shards sit on 4x and the slowest shard sits on 8x. The team merges enough to trigger 600 workflow runs a month. | Line item | Rate | Minutes per month | Monthly cost | | ------------------------------------------------ | ---------- | ----------------- | ------------ | | Build job plus 3 shards, 4 vCPU (43 min per run) | $0.008/min | 25,800 | $206.40 | | Heavy shard, 8 vCPU (13 min per run) | $0.016/min | 7,800 | $124.80 | | WarpBuild total | 33,600 | $331.20 | | | Same minutes on the 4-core Linux larger runner | $0.012/min | 25,800 | $309.60 | | Same minutes on the 8-core Linux larger runner | $0.022/min | 7,800 | $171.60 | | GitHub-hosted total | 33,600 | $481.20 | | The two per-size comparisons behind that table, stated as list-price arithmetic: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB), which is 33 percent lower list price, and warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), which is 27 percent lower list price. GitHub list prices checked on 2026-08-13. Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four things account for most slow Cypress pipelines on GitHub Actions. **Binary download per run.** The Cypress binary is fetched once per cold machine, and every matrix shard is a cold machine. A four-shard suite that misses the binary cache pays that download four times per run before a single test executes. Diagnose it from the install log: a warm run reports the binary as already installed at the cached path, and a cold run shows a download with a progress bar. The fix is the two-path cache entry above, and the reason it gets missed is that people cache the dependency store, see install times drop, and never notice the separate binary fetch underneath. **Single-spec serialization.** One `cypress run` process executes specs in sequence. Wall time equals the sum of every spec, no matter how large the runner, so a suite that grows by ten specs a month gets steadily slower with no configuration change to blame. Splitting across a matrix is the only lever that changes the shape of that curve. Round-robin splitting by file count, as in the workflow above, is coarse: if one spec takes 6 minutes and the rest take 20 seconds, the shard holding it sets the wall time. When that happens, pin the slow spec to its own shard by name and give that shard a larger label, which is what the `include` block in the matrix is for. **Memory exhaustion on small runners.** Covered under sizing, and worth naming here because it presents as flakiness rather than as an out-of-memory error. Symptoms are renderers closing unexpectedly, specs that pass alone and fail in a full run, and failures that move around between runs. Before rewriting the tests, set `numTestsKeptInMemory` lower than its default of 50 so old DOM snapshots stop accumulating, turn on `experimentalMemoryManagement` for Chromium, and move the shard to 8x. If the job runs in a container, raise its shared memory: Docker defaults `/dev/shm` to 64MB, which is well under what Chrome expects. **Video encoding.** Cypress encodes one video per spec with a bundled ffmpeg, and compression is CPU work that lands at the end of each spec. On a 4 vCPU shard that encode competes with the next spec's browser startup. Check the `video` key in your `cypress.config.js` before assuming it is off, since a config carried forward from an older major version may still record everything. Keep recording on only for the shards you actually debug from, or set it to record on failure and drop `videoCompression` so the file is written without the encode pass. When a shard is slow and the cause is unclear, WarpBuild's CI observability shows OpenTelemetry-based system metrics from the runner agent correlated with GitHub Actions job logs, which separates a memory-bound browser from a spec that is genuinely waiting on the network. For a live look, the Action Debugger pauses the workflow and opens an SSH session on the runner, so you can run `npx cypress cache path`, list the cached versions, and confirm what the restore actually put on disk. Dependency-side caching questions are answered separately in [how to cache node modules in GitHub Actions](/answers/how-do-i-cache-node-modules-in-github-actions) and in the [Node.js builds on GitHub Actions](/solutions/nodejs-github-actions) page. ## [Proof](#proof)Copy Anchor Link For a Cypress suite the compounding effect sits in the shards, since each one pays its own checkout, install, and browser startup. Public OSS repositories running `warp-` labels are citable evidence, and you can check them yourself by opening the workflow file and reading the `runs-on` line. The Trigger.dev end-to-end suite runs its matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x`, which you can read in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml) (checked on 2026-08-13). ## [FAQ](#faq)Copy Anchor Link **Why does Cypress download its binary on every GitHub Actions run?** The `cypress` npm package is a thin client. Its postinstall script downloads a platform binary into `~/.cache/Cypress`, which sits outside `node_modules`, so a cache that only covers the npm store or `node_modules` never restores it. Add `~/.cache/Cypress` to the same cache entry. **Which directories does a Cypress job need to cache?** Two: the package manager store at `~/.npm` for dependencies, and `~/.cache/Cypress` for the binary. Key both on the lockfile hash so a Cypress version bump in the lockfile rolls the binary cache at the same time. **What runner size does a headless Chrome Cypress job need?** `warp-ubuntu-latest-x64-4x` at 4 vCPU and 16GB is the practical floor, and 8x at 32GB is right when the job also runs a dev server, a database, or long specs that accumulate DOM snapshots. The 2x size at 8GB runs out of memory on real suites. **How do I split Cypress specs across parallel jobs without a record key?** Sort the spec files deterministically and give each matrix job every Nth file through the `--spec` flag. Splitting by file count is coarse, so pin the slowest specs to their own shard and put that shard on a larger `warp-` label. --- # Database Migrations in GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/database-migrations-github-actions Description: Run database migrations in GitHub Actions as two jobs: a migration test against a fresh service container database, and a gated production apply job. # Database Migrations in GitHub Actions Run database migrations in GitHub Actions as two jobs: a migration test against a fresh service container database, and a gated production apply job. Run database migrations in GitHub Actions as two jobs with different trust levels: a migration test job that applies the whole pending chain to a throwaway database in a service container, and a production apply job that runs behind an environment gate in a single concurrency group. The test job proves the migration applies and reverses against a schema shaped like production, and the apply job is the only path in the repository allowed to write to the real database. This page gives the workflow for both jobs on `warp-` labels, the runner sizes and per-minute rates each one needs, the serialization and rollback checklist that keeps two applies from colliding, and the parts of the job that actually consume the clock. ## [Overview](#overview)Copy Anchor Link The two jobs answer different questions, which is why they are separate. The migration test job answers "does this migration apply". It starts a database container, loads the schema baseline that production is on today, applies every pending migration, diffs the resulting schema against the snapshot checked into the repository, then applies the down migration and re-applies to prove the reverse path exists. It runs on pull requests, it holds no production credentials, and it can fail as often as it likes. The production apply job answers "should this migration run now". It runs on the default branch after the test job passes, it waits for a human approver on a protected [deployment environment](/glossary/deployment-environment), it holds the one credential that can write to the production database, and it must never overlap with another copy of itself. Both jobs belong on the Linux runners, because service containers need Docker and the [cloud runners documentation](/docs/ci/cloud-runners) states that macOS runners do not support nested virtualization and cannot run Docker. Two other parts of the product surface show up in this workflow. Snapshot runners capture a runner VM mid-workflow so a later job boots with a database volume already restored, and CI observability gives you the per-step duration history that tells you whether the baseline restore or the migration itself is growing. The surface also includes remote Docker builders, an MCP server, and the Action Debugger. ## [Configuration](#configuration)Copy Anchor Link ### [The migration test job](#the-migration-test-job)Copy Anchor Link Copy Text ``` name: migrations on: pull_request: paths: - "db/migrations/**" - ".github/workflows/migrations.yml" push: branches: [main] jobs: migration-test: runs-on: warp-ubuntu-latest-x64-4x timeout-minutes: 20 services: postgres: image: postgres:17 env: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U app -d app_test" --health-interval 2s --health-timeout 3s --health-retries 30 env: DATABASE_URL: postgres://app:app@localhost:5432/app_test PGOPTIONS: "-c lock_timeout=5s -c statement_timeout=600s" steps: - uses: actions/checkout@v5 - name: Load the production schema baseline run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f db/schema/baseline.sql - name: Apply pending migrations run: ./scripts/migrate up - name: Diff against the checked-in schema snapshot run: | pg_dump --schema-only --no-owner "$DATABASE_URL" > /tmp/schema.sql diff -u db/schema/schema.sql /tmp/schema.sql - name: Prove the reverse path run: | ./scripts/migrate down 1 ./scripts/migrate up ``` The baseline file is the point of the job. Applying migrations to an empty database only proves the chain is internally consistent, and most production incidents come from a migration meeting data and indexes that an empty database does not have. Dump the schema from a production replica on a schedule, commit it as `db/schema/baseline.sql`, and the test job runs against the shape you are about to change. The health options are what make `services` usable. GitHub Actions waits for the container to report healthy before the first step runs, so the job never needs a `sleep` in front of `psql`. Interval and retry tuning is covered in [service containers in GitHub Actions](/guides/service-containers-github-actions). `PGOPTIONS` sets a short `lock_timeout` in the test job for the same reason it belongs in production: a migration that cannot take its lock inside five seconds should fail loudly rather than sit at the head of the lock queue blocking every reader behind it. ### [The production apply job](#the-production-apply-job)Copy Anchor Link Copy Text ``` apply-production: needs: migration-test if: github.ref == 'refs/heads/main' runs-on: warp-ubuntu-latest-x64-2x environment: production-db concurrency: group: db-migrate-production cancel-in-progress: false timeout-minutes: 30 permissions: contents: read id-token: write steps: - uses: actions/checkout@v5 - name: Record the restore point run: ./scripts/pitr-marker >> "$GITHUB_STEP_SUMMARY" - name: Show pending migrations run: ./scripts/migrate status env: DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }} - name: Apply run: ./scripts/migrate up env: DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }} PGOPTIONS: "-c lock_timeout=5s -c statement_timeout=1800s" - name: Record the applied version run: ./scripts/migrate version >> "$GITHUB_STEP_SUMMARY" ``` Three lines carry the weight. `environment: production-db` puts the job behind whatever required reviewers and wait timer you configured on that environment, and it scopes `PRODUCTION_DATABASE_URL` to the environment so a pull request job cannot read it. `cancel-in-progress: false` queues a second run instead of killing a migration that is mid-statement. `needs: migration-test` means the chain has already applied and reversed once against the baseline before anyone is asked to approve it. The credential handling on the runner side is worth knowing when the secret in that job is a production database password. The [security documentation](/docs/ci/security) states that each runner runs in its own virtual machine, created on demand and destroyed after the build, with its own encrypted storage volume, and that WarpBuild does not access or store build secrets. ### [Serialization and rollback checklist](#serialization-and-rollback-checklist)Copy Anchor Link Serialization: * One concurrency group for the apply job across every workflow that can write to that database, with `cancel-in-progress: false`. * The apply job is the only holder of the write credential, scoped to the `production-db` environment rather than to the repository. * `lock_timeout` set to single-digit seconds so blocked DDL fails instead of queueing. * `statement_timeout` set above the longest expected migration and below the job `timeout-minutes`. * Long backfills split out of the schema migration and run as their own batched job, so the DDL transaction stays short. Rollback: * Every migration ships with a down step, and the test job runs `down 1` then `up` on every pull request. * Expand and contract ordering: the additive migration ships before the code that uses it, and the destructive migration ships in a later release than the code that stopped using the column. * No `DROP` in the same release as the code change that made the column unused. * A restore point is recorded in the step summary before the apply step runs, so recovery does not start with a search through the console. * A failed apply is recovered with a new forward migration rather than by editing the failed file, because the version table already records the attempt. ## [Sizing](#sizing)Copy Anchor Link Neither job is compute bound, so size them separately and stop paying build-job rates for a job that waits on a server. | Job | Runner label | vCPU | RAM | Storage | Per minute | What sets the size | | ------------------------------------------------ | ------------------------- | ---- | ---- | --------- | ---------- | ----------------------------------------------------------------- | | Migration test, schema-only baseline | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | Postgres and psql share the VM; the baseline restore is I/O bound | | Migration test, baseline with seed data over 5GB | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | Restore parallelism and page cache for index builds | | Production apply | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | One connection waiting on the server | Rates from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13\. A service container is billed as part of the runner, so the database in the test job costs nothing beyond the minutes the job already runs. A monthly model for a repository where 260 pull request runs touch migrations at 6 minutes each, and 60 merges reach production at 4 minutes each: | Line | Arithmetic | Monthly | | --------------------------------------------- | -------------------- | ------- | | Migration test on warp-ubuntu-latest-x64-4x | 260 x 6 min x $0.008 | $12.48 | | Production apply on warp-ubuntu-latest-x64-2x | 60 x 4 min x $0.004 | $0.96 | | Total | $13.44 | | For the smallest size, warp-ubuntu-latest-x64-2x costs $0.004 per minute against $0.006 per minute for GitHub-hosted ubuntu-latest: 33 percent lower list price ([GitHub pricing](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked 2026-08-13). ## [Bottlenecks](#bottlenecks)Copy Anchor Link ### [The baseline restore, not the migration](#the-baseline-restore-not-the-migration)Copy Anchor Link On most repositories the migration statement takes a second and the baseline restore takes minutes. Restore schema-only where the migration does not depend on data volume. Where it does, use a template database and create each test database with `CREATE DATABASE app_test TEMPLATE app_seeded`, which copies files instead of replaying inserts. Where the seeded dataset is large and changes on a merge cadence rather than per pull request, carry the restored volume in a snapshot runner and boot from it, which is the approach in [can I keep a database warm between GitHub Actions jobs](/answers/can-i-keep-a-database-warm-between-github-actions-jobs). ### [Lock queues behind a long read](#lock-queues-behind-a-long-read)Copy Anchor Link An `ALTER TABLE` that needs an `ACCESS EXCLUSIVE` lock waits behind any open transaction on that table, and every new query queues behind the waiting `ALTER`. A thirty second analytics query turns a one second migration into a thirty second outage. The `lock_timeout` in both jobs converts that into a failed step you can retry, and adding an index with `CREATE INDEX CONCURRENTLY` keeps the index build out of the queue entirely. ### [Two applies at once](#two-applies-at-once)Copy Anchor Link Two merges landing a minute apart, a manual re-run of a failed job, and a scheduled workflow that also migrates are the three ways teams end up with concurrent applies. The runner platform does not stop this for you: run as many jobs as your workflows need, because generally available Linux and Windows runners do not have plan-level concurrency caps and capacity adjusts dynamically. The single named concurrency group in the apply job is the control, and it has to be the same group string in every workflow that can write to that database. ### [The deploy that races the migration](#the-deploy-that-races-the-migration)Copy Anchor Link An application deploy that starts before the migration finishes runs new code against an old schema. Make the deploy job depend on the apply job, and keep the expand and contract ordering so that either side can be ahead of the other for one release without breaking. [Deployment jobs on GitHub Actions](/guides/deployment-jobs-github-actions) covers the shape of the job on the other side of that dependency. ## [Proof](#proof)Copy Anchor Link Public repositories running `warp-` labels are the most direct evidence available. The [warp-e2e workflow](https://github.com/WarpBuilds/build-push-action/blob/main/.github/workflows/warp-e2e.yml) in the `WarpBuilds/build-push-action` repository runs on `runs-on: warp-ubuntu-latest-x64-4x` for pull requests and pushes to `main`, so the label shape and the runner behavior are both readable in public logs. The [WarpBuild runner agent](https://github.com/WarpBuilds/warpbuild-agent) is open source if you want to read what runs on the runner beside your migration. Every cost number on this page carries its arithmetic, its source, and a checked-on date, and the same numbers appear on the [pricing page](/pricing). Point the migration workflow at a `warp-` label, read the baseline restore step timing in the job log, and size from that number. ## [FAQ](#faq)Copy Anchor Link ### [Should migrations run in the same job as the deploy?](#should-migrations-run-in-the-same-job-as-the-deploy)Copy Anchor Link No. Keep the migration apply in its own job with its own environment gate and its own database credentials, and make the deploy job depend on it. One job that migrates and deploys has a single timeout, a single log, and one approval that covers the schema change and the release together. ### [How do I stop two migration jobs from running at once?](#how-do-i-stop-two-migration-jobs-from-running-at-once)Copy Anchor Link Put the apply job in a concurrency group with `cancel-in-progress: false`, so a second run queues instead of starting or cancelling the first. Generally available Linux and Windows runners do not have plan-level concurrency caps, so configure this deployment's serialization in the workflow file. ### [What does a fresh database in a GitHub Actions job cost?](#what-does-a-fresh-database-in-a-github-actions-job-cost)Copy Anchor Link A migration test job on `warp-ubuntu-latest-x64-4x` costs $0.008 per minute, and the Postgres service container shares that runner rather than billing separately. A 6 minute test run is $0.048, and the 2 vCPU production apply job at $0.004 per minute is cheaper still because it spends its life waiting on the server. --- # dbt Pipelines on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/dbt-github-actions Description: Stop rebuilding every dbt model on each pull request. Use state comparison, deferral, cached dbt packages, and small warp- Linux runners priced per minute. # dbt Pipelines on GitHub Actions Stop rebuilding every dbt model on each pull request. Use state comparison, deferral, cached dbt packages, and small warp- Linux runners priced per minute. Last verified: 2026-08-13 A dbt project gets slow and expensive on GitHub Actions when every pull request rebuilds the entire DAG on a runner that spends most of its minutes waiting on the warehouse. The fix is a slim run: publish `target/manifest.json` from the main branch, select `state:modified+` against that manifest with `--defer`, and run the job on a small `warp-` Linux runner with the dbt package cache and parse cache restored. ## [Overview](#overview)Copy Anchor Link A dbt job on GitHub Actions spends its time in four places: installing the Python environment and dbt adapter, resolving packages with `dbt deps`, parsing and compiling the project, and executing models and tests against the warehouse. Only the middle two consume runner CPU in any real quantity. Execution is a set of queries submitted over the network, so the runner sits at low CPU while the warehouse does the work, and buying a larger machine changes nothing about how long the run takes. That makes dbt the opposite of a compile-bound workload, and the two levers that matter are how many nodes you build and how much of the setup you cache. WarpBuild runners register against your organization under `warp-` labels, so switching a dbt workflow is a change to `runs-on` with the rest of the steps untouched. Runners are ephemeral virtual machines, allocated per job and destroyed when the job ends, which is what keeps per-pull-request warehouse schemas from leaking state between runs. The full label list and sizes are in the [cloud runners documentation](/docs/ci/cloud-runners). ## [Configuration](#configuration)Copy Anchor Link Two workflows carry the pattern. The main-branch workflow builds the whole project and publishes the manifest that becomes the comparison state. The pull request workflow downloads that manifest and builds only what changed. Start with the producer: Copy Text ``` name: dbt-main on: push: branches: [main] schedule: - cron: "0 6 * * *" jobs: build: runs-on: warp-ubuntu-latest-x64-2x env: DBT_PROFILES_DIR: ./ci DBT_ACCOUNT: ${{ vars.DBT_ACCOUNT }} DBT_USER: ${{ vars.DBT_PROD_USER }} DBT_ENV_SECRET_PASSWORD: ${{ secrets.DBT_PROD_PASSWORD }} DBT_TARGET_SCHEMA: analytics steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 with: python-version: "3.12" - name: Restore dbt packages and parse cache uses: WarpBuilds/cache@v1 with: path: | ~/.cache/uv dbt_packages target/partial_parse.msgpack key: ${{ runner.os }}-dbt-${{ hashFiles('uv.lock', 'package-lock.yml') }} restore-keys: | ${{ runner.os }}-dbt- - run: uv sync --frozen - run: uv run dbt deps - name: Build the full project run: uv run dbt build --target prod - name: Publish manifest for state comparison uses: actions/upload-artifact@v4 with: name: dbt-manifest path: target/manifest.json retention-days: 90 ``` Now the consumer. It fetches the manifest from the most recent successful main-branch run, selects modified nodes plus their children, and defers everything else to production: Copy Text ``` name: dbt-pr on: pull_request: permissions: contents: read actions: read jobs: slim-build: runs-on: warp-ubuntu-latest-x64-2x env: DBT_PROFILES_DIR: ./ci DBT_ACCOUNT: ${{ vars.DBT_ACCOUNT }} DBT_USER: ${{ vars.DBT_CI_USER }} DBT_ENV_SECRET_PASSWORD: ${{ secrets.DBT_CI_PASSWORD }} DBT_TARGET_SCHEMA: ci_pr_${{ github.event.pull_request.number }} steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 with: python-version: "3.12" - name: Restore dbt packages and parse cache uses: WarpBuilds/cache@v1 with: path: | ~/.cache/uv dbt_packages target/partial_parse.msgpack key: ${{ runner.os }}-dbt-${{ hashFiles('uv.lock', 'package-lock.yml') }} restore-keys: | ${{ runner.os }}-dbt- - run: uv sync --frozen - run: uv run dbt deps - name: Fetch production manifest continue-on-error: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | run_id=$(gh run list --workflow dbt-main.yml --branch main \ --status success --limit 1 --json databaseId --jq '.[0].databaseId') gh run download "$run_id" --name dbt-manifest --dir prod-state - name: Build and test modified models run: | if [ -f prod-state/manifest.json ]; then uv run dbt build --select 'state:modified+' \ --defer --favor-state --state prod-state else uv run dbt build fi - name: Publish run results if: always() uses: actions/upload-artifact@v4 with: name: dbt-run-results-${{ github.run_id }} path: target/run_results.json ``` Three details in that file do the work. `dbt build` runs each model's tests as soon as that model finishes, in DAG order, so a failing test stops its children instead of letting the run finish and report at the end. Selecting with `state:modified+` carries the tests along, because a test attached to a selected model is selected with it. `--defer --favor-state` tells dbt to resolve `ref()` calls for unselected nodes against the production locations recorded in the state manifest. The per-pull-request schema then holds only the models you actually changed, which keeps warehouse storage and schema cleanup small. Credentials come in as environment variables read by `profiles.yml` through `env_var()`. dbt scrubs values of variables whose names start with `DBT_ENV_SECRET_` from its logs and artifacts, per the [dbt environment variable documentation](https://docs.getdbt.com/docs/build/environment-variables), so the password never lands in `run_results.json` or a debug log. Warehouses that support workload identity federation let you drop the password entirely and add `id-token: write` to `permissions` instead. ## [Sizing](#sizing)Copy Anchor Link Runner size sets how fast the project parses and compiles. It does not set how fast the warehouse executes, so start small and move up only on measured evidence. Rates are from the [pricing page](/pricing). | Runner label | vCPU | RAM | Storage | Price per minute | | --------------------------- | ---- | ---- | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-arm64-2x | 2 | 8GB | 150GB SSD | $0.003 | | warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 | **2x.** The default for dbt. Projects up to a few hundred models parse in seconds with the partial parse cache restored, and the run is dominated by warehouse wait. The `threads` setting in `profiles.yml` controls how many queries dbt keeps in flight, and threads are cheap to raise on 2 vCPUs because each one mostly blocks on the network. **4x.** Projects in the low thousands of models, or workflows that also run `dbt docs generate`, which loads the full catalog into memory before writing it. Also the right size when the job runs seeds that read large CSV files off disk. **8x.** The ceiling worth paying for on a dbt job, and only when a timed parse or compile phase justifies it. At that size the arithmetic is on your side: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13, from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The ARM64 rows carry lower rates at the same shape. Check that your adapter publishes ARM64 Linux wheels before switching, since a missing wheel makes pip compile the connector from source inside the job. ### [Cost model: full builds against state-selected runs](#cost-model-full-builds-against-state-selected-runs)Copy Anchor Link Take a project with 900 models, 300 pull request runs a month, and a nightly full build on main. Assume the full `dbt build` takes 34 minutes of runner wall clock and the average pull request touches 12 models, which `state:modified+` expands to about 60 nodes and 5 minutes. Substitute your own numbers; the shape holds regardless. | Pattern | Nodes built | Wall clock | Runs per month | Cost per run | Cost per month | | -------------------------------- | ----------- | ---------- | -------------- | ------------ | -------------- | | Full build on every pull request | 900 | 34 min | 300 | $0.136 | $40.80 | | Slim run with state:modified+ | 60 | 5 min | 300 | $0.020 | $6.00 | | Nightly full build on main | 900 | 34 min | 30 | $0.136 | $4.08 | The slim pattern plus the nightly full build comes to $10.08 a month of runner time against $40.80 for rebuilding everything on each pull request. Warehouse compute moves in the same direction and is billed separately by your warehouse provider, which is normally the larger of the two lines. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Full builds on every pull request.** The default `dbt build` has no idea what changed, so it materializes the whole project to validate a one-model edit. State comparison is the fix, and the cost is one artifact upload per main-branch run. **A missing or stale state manifest.** `--state` pointing at an empty directory makes `state:modified+` fail, and a manifest from a run that predates a schema change selects the wrong node set. The workflow above handles the first case with `continue-on-error` plus a full-build fallback, so an absent artifact produces a complete run instead of an empty selection. Handle the second by publishing the manifest from every successful main-branch build, including the scheduled one, so the newest artifact is never more than a day old. **Cold `dbt deps` and interpreter setup.** Without a cache, each job re-resolves the Python environment and re-clones every dbt package. `WarpBuilds/cache` is a drop-in replacement for `actions/cache@v4`, keyed the same way, and the cache is enabled by default on Linux runners with entries expiring 7 days after last use. Keying on `hashFiles('uv.lock', 'package-lock.yml')` makes the hit exact, and the `restore-keys` prefix salvages a partial match when one package moves. Carrying cost is visible in the price list: cache storage is $0.20 per GB-month and each write or restore operation is $0.0001, so a 300MB dbt cache costs about $0.06 a month to keep warm. The [caching documentation](/docs/ci/features/caching) covers the split restore and save variants. **Project parse time.** dbt writes `target/partial_parse.msgpack` and reuses it to skip re-parsing unchanged files. Caching that file across runs is worthwhile on large projects, with one caveat: dbt discards it after a version change or a change to the environment variables the project reads, so treat a sudden jump in parse time after a dbt upgrade as expected rather than as a cache bug. **Thread count against warehouse concurrency.** Threads too low leaves the DAG width unused while the warehouse idles. Threads too high queues queries inside the warehouse and adds nothing. WarpBuild CI observability reports CPU and memory from the runner agent correlated with your job logs, which is the fastest way to confirm the runner idles while the warehouse works and that the answer is a `threads` change instead of a bigger machine. When a profile or credential failure only reproduces inside GitHub Actions, the Action Debugger pauses the workflow and opens an SSH session on the live runner so you can run `dbt debug` against the real environment. ## [Proof](#proof)Copy Anchor Link Public repositories running `warp-` labels are checkable evidence, and the sizing argument on this page is the one those repositories already follow: match the label to the job instead of buying one large size for everything. Bitcoin Core routes its Linux jobs across sizes in [bitcoin/bitcoin's ci.yml](https://github.com/bitcoin/bitcoin/blob/master/.github/workflows/ci.yml), with lint on `warp-ubuntu-latest-x64-2x` and the heavier sanitizer matrix on `warp-ubuntu-latest-x64-8x` and `warp-ubuntu-latest-x64-16x` (checked on 2026-08-13). The Trigger.dev end-to-end suite runs its matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x` in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml), checked on 2026-08-13\. The `runs-on` lines are open to read. Teams that run Python transformation code alongside dbt can apply the same cache and sizing approach from the [Python on GitHub Actions page](/solutions/python-github-actions), and the selection idea generalizes past dbt: the [test impact analysis guide](/guides/test-impact-analysis-github-actions) covers the same trade for application test suites. ## [FAQ](#faq)Copy Anchor Link ### [Do dbt runs on GitHub Actions need a large runner?](#do-dbt-runs-on-github-actions-need-a-large-runner)Copy Anchor Link Usually no. dbt pushes the work to your warehouse and the runner mostly submits queries and waits, so warp-ubuntu-latest-x64-2x at $0.004 per minute handles most projects. Move up a size when project parse, jinja compile, or dbt docs generate takes a visible share of the run. ### [How does a pull request run get the production manifest?](#how-does-a-pull-request-run-get-the-production-manifest)Copy Anchor Link Upload target/manifest.json as an artifact from every successful main-branch build, then download it in the pull request job with gh run download and pass the directory to --state. When the download fails, fall back to a full dbt build so a missing manifest produces a complete run instead of an empty selection. ### [What does a dbt pipeline cost on WarpBuild runners?](#what-does-a-dbt-pipeline-cost-on-warpbuild-runners)Copy Anchor Link In the worked model above, state-selected pull request runs cost $0.020 each and $6.00 a month, while the nightly full builds add $4.08\. That makes runner time $10.08 a month; warehouse compute is billed separately by the warehouse provider. --- # Deno Projects on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/deno-github-actions Description: Deno jobs on GitHub Actions start with an empty DENO_DIR. Pin it, restore it with WarpBuilds/cache keyed on deno.lock, and size warp- runners for deno test. # Deno Projects on GitHub Actions Deno jobs on GitHub Actions start with an empty DENO\_DIR. Pin it, restore it with WarpBuilds/cache keyed on deno.lock, and size warp- runners for deno test. Last verified: 2026-08-13 Deno jobs on GitHub Actions repeat the same download every run, because each job starts on a clean virtual machine with an empty `DENO_DIR` and `deno install` refetches the whole dependency graph before the first check runs. Pin `DENO_DIR` to a fixed path, restore it with [WarpBuilds/cache@v1](https://github.com/WarpBuilds/cache) keyed on `deno.lock`, and put the job on a `warp-` runner sized for how many test modules `deno test --parallel` actually has to run. This page covers the cache directory and its key, the workflow that runs lint, check, and test against a warm cache, and the sizing arithmetic for Deno test parallelism. ## [Overview](#overview)Copy Anchor Link Deno keeps downloaded dependencies in a single global cache directory named `DENO_DIR`. Running [deno info](https://docs.deno.com/runtime/reference/cli/info/) with no arguments prints three paths: the DENO\_DIR location, the remote modules cache, and the TypeScript compiler cache. All three live under the same root, so one restored directory brings back remote modules, npm packages, and the type-check output together. [deno install](https://docs.deno.com/runtime/reference/cli/install/) with no arguments installs everything declared in `deno.json` and `package.json` into that global cache, and creates a local `node_modules` directory as well when a `package.json` is present. The `--frozen` flag makes the command error out when the lockfile is out of date, which is the behavior a GitHub Actions job wants. A GitHub Actions job never inherits the previous job's disk. The global cache is empty at the start of every run, so the fetch happens again on every push unless the directory is restored explicitly. There is no WarpBuild fork of a Deno setup action. The [WarpBuild setup actions](/docs/ci/features/caching/setup-actions) cover Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise, so Deno installs through the upstream `denoland/setup-deno@v2` action and the cache is handled as its own step. One detail matters here: `setup-deno` has a `cache` input that defaults to `false` and writes to the GitHub Actions cache when enabled. Leave it off on `warp-` runners and restore `DENO_DIR` with `WarpBuilds/cache@v1`, which is a drop-in replacement for `actions/cache@v4` and stores entries in WarpBuild Cache. Deno work belongs on the Linux sizes described in the [cloud runners documentation](/docs/ci/cloud-runners), and moving a job onto them is a one-line change to `runs-on`. ## [Configuration](#configuration)Copy Anchor Link The workflow installs Deno, restores `DENO_DIR` keyed on `deno.lock`, and runs format, lint, type check, and test on a 4 vCPU WarpBuild runner. Copy Text ``` name: deno-ci on: push: branches: [main] pull_request: env: DENO_DIR: ${{ github.workspace }}/.deno-cache jobs: check: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: denoland/setup-deno@v2 with: deno-version: 2.x - name: Restore Deno cache uses: WarpBuilds/cache@v1 with: path: ${{ env.DENO_DIR }} key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }} restore-keys: | ${{ runner.os }}-deno- - run: deno install --frozen - run: deno fmt --check - run: deno lint - run: deno check src/ - run: deno test --parallel --coverage=coverage ``` Four details carry the behavior. The job-level `env` block pins `DENO_DIR` instead of relying on the per-user default, which differs by operating system. A pinned path gives the cache step one `path` value that stays correct when the same workflow later grows a macOS leg. Keep the cache step after `actions/checkout@v4`, since checkout cleans untracked files in the workspace before it fetches. `key` hashes `deno.lock`, so the entry rolls exactly when the resolved dependency set changes. Hashing `deno.json` instead is the common mistake, because an edit to a task definition then throws away a cache that was still valid. `restore-keys` keeps dependency-update pull requests cheap. When the exact key misses, the action restores the newest entry with the same prefix and `deno install` fetches only the packages that moved. `--frozen` fails the job when `deno.lock` does not match the configuration files, which keeps the cache key honest. Three behaviors from the [caching documentation](/docs/ci/features/caching) shape how entries age. The cache is scoped to key, version, and branch, so an entry written on a feature branch is separate from the one on `main`; seed `main` first and let branch builds pick it up through `restore-keys`. The version part is a hash over the compression tool and the cached paths, so a `DENO_DIR` written on a macOS runner does not restore on a Linux runner even under the same key. Entries expire 7 days after last use. Cache usage is metered at $0.20 per GB-month of storage and $0.0001 per write or restore operation. ## [Sizing](#sizing)Copy Anchor Link Deno repositories run on the Linux sizes. These are the rows that matter: | Runner label | vCPU | Memory | Price per minute | | --------------------------- | ---- | ------ | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | Test parallelism is the reason to care about vCPU count. The [deno test reference](https://docs.deno.com/runtime/reference/cli/test/) states that `--parallel` runs test modules in parallel and that parallelism defaults to the number of available CPUs or the value of the `DENO_JOBS` environment variable. Without the flag, the suite runs in one process and a larger machine changes nothing. With the flag, wall time falls only while there are at least as many test modules as workers, so a suite of six files stops improving somewhere around 4 to 8 vCPUs. `deno check` and the implicit type check inside `deno test` are the other consumers. `deno test` type-checks local modules unless `--no-check` is passed, and type checking is memory-hungry on a large program rather than core-hungry: 16 GB on the 4x size covers most projects, and a heap failure is the signal to move up. The ARM64 row is worth a run when the service also ships on ARM, since `warp-ubuntu-latest-arm64-4x` costs $0.006 per minute against $0.008 for the x64 machine of the same shape. Full specifications for each size are on the [Linux x64 runner page](/runners/linux-x64). ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners at [github.com/pricing](https://github.com/pricing) and in the [minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the 4-core Linux larger runner is $0.012 per minute. Stated as list-price arithmetic: `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13. Take a repository running 1,200 jobs a month at 5 minutes each on a 4 vCPU machine with a warm `DENO_DIR`, which is 6,000 runner minutes. | Scenario | Rate | Monthly minutes | Monthly cost | | ------------------------------------------------ | ---------- | --------------- | ------------ | | GitHub-hosted 4-core Linux larger runner | $0.012/min | 6,000 | $72.00 | | warp-ubuntu-latest-x64-4x | $0.008/min | 6,000 | $48.00 | | Cache storage and operations (500 MB, 2,400 ops) | see above | n/a | $0.34 | Cache metering adds $0.10 for storage and $0.24 for operations, so the WarpBuild column lands at $48.34\. Rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four problems account for most slow Deno pipelines on GitHub Actions. **A cold DENO\_DIR.** Without a restored cache directory, every job refetches remote modules and npm packages and recompiles the TypeScript it just discarded. Confirm the fix from the job log: a warm run shows the cache step restoring an entry and `deno install --frozen` finishing without a long resolution phase. A key that always misses usually hashes a file that changes on every commit, or was only ever written on a feature branch. The mechanics of key design are covered in [cache key](/glossary/cache-key). **Type checking twice.** `deno check` in one step and `deno test` in the next both type-check the same local modules. When the check step has already run and the results are in the restored compiler cache, the repeat is cheap; when the cache is cold, it is paid twice. Splitting check and test into separate jobs makes this worse, since each job restores its own copy. **npm interop.** A `package.json` in the repository makes `deno install` create a local `node_modules` directory alongside the global cache. That directory is not covered by a `DENO_DIR` cache entry, so a job that depends on it either reinstalls or needs a second cache path. Prefer restoring `DENO_DIR` alone and letting `deno install` relink `node_modules`, which is faster than restoring a large tree of small files. **Unbounded parallelism.** `--parallel` starts one worker per available CPU, and tests that bind a fixed port or share a temporary database collide as soon as the machine gets bigger. Cap the workers with `DENO_JOBS` rather than dropping back to a smaller runner: Copy Text ``` test: runs-on: warp-ubuntu-latest-x64-8x env: DENO_DIR: ${{ github.workspace }}/.deno-cache DENO_JOBS: 4 steps: - uses: actions/checkout@v4 - uses: denoland/setup-deno@v2 - uses: WarpBuilds/cache@v1 with: path: ${{ env.DENO_DIR }} key: ${{ runner.os }}-deno-${{ hashFiles('**/deno.lock') }} restore-keys: | ${{ runner.os }}-deno- - run: deno install --frozen - run: deno test --parallel --no-check ``` When a job is slow and the cause is unclear, WarpBuild CI observability reports system metrics from the runner agent alongside GitHub Actions job logs, which separates a CPU-bound test phase from an install stuck on the network. The Action Debugger pauses a workflow and opens a session on the runner, so you can list the restored `DENO_DIR` on the machine and confirm what the cache step actually wrote. ## [Proof](#proof)Copy Anchor Link Public OSS repositories running `warp-` labels are citable evidence, and the workflow files are open to read. The Trigger.dev TypeScript monorepo runs its end-to-end matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x` in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml), checked on 2026-08-13. Every cost number on this page carries a source and a date: WarpBuild rates come from the [pricing page](/pricing), and the GitHub list price comes from GitHub's own billing reference, checked on 2026-08-13. If the repository mixes Deno with other JavaScript runtimes, [Bun builds and tests on GitHub Actions](/solutions/bun-github-actions) covers the install cache path and key for Bun, and [Node.js builds on GitHub Actions](/solutions/nodejs-github-actions) covers the package-manager store caching for the rest of the tree. ## [FAQ](#faq)Copy Anchor Link **Which directory holds the Deno dependency cache?** `DENO_DIR`. It holds the remote module cache, the npm package cache, and the TypeScript compiler cache, and `deno info` prints its location. Set `DENO_DIR` explicitly in the workflow so the same path works on Linux and macOS jobs, then restore that one path with `WarpBuilds/cache@v1`. **What should a Deno cache key hash?** `deno.lock`. Hash the lockfile with `hashFiles` and add a `restore-keys` prefix, so a dependency bump restores the previous entry and `deno install` fetches only what changed instead of rebuilding the cache from empty. **Does a bigger runner make deno test faster?** Only with `--parallel`. Deno runs test modules in one process unless `--parallel` is passed, and parallelism then defaults to the number of available CPUs or the value of `DENO_JOBS`, so extra vCPUs pay off once both the flag and enough test modules are present. **Should I use the cache input on setup-deno?** No. That input writes to the GitHub Actions cache. On `warp-` runners, leave it at its default of `false` and restore `DENO_DIR` with `WarpBuilds/cache@v1` so the entry lands in WarpBuild Cache. --- # Django Test Suites on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/django-github-actions Description: Django tests on GitHub Actions lose time to migration replay and 2 vCPU runners. Run manage.py test --parallel on a warp- label with a Postgres service. # Django Test Suites on GitHub Actions Django tests on GitHub Actions lose time to migration replay and 2 vCPU runners. Run manage.py test --parallel on a warp- label with a Postgres service. Last verified: 2026-08-13 A Django suite on GitHub Actions loses its time in two places: rebuilding the test database by replaying every migration, and running tests close to serially because the standard hosted Linux runner for private repositories carries 2 vCPUs. Point `runs-on` at a `warp-` label with enough cores for `manage.py test --parallel`, cache the dependency directory, and decide once how the test schema gets built. ## [Overview](#overview)Copy Anchor Link A Django test job runs four phases inside the billed minutes: dependency install, test database creation, test execution, and teardown. The Postgres or MySQL service container adds a fifth phase before the first step, because the runner creates it and waits on its health check ([GitHub service container documentation](https://docs.github.com/en/actions/tutorials/use-containerized-services/use-docker-service-containers)). The second phase is the one specific to Django. `manage.py test` creates a database named `test_`, applies migrations to it, and destroys it when the run finishes ([Django testing documentation](https://docs.djangoproject.com/en/stable/topics/testing/overview/#the-test-database)). That step scales with migration count rather than test count, so a project carrying 400 migrations pays for all 400 on every pull request, including the ones that only rename a field. The third phase is where runner size shows up. GitHub's standard hosted Linux runner for private repositories carries 2 vCPUs and 8 GB of RAM per GitHub's published specs ([GitHub-hosted runner specs](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), checked on 2026-08-13), so `--parallel auto` starts two test processes and the suite runs close to end to end. WarpBuild runners register against your organization under `warp-` labels, run as ephemeral virtual machines allocated for one job, and carry the same tooling as GitHub-hosted images, so `manage.py`, `psycopg`, and the `services` block behave the same way. The label list and sizes are in the [cloud runners documentation](/docs/ci/cloud-runners). The rest of this page covers a working workflow with a database service and a parallel run, the two ways to get the test schema built, sizing that budgets cores for test processes and the database together, and the bottlenecks that a bigger runner does not fix. For the pytest side of a Python codebase see [faster Python test suites on GitHub Actions](/solutions/python-github-actions). ## [Configuration](#configuration)Copy Anchor Link The workflow below runs a Django suite on `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB, 150GB SSD) against a Postgres service container, checks that migrations are in sync, and runs the suite across parallel processes. Copy Text ``` name: django-tests on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-8x services: postgres: image: postgres:16 env: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app ports: - 5432:5432 options: >- --health-cmd "pg_isready -h 127.0.0.1 -U app -d app" --health-interval 5s --health-timeout 5s --health-retries 20 --health-start-period 10s env: DATABASE_URL: postgres://app:app@127.0.0.1:5432/app DJANGO_SETTINGS_MODULE: config.settings.test steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-python@v6 with: python-version: "3.12" cache: pip cache-dependency-path: requirements/test.txt - name: Install dependencies run: pip install -r requirements/test.txt tblib - name: Check that models and migrations agree run: python manage.py makemigrations --check --dry-run - name: Run tests in parallel run: python manage.py test --parallel 6 --shuffle -v 2 ``` Three details in that job are worth reading closely. The test runner owns the test database. The `services` block gives Django a server to connect to, and `manage.py test` creates `test_app` on it and applies migrations there, so no separate `migrate` step is needed before the suite. The `makemigrations --check --dry-run` step is the migration guard: it fails the job when a model change landed without its migration, which is the failure that otherwise shows up as a broken deploy rather than a red check. `--parallel` sets the process count, and `auto` reads the core count of the machine ([Django test command reference](https://docs.djangoproject.com/en/stable/ref/django-admin/#cmdoption-test-parallel)). Django clones the test database once per process, so eight processes mean eight clones. `tblib` is in the install line because Django needs it to carry tracebacks back from subprocesses; without it a failure in a parallel run arrives without its stack. Caching runs through `WarpBuilds/setup-python`, a drop-in replacement for `actions/setup-python` that routes pip, pipenv, and poetry caching through the WarpBuild cache with no other workflow change ([setup actions documentation](/docs/ci/features/caching/setup-actions)). For a project that installs into a virtualenv or holds compiled wheels, add an explicit entry keyed on the lockfile: Copy Text ``` - name: Cache the virtualenv uses: WarpBuilds/cache@v1 with: path: .venv key: ${{ runner.os }}-venv-${{ hashFiles('requirements/test.txt') }} restore-keys: | ${{ runner.os }}-venv- ``` ### [The migration state option](#the-migration-state-option)Copy Anchor Link Every Django suite makes one choice about how the test schema is built, and it is worth making deliberately. **Replay migrations.** The default. Django applies the full migration history to the test database, so data migrations run and the schema matches what a production deploy produces. The cost is a fixed per run charge that grows with every migration merged. **Build the schema from models.** Point `MIGRATION_MODULES` at `None` for every app in a test settings module and Django treats the apps as unmigrated, creating tables directly from the model definitions ([MIGRATION\_MODULES reference](https://docs.djangoproject.com/en/stable/ref/settings/#migration-modules)): Copy Text ``` # config/settings/test.py from .base import * # noqa: F403 class DisableMigrations: def __contains__(self, item): return True def __getitem__(self, item): return None MIGRATION_MODULES = DisableMigrations() ``` Suites on pytest-django get the same behavior from the `--no-migrations` flag ([pytest-django database documentation](https://pytest-django.readthedocs.io/en/latest/database.html)). The tradeoff is real: data migrations never run, so any test that reads a row created by a migration fails. Keep `makemigrations --check --dry-run` in the job when you take this path, since it is now the only step that notices a missing migration. `--keepdb` is the third option and it behaves differently on ephemeral runners. It preserves the test database between runs, and each WarpBuild job gets a fresh virtual machine, so it pays off only when one job invokes the suite more than once or when the database lives outside the job. ## [Sizing](#sizing)Copy Anchor Link Two consumers share the machine: the test processes and the database service. Size for both. Every Linux x64 size carries 4 GB of RAM per vCPU on 150GB SSD storage, so cores are usually the binding constraint for a Django suite, and the practical rule is to set `--parallel` one or two below the core count so Postgres keeps a core while the suite runs. Rates are from the [pricing page](/pricing), billed per minute, checked on 2026-08-13. | Label | vCPU | RAM | Suggested \--parallel | USD per minute | | --------------------------- | ---- | ----- | --------------------- | -------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 3 | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 6 to 7 | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 12 to 14 | $0.032 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | 6 to 7 | $0.012 | Three limits decide where you land on that ladder. **Clone setup cost.** Each process gets its own clone of the test database, so the setup phase repeats per process while the execution phase divides across them. Raising `--parallel` from 8 to 16 therefore adds eight more clones before it saves any test time. Run once with `-v 2` and read the "Cloning test database" lines to see what that phase costs on your schema. **Connections.** Postgres defaults `max_connections` to 100 ([Postgres configuration reference](https://www.postgresql.org/docs/current/runtime-config-connection.html), checked on 2026-08-13), and the official image ships that default. Each test process holds at least one connection to its clone, and a suite that sets `CONN_MAX_AGE` or runs threaded live server tests holds more, so check the ceiling before pushing past 16 processes. **Memory per process.** Each process imports the app, the settings graph, and its own fixtures. Budget from your own peak, then take the first size that clears it. WarpBuild CI observability reports CPU and memory percentiles from the runner agent next to the job, which is faster than instrumenting the run yourself. ### [What the sizes cost](#what-the-sizes-cost)Copy Anchor Link Take a suite that runs 16 minutes on GitHub's standard 2 vCPU hosted runner and 6 minutes at `--parallel 6` on 8 vCPUs, across 300 runs a month. Substitute your own two measurements, because parallel efficiency depends on how much of the run is database setup. GitHub's per-minute list prices for x64 Linux are $0.006 for the standard 2 vCPU runner on private repositories and $0.022 for the 8-core larger runner, per the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Setup | Wall clock | Rate per minute | Cost per run | Cost per month | | ----------------------------------- | ---------- | --------------- | ------------ | -------------- | | GitHub hosted, 2 vCPU | 16 min | $0.006 | $0.096 | $28.80 | | GitHub hosted larger runner, 8 vCPU | 6 min | $0.022 | $0.132 | $39.60 | | warp-ubuntu-latest-x64-8x | 6 min | $0.016 | $0.096 | $28.80 | | warp-ubuntu-latest-arm64-8x | 6 min | $0.012 | $0.072 | $21.60 | The x64 row lands on the same monthly figure as the 2 vCPU hosted runner while returning 10 minutes per run to whoever is waiting on the pull request. The list-price gap is the reason: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13\. The ARM64 row is available to any Django project whose dependencies publish aarch64 wheels, which covers `psycopg[binary]`, `Pillow`, and most of the ecosystem. For the sizing method applied to Python suites in general, see [sizing runners for Python test suites](/guides/sizing-runners-for-python-test-suites). ## [Bottlenecks](#bottlenecks)Copy Anchor Link ### [Migration replay on every run](#migration-replay-on-every-run)Copy Anchor Link This is the Django-specific one, and it is invisible in test timings because it happens before the first test. A long history of migrations turns into a fixed floor under every run, and no runner size removes it. The fixes are the migration state options above, plus squashing old migrations so the history that gets replayed is shorter. ### [Cloning and database contention](#cloning-and-database-contention)Copy Anchor Link Cloning is serial work that grows with `--parallel`, and a schema with hundreds of tables makes each clone slower. When wall clock stops falling as you add processes while CPU stays low, the clone phase or the shared server is the limit rather than the core count. Timing a run with `-v 2` separates the two, and the [guide to service containers](/guides/service-containers-github-actions) covers the health check and startup side of the same machine. ### [Cold installs and source builds](#cold-installs-and-source-builds)Copy Anchor Link Without a restored cache, every job resolves and downloads the full requirements set. Where a wheel is missing for the platform and Python version, pip falls back to the sdist and compiles in the job, which is the usual reason a Django install step swings from 20 seconds to several minutes. Install `psycopg[binary]` rather than building `psycopg2` from source, key the cache on the requirements or lock file, and let the `restore-keys` prefix cover partial matches so a one-line dependency bump avoids a full reinstall. ### [Work that stays serial](#work-that-stays-serial)Copy Anchor Link Collecting static files, running system checks, and importing the settings graph happen once per process and shrink for nobody when the runner gets bigger. Move `collectstatic` out of the test job when the suite does not need it, and keep heavy imports out of module scope in test files. When a failure only reproduces in GitHub Actions, the Action Debugger pauses the workflow and opens an SSH session on the live runner, which beats re-running a 16 minute suite with extra print statements. ## [Proof](#proof)Copy Anchor Link Public repositories are checkable evidence for the pattern on this page. [sequinstream/sequin](https://github.com/sequinstream/sequin) runs its signoff job on the `warp-ubuntu-latest-arm64-32x` label with a Postgres service container and a Redis service container, both gated on health checks, and the configuration is readable in the [signoff-commit.yml workflow file](https://github.com/sequinstream/sequin/blob/main/.github/workflows/signoff-commit.yml) (checked on 2026-08-13). That is the same shape as the Django job above: a database service on the job machine, a health gate, and a test run sized against the label. For the Python half, [nobodywho-ooo/nobodywho](https://github.com/nobodywho-ooo/nobodywho) runs its Python jobs on `warp-ubuntu-latest-x64-4x` with dependency caching in [python-ci.yml](https://github.com/nobodywho-ooo/nobodywho/blob/main/.github/workflows/python-ci.yml) (checked on 2026-08-13). Both labels come from the catalog on the [cloud runners page](/docs/ci/cloud-runners). Moving a Django suite over is a one-line change to `runs-on` plus the cache action swap. When the migrations themselves are the workflow you are building, [database migrations on GitHub Actions](/solutions/database-migrations-github-actions) covers the deploy side. ## [FAQ](#faq)Copy Anchor Link **How do I run Django tests in parallel on GitHub Actions?** Run `python manage.py test --parallel auto` on a runner with more than 2 vCPUs. Django starts one test process per core and clones the test database for each process, so the runner size sets the parallelism directly. On `warp-ubuntu-latest-x64-8x` that is 8 processes, and setting an explicit `--parallel 6` or `7` leaves cores for the Postgres service container sharing the machine. **Should I skip migrations when running Django tests in GitHub Actions?** Skip them when the schema is the only thing your tests need. Setting `MIGRATION_MODULES` to `None` for every app in a test settings module makes Django build tables straight from the models, which removes the per-run migration replay. Keep migrations on for any suite that depends on data written by a data migration, and keep a `makemigrations --check --dry-run` step either way. **What does a Django test job cost on WarpBuild runners?** --- # Docker Builds on GitHub Actions: Remote Builders | WarpBuild URL: https://www.warpbuild.com/solutions/docker-builds-github-actions Description: Docker builds crawl on GitHub Actions because the layer cache dies with the runner. Move the build to a WarpBuild remote builder profile in one swap. # Docker Builds on GitHub Actions: Remote Builders Docker builds crawl on GitHub Actions because the layer cache dies with the runner. Move the build to a WarpBuild remote builder profile in one swap. Last verified: 2026-08-13 Docker builds are slow on GitHub Actions because every job starts on a fresh runner with an empty layer cache, so BuildKit rebuilds layers that have not changed and then spends more time exporting the cache back over the network. WarpBuild remote Docker builders move the build onto a dedicated builder VM that keeps its layer cache on local disk between builds, and the workflow change is one action swap plus a profile name. This page covers the exact configuration, a before and after workflow, how a builder profile shares its cache and its billing session across parallel jobs, how to size a profile against your matrix width, and what the whole arrangement costs per month. ## [Overview](#overview)Copy Anchor Link A Docker build on a GitHub-hosted runner pays four costs that have nothing to do with compiling your application. The first is a cold layer store. The runner is ephemeral, BuildKit starts with nothing in `/var/lib/buildkit`, and every layer in the Dockerfile is a miss on the first pass. The second is cache transport. The usual fix is `cache-to: type=gha,mode=max` and `cache-from: type=gha`, which writes every layer blob into the GitHub Actions cache at the end of the build and pulls it back at the start of the next one. That turns local disk reads into network round trips, and it competes for a shared budget: GitHub limits the total size of all caches in a repository to 10 GB by default and evicts entries that have not been accessed in seven days ([GitHub Actions dependency caching docs](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching), checked on 2026-08-13). A few multi-stage images with fat base layers exhaust that budget, and once eviction starts, the cache misses come back. The third is the runner shape. A 2 vCPU runner runs `npm ci`, `go build`, or a Gradle assembly inside a `RUN` layer on two cores, and no amount of caching helps a build step that is compute bound. The fourth is emulation. Building `linux/arm64` on an x64 runner without a native builder means QEMU, which turns a compile step into an interpreted compile step. A remote builder profile addresses the first two directly. Remote Docker builders run outside the GitHub Actions runner with a persistent layer cache on local disks, so repeat builds reuse layers instead of rebuilding them. The runner keeps its normal job: check out the repository, send the build context to the builder, and receive the resulting image or push it to a registry. The build itself happens on hardware sized for building. Builders work with WarpBuild runners and with non-WarpBuild runners, including GitHub-hosted ones, because the connection is made over the API rather than through the runner image. Two companion pages go deeper on the mechanism and on the cache design: [how remote Docker builders work on GitHub Actions](/guides/remote-docker-builders) covers the builder lifecycle and the buildx remote driver, and [Docker layer caching on GitHub Actions](/guides/docker-layer-caching-github-actions) covers Dockerfile ordering and what actually invalidates a layer. ## [Configuration](#configuration)Copy Anchor Link Three steps. Create a builder profile, swap the action, delete the buildx setup step. ### [Step 1: create a builder profile](#step-1-create-a-builder-profile)Copy Anchor Link Go to the [Docker Builders page](https://app.warpbuild.com/ci/docker-builders) in the dashboard and create a profile. A profile carries three settings that matter later: the machine size, the architectures it serves (amd64, arm64, or both), and its name, which is the string your workflow references. Name it after the thing it builds, for example `app-images-x64`, because the profile name is also the cache boundary. ### [Step 2: swap the action](#step-2-swap-the-action)Copy Anchor Link WarpBuild ships a drop-in replacement for `docker/build-push-action`. The replacement takes the same inputs plus `profile-name`. Before, on a GitHub-hosted runner with the Actions cache backend: Copy Text ``` name: build-image on: pull_request: jobs: docker: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Buildx uses: docker/setup-buildx-action@v3 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max ``` After, on a WarpBuild runner with a builder profile: Copy Text ``` name: build-image on: pull_request: jobs: docker: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} profile-name: "app-images-x64" timeout: 600000 ``` The whole change, as a diff: Copy Text ``` jobs: docker: - runs-on: ubuntu-latest + runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - - name: Set up Buildx - uses: docker/setup-buildx-action@v3 - - name: Build and push - uses: docker/build-push-action@v6 + uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max + profile-name: "app-images-x64" + timeout: 600000 ``` ### [Step 3: drop the buildx setup step](#step-3-drop-the-buildx-setup-step)Copy Anchor Link Remove `docker/setup-buildx-action` when its only purpose was creating a builder. The WarpBuild action assigns a builder from the profile, creates a buildx instance backed by the remote driver, runs the build there, and closes the session when the job finishes. `timeout` is the wait for the builder to become ready in milliseconds, and it defaults to ten minutes. Drop `cache-from` and `cache-to` at the same time. The builder profile keeps layers on its own disk, so the export and import round trips have nothing left to do. ### [Running from a non-WarpBuild runner](#running-from-a-non-warpbuild-runner)Copy Anchor Link On a GitHub-hosted runner, or any other runner, add an API key. Everything else is identical. Copy Text ``` jobs: docker: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Build and push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ghcr.io/${{ github.repository }}:${{ github.sha }} profile-name: "app-images-x64" api-key: ${{ secrets.WARPBUILD_API_KEY }} timeout: 600000 ``` The API key is required only off WarpBuild runners, where the runner already carries its own credentials. ### [Multi-architecture builds](#multi-architecture-builds)Copy Anchor Link Set `platforms` and point the job at a profile that has both architectures enabled in the dashboard. A profile with only amd64 enabled fails an arm64 build with `exec format error`. Copy Text ``` jobs: images: runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: service: [api, worker, web, migrator, cron, docs] steps: - uses: actions/checkout@v4 - name: Build and push ${{ matrix.service }} uses: Warpbuilds/build-push-action@v6 with: context: services/${{ matrix.service }} push: true tags: ghcr.io/${{ github.repository }}/${{ matrix.service }}:${{ github.sha }} platforms: linux/amd64,linux/arm64 profile-name: "app-images-multi" ``` Each architecture runs on its own builder instance, which has a billing consequence covered under Sizing. For jobs that need a native ARM64 runner rather than a native ARM64 builder, the same workflow runs on `warp-ubuntu-latest-arm64-4x`; see [Linux ARM64 runners for GitHub Actions](/runners/linux-arm64) and [multi-platform Docker builds for amd64 and arm64](/guides/multi-platform-docker-builds). ### [Outside GitHub Actions](#outside-github-actions)Copy Anchor Link Builders are assigned with a `POST` to `/api/v1/builders/assign` carrying the profile name, polled at `/api/v1/builders/{id}/details` until the status is `ready`, wired into buildx with `docker buildx create --driver remote` and the returned TLS certificates, and released with a `POST` to `/api/v1/builder-session-requests/complete`. The full sequence, including the certificate handling and the cache reset endpoint, is in the [Docker builders documentation](/docs/ci/docker-builders). Billing runs until the session is completed, so the release call belongs in a trap or an always block. Runners themselves can sit anywhere. [WarpBuild cloud runners](/docs/ci/cloud-runners) are the default, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. A builder profile serves all of them the same way. ## [Sizing](#sizing)Copy Anchor Link Two machines get sized here, and they are independent. The size of the Docker builder has no relationship to the size of the GitHub Actions runner. ### [The builder](#the-builder)Copy Anchor Link The documented guidance sets a floor. There is no limit on the number of builds that can run concurrently on a builder profile, and the recommended minimum resource requirement is approximately 8 vCPU and 16GB memory per concurrent build job. Multiply that by the widest fan-out that hits the profile at once. A six way matrix needs about 48 vCPU and 96GB. A twelve way matrix needs about 96 vCPU and 192GB, and at that width the architecture limits start to matter. | Builder size | Price per minute | Architectures | Price per vCPU minute | Concurrent jobs at the 8 vCPU floor | | --------------------------- | ---------------- | ------------------- | --------------------- | ----------------------------------- | | 16 vCPU, 32GB, 100GB disk | $0.06 | amd64, arm64, multi | $0.00375 | 2 | | 32 vCPU, 64GB, 200GB disk | $0.12 | amd64, arm64, multi | $0.00375 | 4 | | 64 vCPU, 128GB, 200GB disk | $0.24 | amd64, arm64, multi | $0.00375 | 8 | | 96 vCPU, 192GB, 600GB disk | $0.36 | amd64 | $0.00375 | 12 | | 96 vCPU, 192GB, 2TB disk | $0.52 | amd64 | $0.0054 | 12 | | 192 vCPU, 384GB, 600GB disk | $0.72 | amd64 | $0.00375 | 24 | | 192 vCPU, 384GB, 2TB disk | $0.88 | amd64 | $0.0046 | 24 | Rates from the [WarpBuild pricing page](/pricing), checked on 2026-08-13\. The arm64 and multi-architecture profiles top out at 64 vCPU, so a matrix wider than eight concurrent multi-architecture builds needs a second profile rather than a bigger one. The two large disk options exist for build contexts and layer stores that outgrow 600GB, and they are the only two rows where the price per vCPU minute moves. ### [The runner](#the-runner)Copy Anchor Link The runner ships the build context and waits. A 4 vCPU runner covers that for most repositories, and the money follows the builder rather than the runner. | Runner label | vCPU | RAM | Storage | Price per minute | Price per vCPU minute | | --------------------------- | ---- | ---- | --------- | ---------------- | --------------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | $0.002 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | $0.002 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | $0.002 | | warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 | $0.0015 | | warp-ubuntu-latest-arm64-8x | 8 | 32GB | 150GB SSD | $0.012 | $0.0015 | Rates from the [WarpBuild pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. Compare the two price per vCPU minute columns before you decide what to move. Builder cores list at $0.00375 per vCPU minute and runner cores list at $0.002 per vCPU minute, so a builder profile is a more expensive way to rent CPU. What you buy with the difference is a layer cache that survives between jobs and a machine that several jobs share at once. A build that misses the cache on every layer anyway is better off staying on a larger runner. ### [Worked cost model, single image](#worked-cost-model-single-image)Copy Anchor Link Assumptions: 1,000 image builds per month, each build occupying the builder for 4 minutes, each runner job lasting 6 minutes, one 16 vCPU profile. | Line | Arithmetic | Monthly | | ---------------- | ---------------------- | ------- | | Builder sessions | 1,000 x 4 min x $0.06 | $240.00 | | Runner minutes | 1,000 x 6 min x $0.008 | $48.00 | | Total | $288.00 | | The runner line is worth checking against the GitHub-hosted list price for the same shape. Those 6,000 runner minutes on the 4 vCPU, 16GB GitHub-hosted Linux larger runner list at $0.012 per minute, which is $72.00 against $48.00 on `warp-ubuntu-latest-x64-4x` ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and [GitHub pricing](https://github.com/pricing), both checked on 2026-08-13). Charges apply to both machines. Runner minutes and builder session minutes are separate resources on the invoice. ### [Worked cost model, six way matrix](#worked-cost-model-six-way-matrix)Copy Anchor Link Assumptions: 300 pull request builds per month, each building six images in parallel on one amd64 profile, each job running 6 minutes with 4 minutes of builder time, and a session that stays open 7 minutes from the first builder start to the last job completion. Six concurrent jobs at the 8 vCPU floor means 48 vCPU and 96GB, so the profile is the 64 vCPU, 128GB size at $0.24 per minute. | Line | Arithmetic | Monthly | | ---------------- | ----------------------------- | ------- | | Builder sessions | 300 x 7 min x $0.24 | $504.00 | | Runner minutes | 300 x 6 jobs x 6 min x $0.008 | $86.40 | | Total | $590.40 | | The same 10,800 runner minutes on the GitHub-hosted 4 vCPU larger runner list at $0.012 per minute, which is $129.60 against $86.40 (GitHub billing reference, checked on 2026-08-13). Switch that profile to multi-architecture and the builder line doubles to $1,008.00 per month, because each architecture opens its own session and the two are billed independently. Build arm64 images only where you ship them. ## [Bottlenecks](#bottlenecks)Copy Anchor Link ### [One profile is one VM](#one-profile-is-one-vm)Copy Anchor Link Each builder profile corresponds to one optimized, dedicated Docker builder virtual machine with caching. Multiple jobs can run on the same builder profile in parallel, and those jobs run on the same VM in parallel. That single sentence explains most of the surprises teams hit. Parallel jobs on a profile share CPU, memory, and disk. Under-provision the profile and a wide matrix keeps working while every job slows down. Six jobs on a 16 vCPU profile get under 3 vCPU each while the sizing guidance asks for 8. Parallel jobs also share the cache, and the sharing is eventually consistent. Layers written by one build may not be visible to another build running at the same moment, and they become available to later builds after synchronization. A matrix of six images that all start from the same base layer will, on the first run, have several of them build that base layer independently. The second run reads it from cache. Cache invalidation follows layer changes in the ordinary Docker way. Editing a file that a `COPY` instruction pulls in invalidates that layer and every layer after it, on a remote builder exactly as on a local one. Ordering the Dockerfile so dependency manifests are copied and installed before application source is the change that decides your hit rate, and no builder makes a badly ordered Dockerfile cache well. Cache lifetime has a floor and a ceiling. A profile that goes unused for more than 10 days is reset automatically, and you can reset it deliberately through the API when a poisoned layer needs clearing. ### [Sessions, timeouts, and the shape of a job](#sessions-timeouts-and-the-shape-of-a-job)Copy Anchor Link Billing is per session, measured from when the builder action starts until the job completes. Overlapping jobs on one profile share one session, billed from the first start to the last completion. A job that assigns a builder early and then spends 15 minutes running tests before the build step keeps that session open the whole time. Put the build step next to the builder setup, or use `Warpbuilds/docker-configure` immediately before the build when you need custom build commands. Builders carry their own idle timeout so an abandoned session does not bill forever. Deleting a builder profile while builds are in flight is a different problem: wait for the builds to finish first. ### [What a remote builder does not fix](#what-a-remote-builder-does-not-fix)Copy Anchor Link It does not speed up test steps, application compiles that live outside the Docker build, or registry pushes bound by your own network. It does not repair a Dockerfile that copies the entire repository before installing dependencies. It does not shrink a 4GB build context that has no `.dockerignore`, and the context still travels from the runner to the builder on every build. Watch for the case where the base image itself is the cost. An image that pulls a 2GB base layer on every build spends its time on registry reads, and a builder profile helps there only after the first build has that layer on local disk. ## [Proof](#proof)Copy Anchor Link The `Warpbuilds/build-push-action` repository is public, and its end to end workflow is the clearest available evidence of the configuration on this page. The [warp-e2e workflow](https://github.com/WarpBuilds/build-push-action/blob/main/.github/workflows/warp-e2e.yml) runs on `runs-on: warp-ubuntu-latest-x64-4x`, builds a test Dockerfile with `profile-name: super-fast-builder`, and then asserts the result rather than trusting it. The workflow inspects the active buildx instance and fails unless the driver is `remote` and the node endpoint is a `tcp://` address, which proves the build left the runner. It then rebuilds the same image and checks that the digest matches, which proves the profile served the layers from cache on the second pass. That workflow runs on pull requests and pushes to `main`, so the evidence is current and anyone can read the logs. The [WarpBuild runner agent](https://github.com/WarpBuilds/warpbuild-agent) is open source as well, if you want to see what runs on the runner side. Named customer results and logos live on the [WarpBuild customers page](/customers). Every cost number on this page carries its arithmetic, its source, and a checked-on date, and the same numbers appear on the [pricing page](/pricing). Start with the $10 free credits, point one noisy image build at a 16 vCPU profile, and read your own invoice before moving the rest. ## [FAQ](#faq)Copy Anchor Link ### [Do I still need docker/setup-buildx-action?](#do-i-still-need-dockersetup-buildx-action)Copy Anchor Link No, if the only job that step did was create a buildx builder. `Warpbuilds/build-push-action` creates a remote buildx builder from your profile, points the build at it, and tears it down afterwards. Keep `docker/setup-buildx-action` only if you use it for something else, such as installing a specific BuildKit version for a build that stays on the runner. ### [Do I need cache-from and cache-to with a builder profile?](#do-i-need-cache-from-and-cache-to-with-a-builder-profile)Copy Anchor Link No. Each builder profile maps to one dedicated builder VM that keeps the layer cache on its local disk, so a repeat build reuses layers without any cache export or import step. Remove the `cache-from` and `cache-to` lines that pointed at `type=gha`, and the 10 GB per repository GitHub Actions cache limit stops applying to your image layers. ### [How is a builder profile billed when five jobs build in parallel?](#how-is-a-builder-profile-billed-when-five-jobs-build-in-parallel)Copy Anchor Link Builders are billed per session. Jobs on the same profile with overlapping execution share one session, billed from the first job's builder start to the last job's completion. A multi-architecture profile runs each architecture on a separate builder instance, so it opens one session per architecture and each is billed independently. ### [How large should a builder profile be?](#how-large-should-a-builder-profile-be)Copy Anchor Link The documented recommended minimum is approximately 8 vCPU and 16GB memory per concurrent build job on the profile. A six way matrix that builds all six images at once needs about 48 vCPU and 96GB, so the 64 vCPU, 128GB, 200GB profile at $0.24 per minute is the first size that clears it. --- # Docker Compose Integration Tests on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/docker-compose-integration-tests-github-actions Description: Run Docker Compose integration tests on GitHub Actions with a separate image pull step, health-gated startup, and runner sizing from real per-minute rates. # Docker Compose Integration Tests on GitHub Actions Run Docker Compose integration tests on GitHub Actions with a separate image pull step, health-gated startup, and runner sizing from real per-minute rates. Last verified: 2026-08-13 To run Docker Compose integration tests on GitHub Actions, pull the stack images in their own step, bring the stack up with `docker compose up -d --wait` behind real health checks, then run the suite against the running services. The tests themselves are rarely the slow part: image pulls, database seeding, and serialized startup dominate the job, and every one of them gets worse on a 2 vCPU runner. This page covers the workflow and compose file that gate on health rather than on `sleep`, how to size the runner against the memory and disk a multi-container stack actually consumes, when a snapshot runner is the right way to carry prepared images between runs, and what the job costs per month against GitHub-hosted list prices. ## [Overview](#overview)Copy Anchor Link A compose stack is a different workload from a unit test job. A unit test job needs cores and a package cache. A compose stack needs memory for five or six processes at once, disk for the images and the volumes, a Docker daemon with room to work, and enough spare CPU that health checks answer inside their timeouts while the application boots. Every GitHub Actions job starts on a fresh virtual machine with an empty container store. That means the same image layers come down from the registry on every run, the same migrations run against an empty database, and the same fixtures load again. None of that work changes between runs on a quiet branch, and all of it is charged by the minute. Compose work belongs on the Linux runners: the [cloud runners documentation](/docs/ci/cloud-runners) states that macOS runners do not support nested virtualization and cannot run Docker. The Linux runners carry the same tooling as GitHub-hosted runners and the swap is a label change. Two parts of the WarpBuild product surface matter for this workload. Snapshot runners capture a runner VM mid-workflow so later jobs boot from that state, and remote Docker builders keep a persistent layer cache for the images your stack builds. The surface also includes CI observability, an MCP server, and the Action Debugger. One boundary before the configuration. This page is about running a stack that already has images. Building those images faster is a separate problem with a separate answer, covered on [Docker builds on GitHub Actions with remote builders](/solutions/docker-builds-github-actions). ## [Configuration](#configuration)Copy Anchor Link Two files decide how long the job takes: the compose file, because it defines what "ready" means, and the workflow, because it decides what gets timed. ### [The compose file](#the-compose-file)Copy Anchor Link `docker compose up -d` returns when containers start, which for Postgres is several seconds before it accepts connections. A `sleep 30` covers that gap on a good day and produces a flaky suite on a bad one. Health checks replace the guess. Copy Text ``` services: postgres: image: postgres:17-alpine environment: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app_test ports: - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U app -d app_test"] interval: 2s timeout: 3s retries: 30 start_period: 5s redis: image: redis:7-alpine ports: - "6379:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 2s timeout: 3s retries: 30 api: image: ghcr.io/acme/api:${API_TAG:-main} depends_on: postgres: condition: service_healthy redis: condition: service_healthy ports: - "8080:8080" healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"] interval: 2s timeout: 3s retries: 60 start_period: 10s ``` The `interval` value is the resolution of your readiness signal. At the common default of 30 seconds, a service that became ready one second after a poll waits another 29 seconds before compose notices. Two seconds costs a handful of extra probe executions and removes that dead time. `start_period` gives a slow starter room to fail its early probes without counting them as failures. ### [The workflow](#the-workflow)Copy Anchor Link Copy Text ``` name: integration-tests on: pull_request: jobs: compose: runs-on: warp-ubuntu-latest-x64-8x timeout-minutes: 30 steps: - uses: actions/checkout@v5 - name: Log in to the registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Pull stack images run: docker compose -f compose.ci.yaml pull --quiet - name: Start the stack run: docker compose -f compose.ci.yaml up -d --wait --wait-timeout 300 - name: Seed the test database run: ./scripts/seed.sh - name: Run integration tests run: npm run test:integration env: DATABASE_URL: postgres://app:app@localhost:5432/app_test REDIS_URL: redis://localhost:6379 - name: Dump service state on failure if: failure() run: | docker compose -f compose.ci.yaml ps docker compose -f compose.ci.yaml logs --no-color --tail=200 - name: Tear down if: always() run: docker compose -f compose.ci.yaml down -v ``` `docker compose pull` sits in its own step so GitHub Actions times it separately in the job log. That single number tells you whether image transfer or application startup is the thing to attack, and it is the number to watch after any change on this page. `--wait` blocks until every service with a healthcheck reports healthy, and `--wait-timeout` is in seconds. Services without a healthcheck are treated as ready when they start, so an undeclared health check turns `--wait` back into a guess. The failure step runs before teardown so the logs still exist. `down -v` removes the volumes, which matters on a snapshot runner where the disk survives the job. ### [Carrying images between runs with a snapshot runner](#carrying-images-between-runs-with-a-snapshot-runner)Copy Anchor Link A snapshot runner captures the VM, including the container store under `/var/lib/docker`, and later jobs boot from that capture. The images your stack pulls are already on disk, so `docker compose pull` checks digests instead of transferring layers. The label syntax lives in `runs-on`. Use `snapshot.enabled=true` to boot from the base image and create a snapshot, and `snapshot.key=` to boot from an existing snapshot for that alias. Copy Text ``` jobs: compose: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=integration-stack' }} steps: - uses: actions/checkout@v5 - name: Pull stack images run: docker compose -f compose.ci.yaml pull --quiet - name: Start the stack run: docker compose -f compose.ci.yaml up -d --wait --wait-timeout 300 - name: Run integration tests run: npm run test:integration - name: Stop containers before snapshotting if: github.ref == 'refs/heads/main' run: docker compose -f compose.ci.yaml down - name: Clean credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.docker/config.json $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "integration-stack" fail-on-error: true wait-timeout-minutes: 60 ``` `down` without `-v` keeps the images and named volumes on the disk that the snapshot captures, so a seeded database volume rides along with the images. Pull requests read that alias and never write it, which keeps one branch from poisoning everyone else's starting state. The boundaries are worth reading before you adopt this. Snapshot boot takes 45 to 60 seconds, which is the honest trade against a cold start, so a stack whose pull and seed cost less than a minute should stay on a plain runner. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners; BYOC runs on AWS, GCP, and Azure, and the snapshot labels are silently ignored there, as they are on Windows and macOS runners. Snapshots are deleted after 15 days. `/tmp` does not persist across the boot. Anyone who can read the alias can boot from the snapshot, which is why the credential cleanup step exists and why public repositories need extra care. The full rules are in the [snapshot runners documentation](/docs/ci/features/snapshot-runners), and [snapshot runners for GitHub Actions](/runners/snapshot) covers the wider set of use cases. Runner and snapshot behavior in this example is driven from the workflow file. ## [Sizing](#sizing)Copy Anchor Link Two resources decide the size: memory, because containers hold it all at once, and disk, because images and volumes accumulate inside a fixed budget. ### [Memory](#memory)Copy Anchor Link Sum the peak resident memory of every container, add the test process, and add headroom for the Docker daemon and the page cache the database depends on. An example five service stack: | Component in the example stack | Assumed peak memory | | ------------------------------ | ------------------- | | Application under test | 2.0GB | | PostgreSQL | 1.0GB | | Redis | 0.5GB | | Message broker | 2.5GB | | Search node, 1GB JVM heap | 2.0GB | | Test runner process | 1.5GB | | Total | 9.5GB | Those are assumptions for illustration; read your own numbers with `docker stats --no-stream` at the end of a local run. The shape of the answer holds either way. A stack near 10GB does not fit the 8GB on `warp-ubuntu-latest-x64-2x`, and the failure mode is an exit code 137 in whichever container the kernel picked, usually the database, usually two thirds of the way through the suite. For five or more services, `warp-ubuntu-latest-x64-8x` with 32GB is the practical floor. `warp-ubuntu-latest-x64-16x` with 64GB becomes the floor once the stack adds a browser for end to end tests, a second JVM service, or several replicas of the application. Three service stacks fit `warp-ubuntu-latest-x64-4x` with 16GB. ### [Disk](#disk)Copy Anchor Link Every Linux runner size carries 150GB SSD, and the runner image already uses part of it for preinstalled tooling. Run `df -h /` in a job to read the free space you actually start with, then budget against these consumers: pulled image layers, container writable layers, named volumes holding seeded data, build contexts if the job also builds images, and test artifacts such as recordings and coverage output. Two habits keep a job inside the budget. Run `docker compose down -v` in an `always()` step so volumes from a failed run do not survive into a snapshot. Prune deliberately rather than by reflex on snapshot runners, since `docker image prune -a` throws away the images the snapshot exists to carry. ### [Rates and the cost model](#rates-and-the-cost-model)Copy Anchor Link | Runner label | vCPU | RAM | Storage | Per minute | GitHub-hosted comparable | GitHub list | Lower by | | -------------------------- | ---- | ----- | --------- | ---------- | ------------------------------------------------- | ----------- | ---------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | ubuntu-latest, 2 vCPU 8GB on private repositories | $0.006 | 33 percent | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 | 32-core Linux larger runner | $0.082 | 22 percent | WarpBuild rates from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners). GitHub list prices from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. Assumptions for the model: 800 pull request runs per month, 9 minutes per run, one 8 vCPU runner per run. | Line | Arithmetic | Monthly | | ------------------------------------------------------------ | -------------------- | ------- | | Runner minutes on warp-ubuntu-latest-x64-8x | 800 x 9 min x $0.016 | $115.20 | | Same 7,200 minutes on the GitHub-hosted 8-core larger runner | 7,200 x $0.022 | $158.40 | Adding a snapshot adds two lines. Snapshot restore lists at $0.04 per job and snapshot storage at $0.025 per snapshot-hour on the [pricing page](/pricing), checked on 2026-08-13\. Restores across 800 runs are $32.00, and one live alias held for a full 15 day retention window is 360 hours x $0.025, or $9.00. That is $41.00 per month, or $0.05125 per run, which buys 3.2 minutes of runner time at $0.016 per minute. The 45 to 60 second snapshot boot comes out of the same budget, so treat roughly 4 minutes of removed pull and seed time per run as the break-even point. Measure your pull step first, then decide. ## [Bottlenecks](#bottlenecks)Copy Anchor Link ### [Image pulls on every run](#image-pulls-on-every-run)Copy Anchor Link The container store is empty at the start of every job, so every layer travels again. Put `docker compose pull` in its own step and read the number. Authenticate to the registry even for public images so anonymous pull limits stop applying. Pin tags to digests so the pull is deterministic and a moving `latest` cannot invalidate a snapshot. Where the pull still dominates, that is the case a snapshot is for. More options are in [how to reduce Docker image pull time in GitHub Actions](/answers/how-do-i-reduce-docker-image-pull-time-in-github-actions). ### [Database seeding](#database-seeding)Copy Anchor Link Migrations plus fixtures against an empty database repeat identically on every run. Three ways out, in increasing order of effort. Bake the migrated schema into a database image so the container starts with the schema in place. Use a template database and create each test database with `CREATE DATABASE app_test TEMPLATE app_seeded`, which copies files instead of replaying inserts. Carry the seeded volume in a snapshot, which works when the schema changes on a merge cadence rather than on every pull request. ### [Sequential service startup](#sequential-service-startup)Copy Anchor Link `depends_on` with `condition: service_healthy` is correct and it serializes the boot. A chain of four services each waiting on the previous one pays four health check intervals in series before the suite starts. Depend on what a service genuinely needs, keep the intervals short, and let independent services start in parallel rather than chaining them for tidiness. Watch the interaction with runner size. Health checks are processes competing for the same cores as the starting services. On a saturated 2 vCPU runner probes time out, compose retries, and the stack reports unhealthy for reasons that look like application bugs. [How to cut GitHub Actions cold starts](/guides/github-actions-cold-starts) covers the queue and boot time that sits in front of all of this. ### [Stacks that outgrew the runner](#stacks-that-outgrew-the-runner)Copy Anchor Link The signature of a stack over its memory budget is exit code 137 with no application error, appearing in a different container each time. The signature of a stack over its disk budget is `no space left on device` during a pull or a volume write. Both look like flaky tests in the report and neither is fixed by retrying. Size the runner from the table above, and read the failure for what it is. ## [Proof](#proof)Copy Anchor Link Public repositories running `warp-` labels are the most direct evidence available. The [warp-e2e workflow](https://github.com/WarpBuilds/build-push-action/blob/main/.github/workflows/warp-e2e.yml) in the `Warpbuilds/build-push-action` repository runs on `runs-on: warp-ubuntu-latest-x64-4x` on pull requests and pushes to `main`, so the label shape and the runner behavior are both readable in public logs. The [WarpBuild runner agent](https://github.com/WarpBuilds/warpbuild-agent) is open source if you want to see what runs on the runner itself. Every cost number on this page carries its arithmetic, its source, and a checked-on date, and the same numbers appear on the [pricing page](/pricing). Point one integration workflow at an 8x runner, read the pull step timing before and after, and decide on a snapshot from your own numbers. ## [FAQ](#faq)Copy Anchor Link ### [What runner size does a Docker Compose stack need?](#what-runner-size-does-a-docker-compose-stack-need)Copy Anchor Link Add the peak memory of every container plus the test process, then pick the first size that clears it with headroom. A five service stack with a database, a broker, and a search node lands near 10GB, which puts it on `warp-ubuntu-latest-x64-8x` at 32GB. Three service stacks fit the 4x size at 16GB, and 2 vCPU with 8GB is where compose stacks start getting killed. ### [Do snapshot runners work with Docker Compose?](#do-snapshot-runners-work-with-docker-compose)Copy Anchor Link Yes, on WarpBuild Cloud Ubuntu runners. Add `snapshot.key=` to the `runs-on` label and the job boots a VM that already carries the container images and volumes captured by an earlier run. Snapshot boot takes 45 to 60 seconds, and the labels are silently ignored on BYOC, Windows, and macOS runners. ### [Why does docker compose up return before my services are ready?](#why-does-docker-compose-up-return-before-my-services-are-ready)Copy Anchor Link Without `healthcheck` blocks, compose considers a container ready as soon as it starts, which is well before Postgres accepts connections. Define a healthcheck per service and start the stack with `docker compose up -d --wait` so the step blocks until every service reports healthy. ### [How do I stop image pulls from dominating every run?](#how-do-i-stop-image-pulls-from-dominating-every-run)Copy Anchor Link Put `docker compose pull` in its own step so the job log times it, authenticate to the registry so anonymous pull limits stop applying, pin digests so the pull is deterministic, and carry the images between runs in a snapshot when the pull time exceeds the snapshot cost. --- # .NET Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/dotnet-github-actions Description: .NET builds on GitHub Actions run on WarpBuild Windows runners from 4 to 32 vCPU at $0.016 to $0.128 per minute, with NuGet caching through actions/cache. # .NET Builds on GitHub Actions .NET builds on GitHub Actions run on WarpBuild Windows runners from 4 to 32 vCPU at $0.016 to $0.128 per minute, with NuGet caching through actions/cache. Last verified: 2026-08-13 A .NET build on a Windows runner in GitHub Actions needs three things: enough vCPUs for MSBuild to compile projects in parallel, a NuGet package cache that survives between jobs, and an image carrying the Visual Studio toolset your solution expects. WarpBuild provides Windows runners from 4 to 32 vCPUs at $0.016 to $0.128 per minute, selected by pointing `runs-on` at a label such as `warp-windows-latest-x64-8x`. Switching is a one-line change to the workflow file. The sections below cover the exact configuration including NuGet caching, which runner size fits restore-heavy and test-heavy solutions, the bottlenecks specific to MSBuild and the .NET SDK, and how to pick between the Windows Server 2022, Windows Server 2025, and Visual Studio 2026 images. ## [Overview](#overview)Copy Anchor Link A .NET pipeline spends its Windows minutes in four places: NuGet restore, MSBuild walking the project graph and invoking the compiler, test execution, and packaging or publish steps. Restore is network and disk bound. Compilation parallelizes across independent projects. Tests parallelize as far as the test framework allows. Publish is mostly single-threaded I/O. Windows runners earn their place when the target requires Windows: .NET Framework projects on net48 and earlier, WPF and WinForms applications, C++/CLI assemblies, VSIX extensions, MSIX packaging, and anything that P/Invokes Win32 during tests. Cross-platform class libraries and ASP.NET Core services can build on cheaper Linux runners, so a common split is Linux jobs for the portable code and Windows jobs for the desktop and Framework targets. The [larger runners cost guide](/guides/github-actions-larger-runners-cost) covers that split in detail. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Windows fleet comes in three image families, each in four sizes from 4 to 32 vCPUs with 256GB SSDs: Windows Server 2022 under the `warp-windows-latest-x64-` labels, Windows Server 2025 under `warp-windows-2025-x64-`, and Windows Server 2025 with Visual Studio 2026 under `warp-windows-2025-vs2026-x64-`. The full matrix is in the [cloud runners documentation](/docs/ci/cloud-runners) and on the [Windows runner hub](/runners/windows). Every runner is an ephemeral VM, freshly allocated for the job and destroyed afterward. The images carry the same tooling as GitHub-hosted Windows runners, documented in the [preinstalled software list](/docs/ci/preinstalled-software), so MSBuild, the .NET SDKs, and Visual Studio Build Tools are present without setup steps. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. ## [Configuration](#configuration)Copy Anchor Link Here is a working .NET pipeline on `warp-windows-latest-x64-8x`. It restores with a committed lock file, caches the NuGet packages folder with `actions/cache`, and reuses the restore and build outputs in later steps. Copy Text ``` name: dotnet on: push: branches: [main] pull_request: env: NUGET_PACKAGES: ${{ github.workspace }}\.nuget\packages DOTNET_NOLOGO: true jobs: build-and-test: runs-on: warp-windows-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: global-json-file: global.json - uses: actions/cache@v4 with: path: ${{ github.workspace }}\.nuget\packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} restore-keys: | ${{ runner.os }}-nuget- - run: dotnet restore --locked-mode - run: dotnet build --no-restore --configuration Release - run: dotnet test --no-build --configuration Release ``` Four details matter here. **Cache NuGet with `actions/cache`, and only with `actions/cache`.** WarpBuild's built-in cache is available on Linux runners only, so Windows jobs use GitHub's cache service through `actions/cache` or a local packages folder committed to the workspace drive. The cache entry lands in GitHub's backend and counts against GitHub's 10 GB per-repository cache limit, so keep the cached path narrow: the packages folder, without `bin` and `obj` directories. **Pin `NUGET_PACKAGES` inside the workspace.** By default NuGet extracts packages to the user profile. Setting `NUGET_PACKAGES` to a workspace path gives the cache step one deterministic folder to save and restore, keeps extraction on the same SSD as the checkout, and avoids profile-relative paths that differ between images. **Commit lock files and restore with `--locked-mode`.** Set `RestorePackagesWithLockFile` to `true` in `Directory.Build.props`, run a local restore to generate `packages.lock.json` per project, and commit the files. Restore then skips dependency resolution and verifies the graph against the lock file, and `hashFiles('**/packages.lock.json')` gives the cache a key that changes exactly when the dependency set changes. Without lock files, key on `hashFiles('**/*.csproj')` instead and accept coarser invalidation. **Build once, then reuse.** `--no-restore` on the build step and `--no-build` on the test step stop the SDK from repeating work inside the job. Each repeated implicit restore or rebuild pays MSBuild startup and evaluation again on the same machine. The default shell on Windows runners is PowerShell, so multi-line `run` steps use PowerShell syntax and backslash paths work as written above. ### [Choosing a Windows image](#choosing-a-windows-image)Copy Anchor Link Three image families cover different toolset needs, all at the same per-minute rates. **`warp-windows-latest-x64-`** resolves to Windows Server 2022 today, with `warp-windows-2022-x64-` as the pinned alias. It ships Visual Studio 2022 and matches the tooling of GitHub's windows-2022 image. This is the default choice for solutions that build with the VS 2022 toolset, which as of 2026 is most production .NET work. **`warp-windows-2025-x64-`** runs the Windows Server 2025 base and still ships Visual Studio 2022\. Pick it to build on the newer OS base, for example when tests exercise OS behavior that differs between Server 2022 and Server 2025, while keeping the same compiler toolset. **`warp-windows-2025-vs2026-x64-`** is a transitional label set: the same Windows Server 2025 base with Visual Studio 2026 installed instead of Visual Studio 2022\. Pick it when the solution needs the VS 2026 toolset, such as a newer MSVC toolchain for C++/CLI projects or MSBuild behavior introduced with VS 2026\. The labels mirror GitHub's own rollout of the Visual Studio 2026 image, and Visual Studio 2026 may become the default on the Windows Server 2025 labels in a later update. If the toolset version matters to your build, pin the explicit label rather than `latest`. Projects mixing C++ and .NET in one solution should confirm the platform toolset version against the image before switching image families; the sibling page on [C++ builds](/solutions/cpp-github-actions) covers the native side. ## [Sizing](#sizing)Copy Anchor Link The Windows catalog, with per-minute rates from the [WarpBuild pricing page](/pricing): | Runner label | OS | vCPU | Memory | Storage | Price per minute | | --------------------------- | ------------------- | ---- | ------ | --------- | ---------------- | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16 GB | 256GB SSD | $0.016 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64 GB | 256GB SSD | $0.064 | | warp-windows-latest-x64-32x | Windows Server 2022 | 32 | 128 GB | 256GB SSD | $0.128 | The `warp-windows-2025-x64-` and `warp-windows-2025-vs2026-x64-` labels come in the same four sizes at the same rates. Windows 2 vCPU runners were removed on June 8, 2026, so 4 vCPU is the smallest Windows size. Restore-heavy and test-heavy solutions size differently. **Restore-heavy solutions** have large package graphs and modest amounts of code: a service with hundreds of transitive NuGet dependencies and twenty projects. Restore time is bound by network transfer and disk extraction, and extra vCPUs shorten neither. Keep these on `warp-windows-latest-x64-4x` or `warp-windows-latest-x64-8x` and spend the effort on lock files and a warm cache, which remove most of the restore minutes outright. **Wide build graphs** reward cores. MSBuild schedules independent projects across available vCPUs, so a solution with 100 projects and a shallow dependency tree keeps 16 vCPUs busy through the compile phase. `warp-windows-latest-x64-16x` fits here. Long dependency chains cap this: projects on the critical path build one after another regardless of core count, so a deep narrow graph gains little above 8 vCPUs. **Test-heavy solutions** depend on how the tests parallelize. xUnit runs test collections in parallel within an assembly, and VSTest can run assemblies concurrently when configured. Parallel-safe suites saturate `warp-windows-latest-x64-16x` or `warp-windows-latest-x64-32x` and finish in proportionally fewer minutes. Serialized integration tests leave cores idle, so keep them on the 8x size and shard across jobs instead, keeping in mind that each extra job pays its own restore and warmup. Memory rarely binds before CPU does, but MSBuild worker nodes, the Roslyn compiler server, and test hosts add up on large solutions; the 32 GB on the 8x size is a comfortable floor for solutions above 50 projects. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners. Windows larger runners meter at $0.022 per minute for 4 vCPU, $0.042 for 8 vCPU, $0.082 for 16 vCPU, and $0.162 for 32 vCPU. Rates are from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. Take a .NET solution whose pull request pipeline consumes 20,000 Windows runner-minutes per month on 8 vCPU machines: | Line item | Rate | Volume | Monthly cost | | ------------------------------------------ | ----------------- | -------------- | ------------ | | GitHub-hosted Windows 8 vCPU larger runner | $0.042 per minute | 20,000 minutes | $840.00 | | warp-windows-latest-x64-8x | $0.032 per minute | 20,000 minutes | $640.00 | The WarpBuild total is $640.00 against $840.00 for the same minutes, a difference of $200.00 per month. As a standalone rate comparison: warp-windows-latest-x64-8x (8 vCPU, 32 GB) costs $0.032 per minute against $0.042 per minute for the 8-core Windows larger runner (8 vCPU, 32 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13\. The model holds minutes equal on both sides. NuGet caching through `actions/cache` uses GitHub's cache service, which carries no per-operation charge, so the runner minutes are the whole WarpBuild bill here. Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four bottlenecks account for most slow .NET pipelines on GitHub Actions. **NuGet restore over the network.** Every runner starts as a fresh VM, so an uncached job resolves the full dependency graph, downloads every package from nuget.org and any private feeds, and extracts them to disk before the compiler runs. On solutions with heavy package graphs this dwarfs compilation. The fix is layered: lock files with `--locked-mode` remove resolution work, `actions/cache` on the packages folder removes the downloads, and `NUGET_PACKAGES` inside the workspace keeps extraction on the local SSD. When the cache misses after a lock file change, `restore-keys` falls back to the newest previous entry so restore only fetches the packages that changed. **MSBuild node reuse across jobs.** MSBuild keeps worker node processes and the Roslyn compiler server warm between invocations on the same machine. On ephemeral runners those processes cannot outlive the job, so a workflow that splits restore, build, and test into three separate GitHub Actions jobs pays VM boot, SDK warmup, cache restore, and MSBuild startup three times over. Consolidate the sequence into one job, as in the workflow above, and let `--no-restore` and `--no-build` carry state between steps. Reserve separate jobs for work that genuinely runs in parallel, such as sharded test suites. **First-run JIT.** The first `dotnet` invocation on a fresh VM pays one-time costs: assembly loading and JIT compilation of MSBuild tasks, NuGet components, analyzers, and source generators that lack precompiled native images. This shows up as a slow first step that no amount of caching removes. Minimizing the number of separate `dotnet` invocations bounds the cost to one warmup per job, and pruning unused analyzers from the build trims what the JIT has to process on every run. **Large solution graphs.** A 200-project `.sln` makes every pipeline step pay graph evaluation, even for changes touching one leaf project. Solution filters (`.slnf` files) scope restore, build, and test to a subsystem, so pull request jobs build the filter covering the changed area while the full solution builds on main. Deep dependency chains are the other cost: the critical path serializes, so flattening project references where possible raises the parallelism that a 16x or 32x runner can actually use. Telling these apart is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so a job stuck on single-threaded restore looks visibly different from a build saturating all cores through a wide graph. Broader tactics for the Windows fleet, including image choice and job structure, are in the [guide to faster Windows builds on GitHub Actions](/guides/faster-windows-github-actions). ## [Proof](#proof)Copy Anchor Link The runner-side agent is open source at [github.com/WarpBuilds/warpbuild-agent](https://github.com/WarpBuilds/warpbuild-agent), and the [cloud runners documentation](/docs/ci/cloud-runners) publishes the full Windows catalog with prices, image contents, and change history for independent verification. ## [FAQ](#faq)Copy Anchor Link **Which WarpBuild Windows runner size should a .NET solution start with?** Start on warp-windows-latest-x64-8x at $0.032 per minute. Watch the CPU chart in WarpBuild's CI observability during the build step. Move to warp-windows-latest-x64-16x when all 8 vCPUs stay busy through compilation, and keep formatting and analyzer-only jobs on warp-windows-latest-x64-4x. **Can I use the WarpBuild cache on Windows runners?** No. WarpBuild's built-in cache is available on Linux runners only. On Windows runners, cache the NuGet packages folder with `actions/cache`, or point `NUGET_PACKAGES` at a folder inside the workspace and cache that path. **Do WarpBuild Windows runners include Visual Studio and the .NET SDKs?** Yes. The images carry the same tooling as the GitHub-hosted Windows images. The Windows Server 2022 and Windows Server 2025 labels ship Visual Studio 2022, and the warp-windows-2025-vs2026-x64 labels ship Visual Studio 2026. **Is WarpBuild SOC 2 compliant?** The audit evidence is published at [trust.warpbuild.com](https://trust.warpbuild.com/). --- # Electron App Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/electron-github-actions Description: Build Electron installers on GitHub Actions with a three-platform matrix across WarpBuild Linux, Windows, and macOS runners, with per-platform caching. # Electron App Builds on GitHub Actions Build Electron installers on GitHub Actions with a three-platform matrix across WarpBuild Linux, Windows, and macOS runners, with per-platform caching. Last verified: 2026-08-13 An Electron app that ships installers for Linux, Windows, and macOS needs a GitHub Actions matrix with one job per platform, because native modules, code signing, and installer formats are produced by platform-specific toolchains. On WarpBuild that matrix is three `runs-on` labels, `warp-ubuntu-latest-x64-8x`, `warp-windows-latest-x64-8x`, and `warp-macos-15-arm64-6x`, with the cache configured separately on the Windows leg because WarpBuild Cache is not supported on Windows runners. This page gives the matrix workflow with per-platform cache steps, the runner sizes and per-minute rates for all three platforms, the four bottlenecks that dominate Electron pipelines, and the list-price arithmetic against GitHub-hosted runners of the same shape. ## [Overview](#overview)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so all three legs of an Electron release matrix run on the same account with the same billing model. Moving an existing Electron workflow across is a change to the `runs-on` line in each leg plus the cache step on Windows. The reason Electron needs the full matrix comes from three separate constraints stacking up. Native modules are compiled against the Electron ABI for one platform and architecture at a time. A package such as `better-sqlite3`, `serialport`, or `sharp` either downloads a prebuilt binary matching the Electron version and platform, or it compiles locally through `node-gyp`. Neither path produces a Windows binary from a Linux machine without a full cross toolchain. Code signing is the harder constraint. Apple signing and notarization run through `codesign` and `notarytool`, which exist only on macOS, so the macOS leg cannot move to Linux under any configuration. Windows installer signing expects the Windows signing toolchain and a certificate the runner can reach. Installer formats then follow from the platform: AppImage, deb, and rpm on Linux, NSIS or MSI on Windows, dmg and zip on macOS. Some of these can be produced off-platform with extra tooling, and the resulting pipeline is fragile enough that most teams stop trying and run three jobs. Disk is the other thing Electron pipelines run into early. Every packaging target copies the unpacked application directory, and the Electron binary distributions, the packaging toolchains, and the finished installers all land on the same volume. The WarpBuild storage per platform is 150GB SSD on Linux runners, 256GB SSD on Windows runners, and 120GB SSD on the 6 vCPU macOS labels or 270GB SSD on the 12 vCPU macOS labels. The macOS runners come in multiple sizes and configurations per chip, which is where the 120GB and 270GB choice comes from. One caching note before the workflow. The [caching documentation](/docs/ci/features/caching) states that WarpBuild Cache is not supported on Windows runners, and it also explains that a cache entry is versioned by the compression tool and paths, so an entry saved on a macOS runner cannot be restored on a Linux runner. Both facts push the same way: give each matrix leg its own cache step and its own key. ## [Configuration](#configuration)Copy Anchor Link The workflow below builds and packages all three platforms from one matrix. The Linux and macOS legs use `WarpBuilds/cache`, the Windows leg uses `actions/cache`, and each leg caches the npm store plus the Electron and packaging caches at the paths that platform actually uses. Copy Text ``` name: electron-release on: push: branches: [main] pull_request: jobs: package: name: package-${{ matrix.platform }} runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: - platform: linux runner: warp-ubuntu-latest-x64-8x build: npx electron-builder --linux --publish never - platform: windows runner: warp-windows-latest-x64-8x build: npx electron-builder --win --publish never - platform: macos runner: warp-macos-15-arm64-6x build: npx electron-builder --mac --publish never steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Restore caches (Linux) if: matrix.platform == 'linux' uses: WarpBuilds/cache@v1 with: path: | ~/.npm ~/.cache/electron ~/.cache/electron-builder key: linux-electron-${{ hashFiles('package-lock.json') }} restore-keys: | linux-electron- - name: Restore caches (macOS) if: matrix.platform == 'macos' uses: WarpBuilds/cache@v1 with: path: | ~/.npm ~/Library/Caches/electron ~/Library/Caches/electron-builder key: macos-electron-${{ hashFiles('package-lock.json') }} restore-keys: | macos-electron- - name: Restore caches (Windows) if: matrix.platform == 'windows' uses: actions/cache@v4 with: path: | ~\AppData\Local\npm-cache ~\AppData\Local\electron\Cache ~\AppData\Local\electron-builder\Cache key: windows-electron-${{ hashFiles('package-lock.json') }} restore-keys: | windows-electron- - run: npm ci - name: Build installers run: ${{ matrix.build }} env: CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - uses: actions/upload-artifact@v4 with: name: ${{ matrix.platform }}-installers path: dist/*.* if-no-files-found: error ``` Four details in that file are worth stating explicitly. `fail-fast: false` keeps a Windows signing failure from cancelling a macOS notarization that is already halfway through Apple's queue. Without it one flaky leg costs you the whole matrix run. The cache key hashes `package-lock.json` and carries a platform prefix, so the three legs never collide. The `restore-keys` prefix is what makes a dependency bump cheap: on an exact miss the action restores the most recent entry with the same prefix, and the install fetches only what changed. The signing environment variables are set on every leg and read only where they apply. The Linux packaging step ignores them, so a single `env` block is simpler than a conditional one. `--publish never` keeps packaging separate from release upload. Upload artifacts from the matrix, then run a single release job that collects all three sets, which is far easier to re-run than a matrix leg that half-published. ## [Sizing](#sizing)Copy Anchor Link The starting point for an Electron matrix is 8 vCPU on Linux and Windows and 6 vCPU on macOS. The catalog rows and per-minute rates for all three platforms: | Runner label | OS | vCPU | Memory | Storage | Price per minute | | --------------------------- | ------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64GB | 150GB SSD | $0.032 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32GB | 256GB SSD | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64GB | 256GB SSD | $0.064 | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | Size each leg for what it can actually use. The renderer bundle step, whether it runs through Vite, webpack, or esbuild, is the part that scales with cores, so it rewards 16 vCPU on a large application. Native module compilation parallelizes across packages and also rewards cores. Installer compression is the phase where extra cores stop helping, because the compressor for a single target largely runs on one thread while writing a lot of bytes to disk. Move the macOS leg to `warp-macos-15-arm64-12x` for disk before you move it for speed. A universal build packaged into dmg and zip targets keeps several copies of the unpacked application at once, and 120GB gets tight on a large application with many targets. The 12 vCPU label carries 270GB. ### [List prices against GitHub-hosted runners of the same shape](#list-prices-against-github-hosted-runners-of-the-same-shape)Copy Anchor Link GitHub publishes per-minute list prices on the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and runner shapes on the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Both were checked on 2026-08-13. | Platform | WarpBuild label and shape | WarpBuild rate | GitHub-hosted equivalent | GitHub rate | | -------- | -------------------------------------------- | -------------- | -------------------------------------------------------- | ----------- | | Linux | warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) | $0.016 | 8-core Linux larger runner (8 vCPU, 32 GB) | $0.022 | | Linux | warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) | $0.032 | 16-core Linux larger runner (16 vCPU, 64 GB) | $0.042 | | Windows | warp-windows-latest-x64-8x (8 vCPU, 32 GB) | $0.032 | 8-core Windows larger runner (8 vCPU, 32 GB) | $0.042 | | Windows | warp-windows-latest-x64-16x (16 vCPU, 64 GB) | $0.064 | 16-core Windows larger runner (16 vCPU, 64 GB) | $0.082 | | macOS | warp-macos-15-arm64-6x (6 vCPU, 14 GB) | $0.08 | Largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB) | $0.102 | Stated as arithmetic, warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. warp-windows-latest-x64-8x (8 vCPU, 32 GB) costs $0.032 per minute against $0.042 per minute for the 8-core Windows larger runner: 24 percent lower list price. warp-macos-15-arm64-6x (6 vCPU, 14 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price, with one more vCPU and the same memory. GitHub list prices checked on 2026-08-13. ### [Worked monthly model](#worked-monthly-model)Copy Anchor Link Take a desktop team whose release matrix runs 300 times a month across pull requests and nightly builds. Each run takes 9 minutes on the Linux leg, 14 minutes on the Windows leg, and 12 minutes on the macOS leg, with warm caches on all three. | Leg | Monthly minutes | WarpBuild rate | WarpBuild cost | GitHub-hosted rate | GitHub-hosted cost | | --------------- | --------------- | -------------- | -------------- | ------------------ | ------------------ | | Linux, 8 vCPU | 2,700 | $0.016 | $43.20 | $0.022 | $59.40 | | Windows, 8 vCPU | 4,200 | $0.032 | $134.40 | $0.042 | $176.40 | | macOS, 6 vCPU | 3,600 | $0.08 | $288.00 | $0.102 | $367.20 | | Total | 10,500 | $465.60 | $603.00 | | | The gap on list prices alone is $137.40 a month on this workload, and the WarpBuild side is billed per minute. Cache metering adds a small amount on top: storage is $0.20 per GB-month and each cache write or restore is $0.0001, so an 8GB Electron cache footprint with 6,000 operations a month comes to about $2.20. Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four bottlenecks account for most of the wall time in an Electron pipeline on GitHub Actions. **Native module rebuilds, once per platform.** Every leg of the matrix compiles or downloads its own copy of each native dependency, because the binary is specific to the platform, the architecture, and the Electron ABI. When a prebuilt binary exists for that combination the install just downloads it; when it does not, `node-gyp` compiles C++ and pulls down the Electron header files first. Pin the Electron version so the ABI stays stable and the prebuilt path keeps hitting, and keep the npm store in the cache so the download half is local. This is the phase that most rewards 8 vCPU or more, since packages compile in parallel. **Electron binary downloads.** Packaging an Electron application means fetching the Electron distribution for each target platform and architecture, and those archives are large. Packaging tooling downloads more on top: the code signing helpers, the NSIS toolchain for Windows targets, and the app image tooling for Linux targets all land in a separate packaging cache directory. Left uncached, every matrix leg re-downloads all of it on every run. The cache steps in the workflow above cover both directories on each platform, which is the single highest-value cache in an Electron repository. **Code signing on macOS and Windows.** On macOS the certificate has to be imported into a keychain the job creates, unlocked, and given a partition list, or `codesign` blocks waiting on an authorization prompt that never appears on a headless runner and the job hangs until the timeout fires. Notarization then submits the artifact to Apple and waits for a verdict, which is wall-clock time on someone else's queue, so a larger runner does nothing for it. On Windows the same shape appears with different tooling: the certificate must be reachable from an ephemeral machine, which rules out a physical token and pushes teams to a certificate file in secrets or a cloud signing service, and the signature needs a timestamp so it stays valid past the certificate's expiry. Keep the whole signing sequence inside a single step so the keychain or certificate state cannot drift between steps. **Installer packaging.** After the application is built, each target rewrites it: asar packing, then compression into the target format, then a checksum pass. The work is dominated by disk throughput and by a compressor that mostly runs on one thread, so it responds to fewer, larger targets rather than to more cores. Two practical levers: cut the target list on pull request runs and package everything only on release runs, and watch the disk on the macOS leg, where 120GB on the 6 vCPU labels is the first ceiling a wide target matrix hits. When a leg is slow and the cause is unclear, CI observability reports OpenTelemetry-based system metrics from the runner agent correlated with GitHub Actions job logs, which separates a CPU-bound compile from a packaging step stuck on disk. For a failure that will not reproduce on a laptop, the Action Debugger pauses the workflow and opens an SSH session on the runner, which is the shortest path to inspecting a keychain, a provisioning profile, or a half-written installer directory in place. Both are part of the WarpBuild product surface alongside snapshot runners, remote Docker builders, and the MCP server. ## [Proof](#proof)Copy Anchor Link Every cost number on this page carries its source and a checked-on date so you can re-derive it. Public OSS repositories running `warp-` labels are citable evidence, and the check is to open the workflow file and read the `runs-on` line yourself. The Trigger.dev end-to-end suite runs its matrix across `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x` in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml), which is the same cross-platform matrix shape an Electron release uses (checked on 2026-08-13). The full runner catalog, including every label and alias used above, is in the [cloud runners documentation](/docs/ci/cloud-runners). Related reading: [Node.js dependency caching on GitHub Actions](/solutions/nodejs-github-actions) for the install phase, [Windows runners](/runners/windows) and [macOS runners](/runners/macos) for the per-platform catalogs, and [GitHub Actions matrix builds](/guides/github-actions-matrix-builds) for the matrix mechanics. ## [FAQ](#faq)Copy Anchor Link **Do Electron builds need one runner per platform?** Yes for macOS, and in practice yes for Windows. Apple code signing and notarization only run on macOS, and Windows installer signing expects the Windows toolchain, so the reliable shape is a three-leg matrix on `warp-ubuntu-latest-x64-8x`, `warp-windows-latest-x64-8x`, and `warp-macos-15-arm64-6x`. **Why does the Windows leg of the matrix cache differently?** WarpBuild Cache is not supported on Windows runners, which the caching documentation states directly. Keep `WarpBuilds/cache` on the Linux and macOS legs and use `actions/cache` on the Windows leg, with separate keys because cache entries are not portable across operating systems anyway. **What runner sizes should each Electron platform leg use?** Start at `warp-ubuntu-latest-x64-8x` ($0.016 per minute), `warp-windows-latest-x64-8x` ($0.032 per minute), and `warp-macos-15-arm64-6x` ($0.08 per minute). Move the macOS leg to `warp-macos-15-arm64-12x` when a wide target matrix fills the 120GB disk, since that label carries 270GB. **How much disk does an Electron packaging job need?** More than most jobs, because the unpacked app is copied once per target. Linux runners carry 150GB SSD, Windows runners carry 256GB SSD, and macOS runners carry 120GB SSD on the 6 vCPU labels or 270GB SSD on the 12 vCPU labels. --- # Elixir and Phoenix Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/elixir-github-actions Description: Elixir builds on GitHub Actions stall on cold deps, _build recompiles, and Dialyzer PLT rebuilds. Cache each separately on a warp- runner and size for mix test. # Elixir and Phoenix Builds on GitHub Actions Elixir builds on GitHub Actions stall on cold deps, \_build recompiles, and Dialyzer PLT rebuilds. Cache each separately on a warp- runner and size for mix test. Last verified: 2026-08-13 Elixir builds on GitHub Actions spend most of their wall clock in three places: fetching and compiling `deps/`, rebuilding `_build/` after an OTP or Elixir version change, and rebuilding the Dialyzer PLT from scratch. The fix is to cache `deps/` and `_build/` as separate entries keyed on the OTP version, the Elixir version, and `mix.lock`, give the PLT its own cached directory, and run the job on a `warp-` runner sized for the concurrency `mix test` actually uses. ## [Overview](#overview)Copy Anchor Link A Mix project keeps its state in two directories that behave differently. `deps/` holds fetched source for every dependency and changes only when `mix.lock` changes. `_build//` holds compiled BEAM files for those dependencies plus your own application, and it is invalidated by a change to the Elixir version, the OTP version, or the compiler options, in addition to any dependency change. Most slow Elixir jobs treat those two directories as one cache entry, or skip `_build/` entirely. Both choices force a full `mix deps.compile` on every run, which for a Phoenix application with 60 to 100 dependencies is the largest single step in the job. WarpBuild runners register against your organization under `warp-` labels, and the Linux runner images carry the same tooling as GitHub-hosted runners, so `erlef/setup-beam`, `mix`, and `dialyxir` run unchanged. Switching a job means editing the `runs-on` line. The rest of this page covers a working workflow with split caches, the PLT configuration that keeps Dialyzer warm, sizing guidance from 4 vCPUs to 16 vCPUs with the list-price arithmetic, and the three bottlenecks that dominate Elixir and Phoenix jobs. For the general case across every language, the [checklist for speeding up GitHub Actions](/guides/speed-up-github-actions) is the broader tour. ## [Configuration](#configuration)Copy Anchor Link The workflow below runs a Phoenix test suite on `warp-ubuntu-latest-x64-8x`. It restores `deps/` and `_build/test` through two separate `WarpBuilds/cache` entries, each keyed on the OTP version, the Elixir version, and the hash of `mix.lock`. Copy Text ``` name: elixir-ci on: push: branches: [main] pull_request: env: MIX_ENV: test jobs: test: runs-on: warp-ubuntu-latest-x64-8x strategy: matrix: include: - otp: "27.3" elixir: "1.18.3" steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 with: otp-version: ${{ matrix.otp }} elixir-version: ${{ matrix.elixir }} - name: Restore deps uses: WarpBuilds/cache@v1 with: path: deps key: deps-otp${{ matrix.otp }}-ex${{ matrix.elixir }}-${{ hashFiles('mix.lock') }} restore-keys: | deps-otp${{ matrix.otp }}-ex${{ matrix.elixir }}- - name: Restore compiled build tree uses: WarpBuilds/cache@v1 with: path: _build/test key: build-otp${{ matrix.otp }}-ex${{ matrix.elixir }}-${{ hashFiles('mix.lock') }} restore-keys: | build-otp${{ matrix.otp }}-ex${{ matrix.elixir }}- - name: Fetch and compile dependencies run: | mix deps.get mix deps.compile - name: Drop restored artifacts for this application run: rm -rf _build/test/lib/my_app - name: Compile run: mix compile --warnings-as-errors - name: Run tests run: mix test ``` Two details in that file carry most of the value. The caches are split because the cache version is a hash over the compression tool and the list of `path` entries. A single entry covering `deps` and `_build/test` gets one version and one key, so any change that invalidates one directory throws away the other. Split entries let `deps/` hit exactly on `mix.lock` while `_build/test` falls back through `restore-keys` to the previous compile, so a one-line dependency bump recompiles one dependency rather than all of them. The `rm -rf _build/test/lib/my_app` step exists because a restored `_build/` tree makes the compiler treat your own modules as already built. That suppresses warnings that `--warnings-as-errors` is supposed to catch and hides stale artifacts after a refactor. Deleting only your own application directory keeps every dependency artifact warm and costs a few seconds of recompilation. `WarpBuilds/cache` is a drop-in replacement for `actions/cache@v4`, so `path`, `key`, `restore-keys`, and `fail-on-cache-miss` behave the same way. Entries are scoped to key, version, and branch, and an entry expires 7 days after its last use. The split restore and save variants used below are documented on the [caching page](/docs/ci/features/caching). ### [Keeping the Dialyzer PLT warm](#keeping-the-dialyzer-plt-warm)Copy Anchor Link Dialyzer needs a persistent lookup table built from the OTP applications and from your dependencies. By default `dialyxir` writes it inside `_build/`, where it is invalidated by the same events that invalidate compiled artifacts. Move it out first, in `mix.exs`: Copy Text ``` def project do [ app: :my_app, dialyzer: [ plt_local_path: "priv/plts", plt_core_path: "priv/plts" ] ] end ``` Then cache `priv/plts` with the split restore and save actions, so the expensive build runs only on a miss: Copy Text ``` dialyzer: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 with: otp-version: "27.3" elixir-version: "1.18.3" - name: Restore PLT id: plt uses: WarpBuilds/cache/restore@v1 with: path: priv/plts key: plt-otp27.3-ex1.18.3-${{ hashFiles('mix.lock') }} restore-keys: | plt-otp27.3-ex1.18.3- - name: Build PLT if: steps.plt.outputs.cache-hit != 'true' run: | mix deps.get mix dialyzer --plt - name: Save PLT if: steps.plt.outputs.cache-hit != 'true' uses: WarpBuilds/cache/save@v1 with: path: priv/plts key: ${{ steps.plt.outputs.cache-primary-key }} - name: Run Dialyzer run: mix dialyzer --format github ``` The OTP version belongs in the PLT key because the core PLT is built from the OTP applications shipped with that release. Bumping OTP without changing the key produces a PLT that Dialyzer rejects and rebuilds anyway, so you pay the rebuild without noticing why. ## [Sizing](#sizing)Copy Anchor Link `mix test` runs cases tagged `async: true` concurrently, with `max_cases` defaulting to `System.schedulers_online()` times two. On a runner with 8 vCPUs that is 16 concurrent cases. The compiler is also parallel across scheduler threads, so a large module graph compiles faster on a wider machine. Runner size therefore sets both compile throughput and test concurrency. These are the Linux sizes worth considering, with rates as listed on the [pricing page](/pricing): | Runner label | vCPU | RAM | Storage | Price per minute | | ---------------------------- | ---- | ----- | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 | | warp-ubuntu-latest-arm64-8x | 8 | 32GB | 150GB SSD | $0.012 | | warp-ubuntu-latest-arm64-16x | 16 | 64GB | 150GB SSD | $0.024 | **4x for a single application with async tests.** Eight concurrent cases, 16GB of RAM. This covers a library or a small service where the suite is mostly unit tests and the database fixture set is light. **8x for a Phoenix application with database-backed tests.** Sixteen concurrent cases against a Postgres service container, each holding a checked-out sandbox connection. 32GB of RAM is what keeps sixteen Ecto sandboxes plus the BEAM heap comfortable. Most Phoenix teams land here. **16x for umbrella projects.** An umbrella multiplies both halves of the work: the compile graph spans every child application, and the test count is the sum across children. Thirty-two concurrent cases on 64GB is the size where a large umbrella stops queueing behind itself. Before buying it, check whether your `async: true` coverage is high enough to use the concurrency, since synchronous cases run one at a time no matter how many schedulers exist. ARM64 rows carry lower per-minute rates at the same shape. The BEAM and the common native dependencies build cleanly on ARM64, so an Elixir suite is a good candidate for the switch. ### [List-price arithmetic](#list-price-arithmetic)Copy Anchor Link GitHub per-minute prices below come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the shapes from the [GitHub-hosted runner specifications](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), both checked on 2026-08-13\. WarpBuild rates come from the table above. | WarpBuild runner | Shape | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | Lower list price | | -------------------------- | --------------- | -------------------- | ------------------------------------- | ----------------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | ubuntu-latest on private repositories | $0.006 | 33 percent | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent | | warp-ubuntu-latest-x64-32x | 32 vCPU, 128 GB | $0.064 | 32-core Linux larger runner | $0.082 | 22 percent | Public repositories get a 4 vCPU, 16GB GitHub-hosted shape at no charge, so the arithmetic above matters for private repositories, which is where most Phoenix applications live. Now put wall clock against it. Assume a Phoenix suite that takes 14 minutes on GitHub's standard 2 vCPU hosted runner and runs 600 times a month, and assume your own measurement puts the same suite at 5 minutes on 8 vCPUs with warm caches. Substitute your real numbers, because parallel efficiency depends on how much of the suite is tagged `async: true`. | Setup | Wall clock | Rate per minute | Cost per run | Cost per month (600 runs) | | ---------------------------------- | ---------- | --------------- | ------------ | ------------------------- | | GitHub-hosted, 2 vCPU | 14 min | $0.006 | $0.084 | $50.40 | | GitHub-hosted 8-core larger runner | 5 min | $0.022 | $0.110 | $66.00 | | warp-ubuntu-latest-x64-8x | 5 min | $0.016 | $0.080 | $48.00 | Buying vCPUs from GitHub raises the monthly bill even though the suite finishes sooner, because the rate climbs faster than the wall clock falls. At the same 8 vCPU shape the WarpBuild list price is lower, so the shorter suite and the smaller bill stop trading against each other. ## [Bottlenecks](#bottlenecks)Copy Anchor Link Three bottlenecks account for most slow Elixir and Phoenix jobs on GitHub Actions. ### [Recompiling dependencies on OTP and Elixir version churn](#recompiling-dependencies-on-otp-and-elixir-version-churn)Copy Anchor Link BEAM artifacts in `_build/` are tied to the Elixir and OTP releases that produced them. A matrix that spans two OTP versions and two Elixir versions has four distinct `_build/` trees, and a workflow keyed without those versions will thrash: each matrix leg overwrites the previous leg's entry and every leg starts cold. The fix is the key structure in the workflow above. Put both versions in the cache key so each matrix leg owns its own entry, and let `restore-keys` fall back within the same leg. When you do bump OTP deliberately, expect one full recompile per leg and then steady warm runs. Storage for this is cheap and priced openly: cache storage is $0.20 per GB-month and each cache write or restore is $0.0001\. A 1.5GB combined `deps/` and `_build/` cache therefore costs about $0.30 a month to keep warm, and entries expire 7 days after last use. ### [Dialyzer PLT builds](#dialyzer-plt-builds)Copy Anchor Link A PLT built from OTP plus a Phoenix dependency tree is hundreds of megabytes and takes minutes of pure CPU work. The cached configuration above removes the rebuild on the common path, but the restore path still costs a download and a `zstd` decompression of a large file on every job. This is where snapshot runners change the shape of the problem. A snapshot captures the runner VM mid-workflow and later jobs boot from that captured disk, so the PLT is already present at boot with no restore step and no rebuild. Build the PLT once on `main` behind `snapshot.enabled=true`, save it with `WarpBuilds/snapshot-save`, and point pull request jobs at `snapshot.key=`: Copy Text ``` jobs: dialyzer: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=elixir-plt' }} ``` Three constraints decide whether that trade is worth taking. Snapshot boot takes 45 to 60 seconds, which is slower than a default runner boot, so the PLT restore you avoid has to cost more than that. Snapshots are deleted after 15 days, and a snapshot held for its full lifetime costs 15 times 24 times $0.025, which is $9.00 in snapshot storage, with $0.04 added per job that restores from it. And snapshot runners are supported on WarpBuild Cloud Ubuntu runners only: snapshot labels on BYOC, Windows, or macOS runners are silently ignored and the job runs normally. The rule of thumb: a cache is the right tool while the PLT restore stays under roughly a minute, and a snapshot wins once the PLT grows large enough that restoring and decompressing it dominates the Dialyzer job. Details on both are in the [snapshot runners documentation](/docs/ci/features/snapshot-runners) and on the [snapshot runner page](/runners/snapshot). ### [The Phoenix asset pipeline](#the-phoenix-asset-pipeline)Copy Anchor Link `mix assets.deploy` runs `esbuild`, `tailwind`, and `phx.digest`. The Elixir `esbuild` and `tailwind` packages download a platform binary on first use and write it under `_build/`, so a job with no `_build/` cache pays two binary downloads before any asset work begins. Caching `_build/` as shown above covers them. Two further details are worth checking. If your assets pipeline shells out to npm, cache `assets/node_modules` as its own entry keyed on `assets/package-lock.json`, for the same reason `deps/` and `_build/` are split. And run `mix phx.digest` once in the release job rather than in every test job, since digesting fingerprints and gzips the whole static tree and contributes nothing to test results. When a failure reproduces only inside GitHub Actions, the WarpBuild Action Debugger pauses the workflow and opens an SSH session on the live runner, which is faster than adding `IO.inspect` calls and re-running a 14 minute suite. WarpBuild's CI observability reports system metrics from the runner agent correlated with the job logs, so you can check whether 16 concurrent cases actually saturate an 8x runner before paying for a 16x. Both are covered on the [incremental builds guide](/guides/incremental-builds-github-actions) alongside the caching patterns. ## [Proof](#proof)Copy Anchor Link Every rate on this page is on the [pricing page](/pricing). Several of them run large test matrices across multiple language runtimes on the same runner fleet described here. SSO is available for a flat $250 per month, whatever the user count. Teams running Rails alongside Phoenix hit the same split-cache and sizing questions in a different vocabulary; the [Ruby and Rails on GitHub Actions page](/solutions/ruby-github-actions) covers that ecosystem. ## [FAQ](#faq)Copy Anchor Link ### [How should I cache deps and \_build for an Elixir project?](#how-should-i-cache-deps-and-%5Fbuild-for-an-elixir-project)Copy Anchor Link Cache them as two separate entries. The cache version is a hash over the compression tool and the list of cached paths, so one entry covering both directories invalidates as a unit. Key both on the OTP version, the Elixir version, and the hash of mix.lock. ### [Why does my Dialyzer PLT rebuild on every GitHub Actions run?](#why-does-my-dialyzer-plt-rebuild-on-every-github-actions-run)Copy Anchor Link The default PLT path sits inside \_build, which most workflows either skip caching or invalidate on every dependency change. Move it with plt\_local\_path and plt\_core\_path in mix.exs, cache that directory on its own key, and include the OTP version in the key. ### [What runner size should I use for mix test?](#what-runner-size-should-i-use-for-mix-test)Copy Anchor Link ExUnit runs async cases with max\_cases defaulting to System.schedulers\_online() times two, so vCPU count sets concurrency directly. Start at warp-ubuntu-latest-x64-4x for a single app, 8x for a Phoenix app with database-backed tests, and 16x for umbrella projects. ### [Do snapshot runners work for Elixir builds on BYOC or macOS?](#do-snapshot-runners-work-for-elixir-builds-on-byoc-or-macos)Copy Anchor Link No. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only. Snapshot labels applied to BYOC, Windows, or macOS runners are silently ignored and the job runs normally without snapshot functionality. --- # Embedded Firmware Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/embedded-firmware-github-actions Description: Embedded firmware on GitHub Actions needs a cached cross toolchain, a board matrix emitting per-target artifacts, and runners sized per leg. YAML and rates. # Embedded Firmware Builds on GitHub Actions Embedded firmware on GitHub Actions needs a cached cross toolchain, a board matrix emitting per-target artifacts, and runners sized per leg. YAML and rates. Last verified: 2026-08-13 An embedded firmware pipeline on GitHub Actions is a cross-compilation pipeline: an x86-64 runner installs an `arm-none-eabi` or RISC-V toolchain plus the vendor SDK, builds one image per board across a matrix, and uploads the ELF, HEX, and map file for each target. Three things decide how fast and how cheap that pipeline runs: how the toolchain and SDK get cached, how wide the board matrix fans out, and how clearly the pipeline draws the line at checks that need real hardware. This page gives the cache strategy for cross toolchains and vendor SDKs, a working board matrix that produces per-target artifacts, the sizing and cost arithmetic behind the runner labels, and the hardware boundary that decides what belongs in a hosted job at all. ## [Overview](#overview)Copy Anchor Link A firmware build inverts the usual GitHub Actions cost profile. The compile itself is small, since a bare-metal or RTOS image is tens of thousands of lines rather than millions, and the link step finishes in seconds. The expensive part is everything before the first `.o` file: downloading and unpacking the cross toolchain tarball, installing a vendor SDK, running a package manager that fetches board support packages, and pulling git submodules that carry HAL sources. On a fresh virtual machine, that setup routinely runs longer than the build it exists to serve. The second structural fact is matrix width. Firmware projects ship for several boards, several silicon revisions, and usually a debug and a release variant of each. Every one of those combinations is an independent job, each starting from an empty machine and paying its own setup cost, so a caching mistake gets multiplied by the matrix rather than paid once. Cross builds belong on Linux x64, because vendor toolchains and SDK installers ship x86-64 Linux binaries and target support is what matters rather than host architecture. The [cloud runner catalog](/docs/ci/cloud-runners) lists Ubuntu 22.04, 24.04, and 26.04 images from 2 to 32 vCPUs, each with a 150GB SSD and the same tooling as GitHub-hosted runners. Runners are ephemeral virtual machines, allocated per job and destroyed afterward, which is exactly why toolchain state has to live somewhere outside the machine. That somewhere is WarpBuild Cache, enabled by default on Linux runners and documented in the [caching guide](/docs/ci/features/caching). The third fact is the hardware boundary. A hosted runner has no board attached, so part of a firmware test plan is out of its reach permanently and no amount of runner tuning changes that: | Check | Hosted runner | Physical board | | --------------------------------------------------- | -------------------- | -------------- | | Compile and link one image per board | Yes | | | Static analysis and coding-standard rules | Yes | | | Flash and RAM budget from the map file | Yes | | | Host-compiled unit tests of portable logic | Yes | | | Emulated boot and driver tests under QEMU or Renode | Where a model exists | | | Timing against real peripheral silicon | Yes | | | Analog behavior, power draw, EMC | Yes | | | Radio interop and certification runs | Yes | | | Flash-and-run of a production image | Yes | | The practical split is that hosted runners own everything up to the artifact, and a bench of boards behind [self-hosted runners](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners) consumes those artifacts on a slower cadence, usually nightly or on release tags rather than on every pull request. ## [Configuration](#configuration)Copy Anchor Link Cross toolchains and vendor SDKs cache differently from application dependencies. A lockfile hash is the wrong key here, because the toolchain does not change when your source does. Key on the version string instead, so the cache is restored on nearly every run and rebuilt only when you deliberately bump a version. | Ecosystem | What to cache | Path | Key input | | --------------------- | ----------------------------------------- | ------------------------------------------------- | ------------------------------ | | Arm GNU toolchain | Unpacked toolchain tree | /opt/arm-gnu-toolchain | Toolchain release string | | Zephyr | West modules and the Zephyr SDK | \~/zephyrproject/modules, \~/zephyr-sdk- | west.yml hash plus SDK version | | ESP-IDF | Downloaded tools and installed toolchains | \~/.espressif | IDF release tag | | PlatformIO | Platform packages and frameworks | \~/.platformio | platformio.ini hash | | Embedded Rust | Registry, git checkouts, target dir | \~/.cargo, target/ | Cargo.lock hash | | Vendor board packages | Extracted BSP or HAL pack | vendor/sdk | SDK version or pack lockfile | Here is a pipeline built on that strategy. The board matrix runs on 8 vCPUs, each leg restoring the shared toolchain and its own SDK slice, and host-side unit tests run separately on 4 vCPUs. Copy Text ``` name: firmware on: push: branches: [main] pull_request: env: ARM_TOOLCHAIN_VERSION: 14.2.rel1 TOOLCHAIN_ROOT: /opt/arm-gnu-toolchain jobs: build: runs-on: warp-ubuntu-latest-x64-8x strategy: fail-fast: false matrix: board: [nucleo_f429zi, nrf52840dk, stm32h743_eval] steps: - uses: actions/checkout@v4 with: submodules: recursive - name: Restore cross toolchain id: toolchain uses: WarpBuilds/cache@v1 with: path: ${{ env.TOOLCHAIN_ROOT }} key: arm-gnu-${{ env.ARM_TOOLCHAIN_VERSION }}-x86_64 - name: Install cross toolchain if: steps.toolchain.outputs.cache-hit != 'true' run: | sudo mkdir -p "$TOOLCHAIN_ROOT" curl -sSL "$ARM_TOOLCHAIN_URL" \ | sudo tar -xJ --strip-components=1 -C "$TOOLCHAIN_ROOT" env: ARM_TOOLCHAIN_URL: https://developer.arm.com/-/media/Files/downloads/gnu/${{ env.ARM_TOOLCHAIN_VERSION }}/binrel/arm-gnu-toolchain-${{ env.ARM_TOOLCHAIN_VERSION }}-x86_64-arm-none-eabi.tar.xz - name: Put the toolchain on PATH run: echo "$TOOLCHAIN_ROOT/bin" >> "$GITHUB_PATH" - name: Restore vendor SDK uses: WarpBuilds/cache@v1 with: path: vendor/sdk key: sdk-${{ matrix.board }}-${{ hashFiles('vendor/sdk.lock') }} restore-keys: sdk-${{ matrix.board }}- - name: Build run: make BOARD=${{ matrix.board }} -j8 - name: Size report run: | arm-none-eabi-size --format=berkeley \ build/${{ matrix.board }}/firmware.elf - uses: actions/upload-artifact@v4 with: name: firmware-${{ matrix.board }} path: | build/${{ matrix.board }}/firmware.elf build/${{ matrix.board }}/firmware.hex build/${{ matrix.board }}/firmware.map host-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: cmake -S test -B build-host -DCMAKE_BUILD_TYPE=Debug - run: cmake --build build-host --parallel 4 - run: ctest --test-dir build-host --output-on-failure ``` Four details carry the weight. **The toolchain key holds still.** `arm-gnu-14.2.rel1-x86_64` changes when you edit one environment variable and at no other time, so every matrix leg on every branch restores the same entry. The conditional install step then runs approximately never, which is the point. **Artifacts are named per board.** `firmware-${{ matrix.board }}` gives a release job a predictable set of names to fan back in, and it keeps a failed leg from poisoning the others. `fail-fast: false` matters for the same reason: one broken board should report as one broken board rather than cancelling the matrix. **The map file ships with the image.** Flash and RAM headroom are release criteria on constrained parts, and the map file is the only artifact that can answer "which commit ate 3 KB of flash" after the fact. Uploading it costs nothing and turns a size regression into a diff. **Submodules are part of the setup bill.** `submodules: recursive` on a HAL-heavy tree can pull hundreds of megabytes. Where the vendor tree is stable, cache the checked-out submodule paths on the pinned commit rather than refetching them. For installs that take minutes and scatter files across the filesystem, such as a full IDF or SDK installer, caching directories is the wrong tool. Snapshot runners capture the whole runner VM mid-workflow and boot later jobs from it: add `snapshot.enabled=true` to the `runs-on` label on `main`, call `WarpBuilds/snapshot-save@v1` after the install completes, and use `snapshot.key=` on pull request runs to boot from that state. Snapshots are deleted after 15 days, and the labels are silently ignored on runner types that do not support them. The [snapshot runner documentation](/docs/ci/features/snapshot-runners) covers the full label syntax. ## [Sizing](#sizing)Copy Anchor Link Linux x64 sizes and rates, from the [pricing page](/pricing): | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Firmware sizing follows one rule: parallelism lives in the matrix, so buy legs before you buy cores. A single board image has a few hundred translation units at most, and `make -j8` exhausts the compile graph long before it exhausts 32 vCPUs. Cores start paying again when a leg does more than compile the application: building a vendor SDK or an RTOS tree from source, running clang-tidy across the full unit set, or generating protocol or device-tree code as part of the build. Practical starting points. Keep documentation, formatting, and lint jobs on `warp-ubuntu-latest-x64-2x` at $0.004 per minute. Put host-compiled unit tests and emulator runs on `warp-ubuntu-latest-x64-4x` at $0.008 per minute. Run the board matrix on `warp-ubuntu-latest-x64-8x` at $0.016 per minute, and move up only when a leg still shows every core saturated at the end of the build. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes list prices for its hosted runners; the 8-core Linux larger runner meters at $0.022 per minute ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). Take a firmware team with a 12 board matrix, 5 minutes per leg, running 20 pipelines a day across 22 working days: 26,400 runner-minutes per month, plus 12 GB of toolchain and SDK cache and 10,560 cache operations. | Line item | Rate | Volume | Monthly cost | | ---------------------------------------- | --------------------- | ----------------- | ------------ | | GitHub-hosted Linux 8 vCPU larger runner | $0.022 per minute | 26,400 minutes | $580.80 | | warp-ubuntu-latest-x64-8x | $0.016 per minute | 26,400 minutes | $422.40 | | WarpBuild cache storage | $0.20 per GB-month | 12 GB | $2.40 | | WarpBuild cache operations | $0.0001 per operation | 10,560 operations | $1.06 | The WarpBuild total is $425.86 against $580.80 for the same minutes, a difference of $154.94 per month. As a rate comparison on its own: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), which is 27 percent lower list price. GitHub list price checked on 2026-08-13. Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Setup dominating the build.** The signature is a five minute job with a forty second compile inside it. Read the step durations before tuning anything else: a toolchain download, an SDK installer, and a recursive submodule fetch are each a separate line, and each has a different fix (version-keyed cache, snapshot, pinned submodule cache). **Matrix legs that queue behind a shared setup job.** A `needs:` edge that makes every board wait for one preparation job converts a parallel matrix into a serial prologue. Where the preparation output is a cache entry or a snapshot, the legs can restore it independently and start immediately. **Vendor container images pulled per leg.** Toolchain containers are commonly multi-gigabyte, and pulling one per matrix leg per run turns image transfer into the dominant cost. Where the pipeline builds its own toolchain image, remote Docker builders keep the layer cache warm between runs, so a Dockerfile edit rebuilds the changed layers instead of the whole image. **Emulator jobs on undersized machines.** QEMU or Renode runs of a full board model are single-process and memory-hungry, and a 2 vCPU machine with 8 GB stalls them. These belong on the 4x size, sized by memory rather than cores. **Architecture confusion on 32-bit targets.** Building an armv7 firmware image is a cross-compilation job that runs perfectly well on an x86-64 runner, since the compiler is a native x86-64 binary emitting ARM code. Executing 32-bit ARM binaries on an ARM64 runner is a separate question with a separate answer, covered on the [32-bit ARM builds page](/answers/can-i-run-32-bit-arm-builds-on-arm64-runners). Mixing the two up sends teams looking for ARM64 runners they do not need. The [cross-compilation glossary entry](/glossary/cross-compilation) has the vocabulary for build, host, and target. CI observability streams system metrics from the runner agent correlated with GitHub Actions job logs, which distinguishes a job stalled on a download from one saturating every core. The Action Debugger pauses a workflow and opens an SSH session on the live runner, which is the shortest path to inspecting a half-populated SDK directory or a failing linker script in place. Larger image-based pipelines that assemble a full Linux distribution have a different bottleneck profile, covered on the [Yocto builds page](/solutions/yocto-github-actions). ## [Proof](#proof)Copy Anchor Link The nearest checkable public evidence is a cross-compilation matrix in an open repository. The [bitcoin/bitcoin GitHub Actions workflow](https://github.com/bitcoin/bitcoin/blob/master/.github/workflows/ci.yml) runs its `windows-cross` job on `warp-ubuntu-latest-x64-4x` with a matrix over two C runtimes, each leg uploading its own artifact set (`x86_64-w64-mingw32-executables` and `x86_64-w64-mingw32ucrt-executables`), and runs its macOS cross builds to arm64 and x86\_64 on the same label, with the heavier sanitizer and fuzz jobs on the 8x and 16x sizes (checked on 2026-08-13). The mechanics are the ones this page describes: one cross toolchain per target, per-target artifact names, and a runner size chosen per matrix leg rather than one size for the whole workflow. The rest is measurable in your own repository without taking anyone's word for it. Run the board matrix unchanged on the label you use today, then on `warp-ubuntu-latest-x64-8x`, and compare per-step durations at P75 rather than a single run, since one run hides queue time and cache-miss variance. Separate the setup steps from the compile steps in that comparison, because the caching work above moves the first group and the runner size moves the second. ## [FAQ](#faq)Copy Anchor Link **Which runner size fits an embedded firmware build?** Start the board matrix on `warp-ubuntu-latest-x64-8x` at $0.016 per minute when a leg compiles a vendor SDK from source or runs static analysis over the tree, and keep host-side unit tests on `warp-ubuntu-latest-x64-4x` at $0.008 per minute. A single small board image rarely saturates 8 vCPUs, so buy width through matrix legs rather than cores per leg. **How do I stop a vendor SDK install from running on every job?** Cache the SDK directory with `WarpBuilds/cache@v1` keyed on the SDK version string rather than a source hash, so the key changes only when you bump the SDK. For installs that take minutes and write across the filesystem, capture the whole runner VM with `WarpBuilds/snapshot-save` and boot later jobs from that alias using `snapshot.key` on the `runs-on` label. **What can GitHub Actions verify about firmware without a board attached?** A hosted runner compiles and links each board image, runs static analysis, checks flash and RAM against the map file, runs host-compiled unit tests of portable logic, and runs emulated boot tests where a QEMU or Renode model exists. Timing against real peripheral silicon, analog behavior, power draw, and radio interop need a physical board on a self-hosted runner. --- # Fastlane Lanes on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/fastlane-github-actions Description: Run fastlane lanes on GitHub Actions macOS runners with cached bundler and CocoaPods installs, ephemeral signing keychains, and warp- label sizing. # Fastlane Lanes on GitHub Actions Run fastlane lanes on GitHub Actions macOS runners with cached bundler and CocoaPods installs, ephemeral signing keychains, and warp- label sizing. Last verified: 2026-08-13 Fastlane runs on GitHub Actions the same way it runs on a laptop: `bundle exec fastlane ` inside a job whose `runs-on` points at a macOS runner. On WarpBuild that label is a `warp-macos-` label, and the macOS fleet ships in two configurations, 6 vCPU with 14GB of memory at $0.08 per minute and 12 vCPU with 28GB at $0.16 per minute. This page covers the workflow YAML for a cached test lane and a signed release lane, how to pick between the two macOS configurations, the four things that dominate fastlane wall clock time on GitHub Actions, and the arithmetic against GitHub-hosted macOS list prices. ## [Overview](#overview)Copy Anchor Link A fastlane repository usually ends up with two lane shapes on GitHub Actions. The first is a feedback lane on every pull request push. It calls `scan` for unit and UI tests, sometimes `swiftlint`, and never touches a signing identity. The second is a release lane on merge to the main branch. It calls `match` or an equivalent certificate step, then `gym` to archive and export an IPA, then `pilot` or `deliver` to upload. Both need macOS, because `xcodebuild`, the simulator runtimes, and the `security` keychain toolchain that fastlane drives only exist there. The macOS fleet runs on Apple Silicon with ARM64 architecture in multiple sizes and configurations per chip. A test lane and a release lane can therefore sit on different labels inside one workflow file. ### [macOS runner catalog](#macos-runner-catalog)Copy Anchor Link | Runner label | macOS | vCPU | Memory | Storage | Per-minute | Alias | | ----------------------- | -------- | ---- | ------ | --------- | ---------- | --------------------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | | Source: the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), verified 2026-08-13\. The `latest` aliases track macOS 15, in sync with GitHub's `macos-latest` tag. macOS 13 runners were removed on June 8, 2026, so a Fastfile still pinned to a macOS 13 label needs to move. The macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes (build 27A266a, with iOS, tvOS, watchOS, and visionOS 27.0 runtimes) while GitHub's upstream macOS 27 runner image is in beta. A dedicated macOS 27 image follows once that upstream image is released. A lane that pins a specific Xcode through `DEVELOPER_DIR` or `xcversion` should therefore state the version it expects rather than trusting the image default. Three platform facts shape how a fastlane pipeline is laid out. Every job gets a fresh virtual machine, and that VM's storage is destroyed when the runner terminates. Gems, pods, DerivedData, and keychains all start empty on every run unless the workflow restores them. The isolation model is described in the [runner security documentation](/docs/ci/security). macOS runners do not support nested virtualization and cannot run Docker, so any containerized step, such as a backend fixture or a screenshot service, belongs on a Linux label in a separate job. Concurrency on the macOS fleet is governed by per-organization quotas as of July 27, 2026\. Jobs beyond the quota wait for capacity, which matters for a fastlane `scan` matrix that fans out over device names. ## [Configuration](#configuration)Copy Anchor Link Point `runs-on` at a `warp-macos-` label and keep the rest of the workflow standard, because the images carry the same tooling as GitHub-hosted macOS runners. The workflow below hoists `bundle install` and `pod install` out of the lane into their own cached steps, so the lane itself runs the build and nothing else, and the install cost shows up as its own line in the job timeline. Copy Text ``` name: fastlane on: pull_request: push: branches: [main] concurrency: group: fastlane-${{ github.ref }} cancel-in-progress: true env: DEVELOPER_DIR: /Applications/Xcode_${{ vars.XCODE_VERSION }}.app/Contents/Developer FASTLANE_SKIP_UPDATE_CHECK: "1" FASTLANE_HIDE_CHANGELOG: "1" LC_ALL: en_US.UTF-8 LANG: en_US.UTF-8 jobs: test: runs-on: warp-macos-15-arm64-6x timeout-minutes: 30 steps: - uses: actions/checkout@v4 - name: Cache bundler gems uses: actions/cache@v4 with: path: vendor/bundle key: gems-${{ runner.os }}-${{ hashFiles('Gemfile.lock') }} restore-keys: gems-${{ runner.os }}- - name: Install gems run: | bundle config set --local path vendor/bundle bundle config set --local deployment true bundle install --jobs 4 - name: Cache CocoaPods uses: actions/cache@v4 with: path: | Pods ~/Library/Caches/CocoaPods key: pods-${{ runner.os }}-${{ hashFiles('Podfile.lock') }} restore-keys: pods-${{ runner.os }}- - name: Install pods run: bundle exec pod install --deployment - name: Run the test lane run: bundle exec fastlane ios test - uses: actions/upload-artifact@v4 if: always() with: name: fastlane-test-output path: fastlane/test_output beta: needs: test if: github.ref == 'refs/heads/main' runs-on: warp-macos-26-arm64-12x timeout-minutes: 60 steps: - uses: actions/checkout@v4 - name: Cache bundler gems uses: actions/cache@v4 with: path: vendor/bundle key: gems-${{ runner.os }}-${{ hashFiles('Gemfile.lock') }} restore-keys: gems-${{ runner.os }}- - name: Install gems run: | bundle config set --local path vendor/bundle bundle config set --local deployment true bundle install --jobs 4 - name: Cache CocoaPods uses: actions/cache@v4 with: path: | Pods ~/Library/Caches/CocoaPods key: pods-${{ runner.os }}-${{ hashFiles('Podfile.lock') }} restore-keys: pods-${{ runner.os }}- - name: Install pods run: bundle exec pod install --deployment - name: Run the release lane env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_TOKEN }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_P8_BASE64 }} run: bundle exec fastlane ios beta ``` The Fastfile that pairs with it keeps each lane narrow. Copy Text ``` default_platform(:ios) platform :ios do desc "Build and run the unit and UI test suites" lane :test do scan( scheme: "Kitchen", device: "iPhone 17", derived_data_path: "DerivedData", result_bundle: true, clean: false ) end desc "Sign, archive, and upload a TestFlight build" lane :beta do setup_ci key = app_store_connect_api_key( key_id: ENV["ASC_KEY_ID"], issuer_id: ENV["ASC_ISSUER_ID"], key_content: ENV["ASC_KEY_CONTENT"], is_key_content_base64: true ) match(type: "appstore", readonly: true, api_key: key) gym( scheme: "Kitchen", configuration: "Release", export_method: "app-store", derived_data_path: "DerivedData" ) pilot(api_key: key, skip_waiting_for_build_processing: true) end end ``` Five details in those files carry most of the value. `bundle config set --local path vendor/bundle` puts the gems inside the workspace, which is what makes `vendor/bundle` a cacheable path. Without it the gems land under the system Ruby prefix and the cache entry restores nothing useful. `bundle config set --local deployment true` makes `bundle install` fail when `Gemfile.lock` is out of date instead of silently resolving a different fastlane version on the runner than the one a developer ran locally. The CocoaPods cache stores `Pods` and `~/Library/Caches/CocoaPods` together. The first is the integrated output, the second is the downloaded pod sources, and caching only one of them leaves half the work to redo. `setup_ci` is the line that makes signing work on an ephemeral machine. It creates a temporary keychain for the job and points fastlane at it, so `match` can install the certificate without touching a login keychain that does not exist on a headless runner. There is no cleanup step to write, because the VM and its storage are destroyed when the job ends. `readonly: true` on `match` stops a release job from minting new certificates when a lookup fails. Keep certificate creation in a lane a person runs deliberately, so a merge can never issue one. Moving an existing fastlane setup across is a label change. Swap `macos-latest` for `warp-macos-15-arm64-6x` and the Fastfile stays untouched. ## [Sizing](#sizing)Copy Anchor Link | Configuration | vCPU | Memory | Storage | Per-minute | Fits | | --------------------------------------------------------------------------- | ---- | ------ | --------- | ---------- | --------------------------------------------------------------------------- | | 6x (warp-macos-15-arm64-6x, warp-macos-26-arm64-6x, warp-macos-14-arm64-6x) | 6 | 14GB | 120GB SSD | $0.08 | scan on one or two destinations, lint lanes, bundle install and pod install | | 12x (warp-macos-15-arm64-12x, warp-macos-26-arm64-12x) | 12 | 28GB | 270GB SSD | $0.16 | gym release archives, wide scan device matrices, large DerivedData | Start the test lane on 6x. `scan` spends much of its wall clock booting a simulator, installing the test host, and running tests serially inside one destination, and none of that shortens with more cores. Put the release lane on 12x. `gym` runs a Release build with whole-module optimization, produces dSYMs, and then exports the IPA, which is the one part of a fastlane pipeline with enough parallel compile work to use the extra cores. Storage decides more than people expect. A 120GB SSD holds Xcode, the bundled simulator runtimes, the checkout, `vendor/bundle`, `Pods`, DerivedData, and, in a release lane, an `.xcarchive` plus the exported IPA. A large app with several pods and a fat DerivedData tree can run that disk close to full, and the 12x configuration carries 270GB. At $0.16 against $0.08 per minute, the larger label lowers the cost of a job only when it cuts that job's wall clock by more than half. For a release lane that runs a handful of times a day, wall clock usually matters more than the per-job cost. For a test lane on every push, the per-job cost dominates. ### [Worked cost model](#worked-cost-model)Copy Anchor Link Take a six-engineer iOS team on a 22 weekday month. Test lane: 40 pull request pushes per weekday, 9 minutes each, on `warp-macos-15-arm64-6x`. * 40 pushes x 22 days = 880 jobs * 880 jobs x 9 minutes = 7,920 minutes * 7,920 minutes x $0.08 = **$633.60** Release lane: 5 merges to the main branch per weekday, 19 minutes each, on `warp-macos-26-arm64-12x`. * 5 merges x 22 days = 110 jobs * 110 jobs x 19 minutes = 2,090 minutes * 2,090 minutes x $0.16 = **$334.40** Monthly total: $633.60 + $334.40 = **$968.00**. That total is the whole bill for runner time. ### [Against GitHub-hosted macOS list prices](#against-github-hosted-macos-list-prices)Copy Anchor Link GitHub publishes its per-minute rates in the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). The rows below were checked on 2026-08-13. | Runner | Architecture | Shape | Per-minute rate | | --------------------------------------------- | ------------ | ------------- | --------------- | | GitHub-hosted macOS standard (actions\_macos) | arm64 or x64 | 3 or 4 cores | $0.062 | | GitHub-hosted macOS larger, arm64 (macos\_xl) | arm64 | 5 vCPU, 14 GB | $0.102 | | GitHub-hosted macOS larger, x64 (macos\_l) | x64 | 12 cores | $0.077 | | warp-macos-15-arm64-6x | arm64 | 6 vCPU, 14GB | $0.080 | | warp-macos-15-arm64-12x | arm64 | 12 vCPU, 28GB | $0.160 | The like-for-like row is the arm64 larger runner: warp-macos-latest-arm64-6x (6 vCPU, 14 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price. GitHub list price checked on 2026-08-13\. The gap is $0.022 per minute, on one more vCPU and the same memory. Applied to the test lane above: 7,920 minutes x $0.102 = $807.84 on the GitHub-hosted arm64 larger runner against $633.60 on `warp-macos-15-arm64-6x`, a difference of $174.24 per month on that lane alone. Note that GitHub rounds each job up to the nearest whole minute, which weighs on repositories running many short lanes. ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four things dominate fastlane wall clock time on GitHub Actions, and the compiler is only one of them. ### [Gem installs on every run](#gem-installs-on-every-run)Copy Anchor Link Fastlane arrives as a gem, and a typical `Gemfile` pulls in fastlane, cocoapods, xcpretty, and a handful of plugins. Resolving and building those native extensions from scratch is a fixed tax paid on every job, because the runner storage is deleted when the runner terminates. Cache `vendor/bundle` keyed on `Gemfile.lock`. The key is exact, so a lockfile change misses cleanly and a restore key prefix is enough of a fallback. Keep `FASTLANE_SKIP_UPDATE_CHECK` set so the lane does not spend a network round trip checking RubyGems for a newer fastlane on every invocation. ### [CocoaPods resolution](#cocoapods-resolution)Copy Anchor Link `pod install` does two separate things: it resolves the dependency graph against the specs repositories, and it downloads and integrates each pod. The resolution step is the slow half on a cold machine, because CocoaPods has to fetch specs before it can resolve anything. Cache `Pods` and `~/Library/Caches/CocoaPods` together, keyed on `Podfile.lock`, and run `pod install --deployment` so the job fails loudly when the lockfile and the manifest disagree instead of quietly re-resolving. Commit `Podfile.lock`, which most teams already do, or the cache key changes on every run and the cache never helps. ### [Keychain and signing setup](#keychain-and-signing-setup)Copy Anchor Link Signing failures look like build failures and land late, after the archive has already compiled. The pattern that survives on ephemeral runners is the one in the Fastfile above: `setup_ci` first, `match` in readonly mode second, `gym` third. `setup_ci` exists because the runner has no unlocked login keychain and no interactive user. It creates a temporary keychain, unlocks it, and sets a partition list so `codesign` reads the private key instead of blocking on an authorization prompt that no one can answer. A lane that calls `match` without `setup_ci` on a headless macOS runner hangs until the job timeout fires. The ephemeral model removes the other half of the problem. Each job gets a fresh VM and its storage is destroyed when the runner terminates, so a certificate imported for one job cannot leak into the next, and there is no stale keychain to clean up between runs. Credential handling is described in the [runner security documentation](/docs/ci/security). ### [Archive and export time](#archive-and-export-time)Copy Anchor Link `gym` is the longest step in a release lane. It runs a Release configuration build with whole-module optimization, generates dSYMs, and then runs `-exportArchive` to produce the IPA, and the export step alone can take minutes on a large app because it re-signs every embedded framework and extension. Two levers help. Set `derived_data_path` so the archive reuses build products across steps in the same job rather than writing to a random path per invocation. And keep the release lane off the pull request trigger entirely, so the expensive path runs on merges rather than on every push. When a lane fails on the runner and not on a laptop, the [Action Debugger](/docs/ci/tools/action-debugger) pauses the workflow and opens an SSH session on the runner machine, which is the shortest route to inspecting a keychain, a provisioning profile, or a `Podfile.lock` in place. ## [Proof](#proof)Copy Anchor Link Public repositories running `warp-` labels are readable evidence for the macOS half of this setup. [manaflow-ai/cmux](https://github.com/manaflow-ai/cmux/blob/main/.github/workflows/ci.yml) shards its app-host XCTest suite across `warp-macos-15-arm64-6x`, the same label the test job above uses (checked on 2026-08-13). The migration cost is a label. The macOS images carry the same tooling as GitHub-hosted macOS runners, so `fastlane`, `bundler`, `pod`, `xcodebuild`, and `security` behave the same way and marketplace actions keep working. Two parts of the product surface earn their keep here: CI observability reports right-sizing recommendations per repository, workflow, job, and instance type from measured CPU and memory utilization, which settles the 6x against 12x question faster than guessing, and the Action Debugger opens a shell on a runner when a signing failure will not reproduce locally. Rates, sizes, and labels for every platform are on the [WarpBuild pricing page](/pricing), and the full macOS lineup sits on the [macOS runners hub](/runners/macos). For the wider iOS pipeline around the lanes, see [iOS builds on GitHub Actions](/solutions/ios-github-actions). For the certificate and profile mechanics underneath `match`, see [code signing iOS builds on GitHub Actions](/guides/ios-code-signing-github-actions), and for the compile side of the archive lane, see [cutting Xcode build times on GitHub Actions](/guides/xcode-build-times-github-actions). ## [FAQ](#faq)Copy Anchor Link **Which runner label should a fastlane lane use?** Use `warp-macos-15-arm64-6x` (6 vCPU, 14GB, 120GB SSD, $0.08 per minute) for a test or lint lane, and `warp-macos-26-arm64-12x` (12 vCPU, 28GB, 270GB SSD, $0.16 per minute) for a lane that runs `gym` and uploads a build. Both labels go straight into `runs-on`. **How do I stop bundle install and pod install from running from scratch every job?** Cache `vendor/bundle` keyed on `Gemfile.lock` and cache `Pods` plus `~/Library/Caches/CocoaPods` keyed on `Podfile.lock` with `actions/cache@v4`. Every job starts on a fresh VM whose storage is destroyed when the runner terminates, so without those caches both installs resolve and download on every run. **Does fastlane match or a signing keychain work on an ephemeral runner?** Yes. Call `setup_ci` at the top of the release lane so fastlane creates a temporary keychain for the job, then call `match` in readonly mode. The runner VM and its storage are destroyed when the job finishes, so nothing is left behind. **What does a fastlane minute cost against GitHub-hosted macOS runners?** `warp-macos-15-arm64-6x` is $0.08 per minute. GitHub lists its arm64 macOS larger runner at $0.102 per minute and its standard 3-core or 4-core macOS runner at $0.062 per minute, checked on 2026-08-13\. On the like-for-like Apple Silicon larger runner the difference is $0.022 per minute. --- # Flutter Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/flutter-github-actions Description: Split a Flutter pipeline across two runner labels: Android artifacts on Linux x64 at $0.016 per minute and iOS artifacts on macOS at $0.08 per minute. # Flutter Builds on GitHub Actions Split a Flutter pipeline across two runner labels: Android artifacts on Linux x64 at $0.016 per minute and iOS artifacts on macOS at $0.08 per minute. Last verified: 2026-08-13 A Flutter pipeline on GitHub Actions splits along the platform line: the Android artifact builds on a Linux runner, and the iOS artifact needs a macOS runner because Xcode, CocoaPods, and the iOS toolchain only exist on macOS. On WarpBuild that split is two `runs-on` labels in one workflow file, `warp-ubuntu-latest-x64-8x` at $0.016 per minute for the Android job and `warp-macos-15-arm64-6x` at $0.08 per minute for the iOS job. This page covers the two-job workflow with the pub cache and the Gradle caches wired per job, the runner sizes that fit each half, the four bottlenecks that dominate Flutter wall clock time, and a dual-platform cost model against GitHub-hosted list prices. ## [Overview](#overview)Copy Anchor Link A Flutter repository usually settles into three job shapes. The first is analysis and unit tests: `flutter analyze` and `flutter test` against the Dart VM, with no platform toolchain involved. The second is the Android artifact: `flutter build apk` or `flutter build appbundle`, which shells out to the Gradle build under `android/`. The third is the iOS artifact: `flutter build ios` or `flutter build ipa`, which runs CocoaPods and then `xcodebuild`. Only the third needs macOS. The first two run on Linux, where a minute costs a fraction of a macOS minute, so the platform split is also the cost split. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. Two macOS limits shape the layout of a Flutter pipeline. macOS runners do not support nested virtualization. They cannot run Docker. So an Android emulator job for integration tests, or a container that stands up a backend fixture, stays on a Linux label in a separate job, and the two halves exchange work through artifacts rather than through a shared runner. ### [Linux x64 labels for the Android half](#linux-x64-labels-for-the-android-half)Copy Anchor Link | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | ### [macOS labels for the iOS half](#macos-labels-for-the-ios-half)Copy Anchor Link | Runner label | macOS | vCPU | Memory | Storage | Price per minute | Alias | | ----------------------- | -------- | ---- | ------ | --------- | ---------------- | --------------------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | | Both tables come from the [cloud runners documentation](/docs/ci/cloud-runners), verified 2026-08-13\. The `latest` aliases track macOS 15, in sync with GitHub's `macos-latest` tag. The macOS 26 images ship the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released, which matters to a Flutter team that needs a newer iOS SDK than the macOS 15 image carries. Caching behaves differently on each half, and a Flutter pipeline touches both. The WarpBuild cache is enabled by default on Linux runners, where [WarpBuilds/cache@v1](/docs/ci/features/caching) is a drop-in replacement for `actions/cache@v4`. Entries are scoped to the key, the version, and the branch. The version hash covers the compression tool and the cached paths, so an entry written by the iOS job on macOS never restores in the Android job on Linux. Entries expire after 7 days of last use, and cache storage bills at $0.20 per GB-month with $0.0001 per operation on hosted runners. One scheduling note for the iOS half. The cloud runners documentation records unlimited concurrency for the generally available Linux and Windows runners, and asks teams that need high macOS concurrency to contact support, so a wide iOS matrix is worth raising before it lands. ## [Configuration](#configuration)Copy Anchor Link Point each job at the label its platform needs. Nothing else in the workflow changes, because the runner images carry the same tooling as GitHub-hosted runner images. Copy Text ``` name: flutter on: pull_request: push: branches: [main] concurrency: group: flutter-${{ github.ref }} cancel-in-progress: true env: FLUTTER_VERSION: 3.35.4 jobs: android: runs-on: warp-ubuntu-latest-x64-8x timeout-minutes: 30 steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: '17' - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} cache: true - name: Cache pub packages uses: WarpBuilds/cache@v1 with: path: ~/.pub-cache key: pub-linux-${{ hashFiles('**/pubspec.lock') }} restore-keys: pub-linux- - name: Cache Gradle user home uses: WarpBuilds/cache@v1 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-linux-${{ hashFiles('android/**/*.gradle*', 'android/**/gradle-wrapper.properties') }} restore-keys: gradle-linux- - run: flutter precache --android - run: flutter pub get - run: flutter analyze - run: flutter test - run: flutter build appbundle --release - uses: actions/upload-artifact@v4 with: name: app-release-aab path: build/app/outputs/bundle/release/app-release.aab ios: runs-on: warp-macos-15-arm64-6x timeout-minutes: 45 steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} cache: true - name: Cache pub packages uses: actions/cache@v4 with: path: ~/.pub-cache key: pub-macos-${{ hashFiles('**/pubspec.lock') }} restore-keys: pub-macos- - name: Cache CocoaPods uses: actions/cache@v4 with: path: | ios/Pods ~/Library/Caches/CocoaPods key: pods-${{ hashFiles('ios/Podfile.lock') }} restore-keys: pods- - run: flutter precache --ios - run: flutter pub get - run: pod install --project-directory=ios - run: flutter build ios --release --no-codesign release-ipa: needs: [android, ios] if: github.ref == 'refs/heads/main' runs-on: warp-macos-26-arm64-12x timeout-minutes: 60 steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} cache: true - run: flutter pub get - run: flutter build ipa --release --export-options-plist=ios/ExportOptions.plist - uses: actions/upload-artifact@v4 with: name: ipa path: build/ios/ipa/*.ipa ``` Five details in that file are worth calling out. The pub cache is keyed per platform. `pub-linux-` and `pub-macos-` are separate key prefixes because the cache version already separates the two, and matching prefixes to reality keeps a restore miss from looking like a bug. Both halves resolve the same `pubspec.lock`, so both keys roll at the same moment. The Android job uses `WarpBuilds/cache@v1` and `WarpBuilds/setup-java@v5`. Both use the WarpBuild cache automatically when they run on a WarpBuild runner, with no extra configuration. The iOS job stays on `actions/cache@v4` because the WarpBuild cache is enabled on Linux runners. Gradle caching here is explicit rather than delegated. `flutter build appbundle` invokes the project's own Gradle wrapper under `android/`, so caching `~/.gradle/caches` and `~/.gradle/wrapper` covers the dependency and wrapper downloads for that invocation. A job that calls `./gradlew` directly, for a flavor build or a native module test, can use [WarpBuilds/gradle-actions/setup-gradle@v5](/docs/ci/features/caching/setup-actions) instead, which caches the whole Gradle User Home including the build cache. `flutter precache --android` and `flutter precache --ios` download only the engine artifacts that half of the pipeline needs. The default `flutter precache` pulls artifacts for every target platform, which is several hundred megabytes of download that the job never uses. The release job builds a signed IPA on the 12 vCPU macOS label and stops there. Certificate import, the throwaway keychain, and the provisioning profile sequence are the same on a Flutter project as on a native project, and they are written out step by step on the [iOS builds page](/solutions/ios-github-actions). ## [Sizing](#sizing)Copy Anchor Link The two halves size independently, because they bottleneck on different resources. | Job | Label | Why | | ---------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | Analyze and test | warp-ubuntu-latest-x64-4x | The Dart analyzer and flutter test run on the Dart VM. 4 vCPU and 16GB covers most app-sized repositories. | | Android artifact | warp-ubuntu-latest-x64-8x | Gradle, Kotlin compilation, and the AOT snapshot for the release engine want cores and memory at the same time. | | Wide Android matrix | warp-ubuntu-latest-x64-16x | Several flavors or ABI splits built in one job, or a module graph wide enough to keep 16 cores busy. | | iOS artifact | warp-macos-15-arm64-6x | pod install plus an unsigned xcodebuild of the Runner target fits comfortably in 6 vCPU and 14GB. | | Signed release archive | warp-macos-26-arm64-12x | The archive compiles in release configuration, produces dSYMs, and writes an .xcarchive and an IPA into the same disk. | Storage decides more of the macOS choice than core count does. The 6 vCPU label carries a 120GB SSD, which holds Xcode, the bundled simulator runtimes, the checkout, the pub cache, the Pods directory, and DerivedData. A release job that also writes an `.xcarchive` and an exported IPA runs that disk close to full on a large app. The 12 vCPU label carries 270GB, which removes the cleanup steps that a tight disk otherwise forces into the workflow. On the Linux side, resist jumping to 16 vCPU by default. A Flutter Android build spends much of its time in a single Gradle configuration phase and in the Dart AOT compiler, and neither scales linearly with cores. [CI observability](/docs/ci/features/observability) reports right-sizing recommendations from measured CPU and memory utilization per repository, workflow, and job, which settles the question faster than guessing from one run. ### [List prices against GitHub-hosted runners](#list-prices-against-github-hosted-runners)Copy Anchor Link GitHub publishes its per-minute rates in the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and its runner shapes in the [hosted runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Both were checked on 2026-08-13. | Shape | WarpBuild label | WarpBuild rate | GitHub-hosted equivalent | GitHub rate | Difference | | -------------- | -------------------------------------- | -------------- | -------------------------------------------------------- | ----------- | --------------------------- | | 4 vCPU, 16 GB | warp-ubuntu-latest-x64-4x | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent lower list price | | 8 vCPU, 32 GB | warp-ubuntu-latest-x64-8x | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent lower list price | | 16 vCPU, 64 GB | warp-ubuntu-latest-x64-16x | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent lower list price | | macOS ARM64 | warp-macos-15-arm64-6x (6 vCPU, 14 GB) | $0.08 | largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB) | $0.102 | 22 percent lower list price | The macOS row is the one that carries a shape difference as well as a price difference: `warp-macos-latest-arm64-6x` (6 vCPU, 14 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB), which is 22 percent lower list price on one more vCPU and the same memory. GitHub list price checked on 2026-08-13\. GitHub's standard macOS runner is cheaper per minute at $0.062, on a 3 vCPU, 7 GB machine, so that row compares different amounts of hardware rather than the same machine at two prices. ### [Worked cost model for a two-platform pipeline](#worked-cost-model-for-a-two-platform-pipeline)Copy Anchor Link Assumptions, stated so they can be replaced with your own numbers: a team of eight on a 22 weekday month, 15 pull request pushes per weekday for 330 pipeline runs, an Android job of 12 minutes on `warp-ubuntu-latest-x64-8x`, and an iOS job of 18 minutes on `warp-macos-15-arm64-6x`. Job durations are held equal on both sides, so the table isolates the rate difference. | Job | Minutes per month | WarpBuild rate | WarpBuild cost | GitHub-hosted rate | GitHub-hosted cost | | ------------------ | ----------------- | -------------- | -------------- | ------------------ | ------------------ | | Android (8 vCPU) | 3,960 | $0.016 | $63.36 | $0.022 | $87.12 | | iOS (6 vCPU macOS) | 5,940 | $0.08 | $475.20 | $0.102 | $605.88 | | **Total** | **9,900** | **$538.56** | **$693.00** | | | The monthly difference is $154.44, which is 22 percent of the GitHub-hosted total. Add eight signed release builds of 26 minutes each on `warp-macos-26-arm64-12x`: 208 minutes at $0.16 is $33.28, for a WarpBuild total of $571.84\. That row has no like-for-like GitHub comparison, because GitHub publishes no 12 vCPU Apple Silicon macOS shape. Every rate above is on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four things dominate Flutter wall clock time on GitHub Actions. ### [A cold pub cache and cold engine artifacts](#a-cold-pub-cache-and-cold-engine-artifacts)Copy Anchor Link Every job starts on a fresh ephemeral VM, so `~/.pub-cache` is empty and `flutter pub get` re-downloads the full dependency set from pub.dev before any code compiles. On top of that, `flutter precache` fetches the engine artifacts for the target platform, which include `gen_snapshot` and the platform engine binaries. Cache `~/.pub-cache` keyed on `pubspec.lock`, cache the Flutter SDK itself through the `cache: true` input on the setup action, and narrow the precache to one platform per job. The three together turn a multi-minute cold start into a restore. ### [CocoaPods installs](#cocoapods-installs)Copy Anchor Link `pod install` resolves the Podfile against the local CocoaPods specs repository, and on a cold machine it clones or updates that repository before it installs anything. On plugin-heavy Flutter apps the Pods directory itself is large, and the install compiles nothing but still moves a lot of files. Cache `ios/Pods` keyed on `ios/Podfile.lock` and cache `~/Library/Caches/CocoaPods` alongside it. Commit `Podfile.lock` so the key is stable, and keep `pod install` as its own step so the timeline shows what it cost. ### [Gradle daemon cold start](#gradle-daemon-cold-start)Copy Anchor Link The Android half starts a fresh JVM and a fresh Gradle daemon on every job, then runs configuration across the app module and every plugin module Flutter generates. Configuration time grows with the plugin count, and a Flutter app with 30 plugins configures 30 extra Gradle projects. Cache `~/.gradle/caches` and `~/.gradle/wrapper` so dependency resolution and the wrapper distribution download are restores rather than fetches. Keep the daemon settings explicit in `android/gradle.properties`, and give the job enough memory that the Kotlin compiler and the Dart AOT step are not competing for the same heap headroom. ### [iOS archive time](#ios-archive-time)Copy Anchor Link The signed release archive is the longest step in a Flutter pipeline. It compiles the Dart code to native AOT, builds the Runner target in release configuration, links every plugin pod, generates dSYMs, and then runs the export step that produces the IPA. Two things help. Run the archive only on the branch that ships, as the workflow above does with an `if` on `refs/heads/main`, so pull request runs stop at the unsigned build. And put that job on the 12 vCPU macOS label, where the extra cores shorten the compile and the 270GB disk holds the archive and the IPA without a cleanup step. When a job fails on the runner and reproduces nowhere else, the Action Debugger pauses the workflow and opens an SSH session on the runner machine, which is the shortest path to inspecting a Podfile resolution, a Gradle daemon log, or a provisioning profile in place. ## [FAQ](#faq)Copy Anchor Link **Which runner labels should a Flutter pipeline use?** Put the Android job on `warp-ubuntu-latest-x64-8x` (8 vCPU, 32GB, $0.016 per minute) and the iOS job on `warp-macos-15-arm64-6x` (6 vCPU, 14GB, 120GB SSD, $0.08 per minute). Move the signed release build to `warp-macos-26-arm64-12x` (12 vCPU, 28GB, 270GB SSD, $0.16 per minute) when the archive runs long or the disk fills. **Can the Android half of a Flutter build run on a macOS runner?** It runs, and it costs $0.08 per minute instead of $0.016\. macOS runners also do not support nested virtualization and cannot run Docker, so emulator jobs and container fixtures have no home there. Keep the Android artifact and any containerized step on a Linux label. **Does the WarpBuild cache cover both halves of a Flutter pipeline?** The WarpBuild cache is enabled by default on Linux runners, so the Android job uses `WarpBuilds/cache@v1` as a drop-in replacement for `actions/cache@v4`. The iOS job uses `actions/cache@v4` against the GitHub Actions cache backend. Cache entries are scoped to key, version, and branch, and the version covers the compression tool and the cached paths, so an entry saved on macOS never restores on Linux. **What does a two-platform Flutter pipeline cost per month?** For 330 pipeline runs with a 12 minute Android job and an 18 minute iOS job, WarpBuild runner minutes come to $538.56 against $693.00 for the same minutes on GitHub-hosted larger runners (GitHub list prices, checked on 2026-08-13). Pricing is purely usage based, so that is the whole bill. --- # GitHub Actions Observability and Runner Metrics | WarpBuild URL: https://www.warpbuild.com/solutions/github-actions-observability Description: GitHub Actions observability means per-job CPU, memory, disk, and queue metrics. WarpBuild collects them from an agent on every runner and exposes them by API. # GitHub Actions Observability and Runner Metrics GitHub Actions observability means per-job CPU, memory, disk, and queue metrics. WarpBuild collects them from an agent on every runner and exposes them by API. Last verified: 2026-08-13 GitHub Actions observability means measuring what the runner did while a job executed: CPU, memory, filesystem, disk I/O, and network utilization, plus the system logs sitting alongside the workflow logs. WarpBuild collects that telemetry from an agent on every runner it operates, correlates it with GitHub Actions job logs, and exposes it in two dashboard surfaces and in the HTTP API. GitHub's own job logs tell you which step ran and how long it took. They do not tell you that a job held every vCPU near saturation for most of its run, or that it never touched more than a tenth of the 32 GB you paid for. This page covers what gets recorded, how to switch runners over, how to read the numbers, and how to query them from a script or an AI assistant. ## [Overview](#overview)Copy Anchor Link The [Observability page](/docs/ci/features/observability) in WarpBuild is organized into two sections, and they answer different questions. **Recommendations** answers "is this job on the right runner". It aggregates utilization across many runs of the same job and flags instances that are under-provisioned or over-provisioned against fixed thresholds. **Usage** answers "what happened on this one machine". It shows the metrics and logs for an individual runner instance, including both the syslogs from the runner and the GitHub Actions logs, so a spike in disk I/O lines up with the step that caused it. ### [The metric hierarchy](#the-metric-hierarchy)Copy Anchor Link Recommendations aggregates performance metrics in a four-level hierarchy, which matches how GitHub Actions itself is structured: 1. **Repository**: the top-level grouping of all workflows in a repository. 2. **Workflow**: an individual workflow file, such as `ci.yml` or `release.yml`. 3. **Job**: a specific job defined in that workflow file, such as `build` or `integration-tests`. 4. **Instance type**: the runner instance type that job ran on. That ordering matters when you go looking for waste. A repository average hides everything. One workflow inside it might be dominated by a single fan-out job that runs 200 times a day on a 32 vCPU label while using four cores. Drilling from repository to workflow to job to instance type is how you find that job instead of guessing at it. ### [What the agent records](#what-the-agent-records)Copy Anchor Link The WarpBuild agent on each runner collects three things: utilization metrics, system logs from the runner, and the GitHub Actions logs used to correlate workflow execution with the machine behavior. Collection runs over OpenTelemetry on port `33931`. | Metric | Definition | | ---------------------- | ---------------------------------------------------------------------------------------------- | | CPU utilization | Maximum rolling average CPU usage percentage over the last 30 seconds | | Memory utilization | Maximum memory usage percentage | | Filesystem utilization | Maximum storage usage percentage | | Disk I/O | Maximum rolling average of combined read and write disk throughput over the last 30 seconds | | Network utilization | Maximum rolling average of combined read and write network throughput over the last 30 seconds | Two operational details decide whether you see data at all. Observability only collects metrics and logs for jobs longer than roughly one minute, so a 20 second lint job will show nothing. And for pooled instances with collection paused, telemetry can appear in the interface before a job is allocated, because the agent initializes before any data is actually collected. On data handling, the documentation is explicit: no sensitive data is collected through telemetry, collection is limited to syslogs and utilization metrics, everything is encrypted in transit, and none of it is used for training. ### [Reports, the aggregate view](#reports-the-aggregate-view)Copy Anchor Link The [Reports page](/docs/ci/features/reports) sits next to Observability and covers three sections: Billing, Jobs, and Queue Timings. Every tab supports date range selection, sorting, filtering, search, and CSV export, and the export includes every row matching the current filters rather than the current page. The Jobs section aggregates per unique combination of repository, workflow, and job name, with run count, success rate, duration P75 and P90, queue time P75 and P90, CPU P75 and P90, and memory P75 and P90\. The CPU and memory columns require Observability to be enabled; jobs without telemetry show a dash. Observability is the piece that tells you whether the rest of it is configured correctly. ## [Configuration](#configuration)Copy Anchor Link There is nothing to install. The agent ships inside the runner image, so a job produces telemetry as soon as it runs on a WarpBuild runner. Switching a workflow over is a label change. All four report into the same hierarchy. Copy Text ``` name: ci on: pull_request: push: branches: [main] jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npm test integration-tests: runs-on: warp-ubuntu-latest-x64-8x services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres ports: ["5432:5432"] steps: - uses: actions/checkout@v4 - run: ./scripts/integration.sh arm-image: runs-on: warp-ubuntu-latest-arm64-4x steps: - uses: actions/checkout@v4 - run: docker build --platform linux/arm64 -t app:arm64 . ios-build: runs-on: warp-macos-latest-arm64-6x steps: - uses: actions/checkout@v4 - run: xcodebuild -scheme App -destination 'generic/platform=iOS' build windows-build: runs-on: warp-windows-latest-x64-4x steps: - uses: actions/checkout@v4 - run: ./build.ps1 ``` Once that runs, the four jobs appear under `ci.yml` in the Recommendations hierarchy, each with its own instance type row. The `integration-tests` job on `warp-ubuntu-latest-x64-8x` gets rated separately from `unit-tests` on the 4x label, which is the point: sizing is a per-job decision. ### [BYOC and firewall rules](#byoc-and-firewall-rules)Copy Anchor Link BYOC runs on AWS, GCP, and Azure, and the same agent runs on those instances. If you filter egress inside your own account, allow the observability channel on port `33931` alongside the control plane. Without it, jobs still run and still bill, and the CPU and memory columns in the Jobs report stay empty, which is the usual cause of a fleet that reports duration but no utilization. ### [API access](#api-access)Copy Anchor Link The API is served at `https://api.warpbuild.com/api/v1`, and you authenticate with an API key carrying the `ci` scope as a bearer token. Pull the Jobs report for one repository over the last 30 days: Copy Text ``` curl -sS -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/reports/jobs\ ?start_date=2026-07-13T00:00:00Z\ &end_date=2026-08-13T00:00:00Z\ &repos=acme/web\ &chart_metric=queue_time\ &chart_percentile=p90\ &sort_by=duration_p90&sort_order=desc&per_page=25" ``` The response carries a chart block, a paginated table, and the filter values available for the current window. Values below are illustrative shapes rather than measurements: Copy Text ``` { "organization_id": "worg_1a2b3c", "chart": { "metric": "queue_time", "percentile": "p90", "bucket_interval_seconds": 3600, "rows": [ { "repo": "acme/web", "workflow_name": "ci.yml", "job_name": "integration-tests", "points": [ { "bucket": "2026-08-12T09:00:00Z", "value": 11.4, "job_count": 18 } ] } ] }, "table": { "items": [ { "repo": "acme/web", "workflow_name": "ci.yml", "job_name": "integration-tests", "run_count": 412, "success_rate": 96.8, "duration_p75": 512.4, "duration_p90": 604.1, "queue_time_p75": 4.2, "queue_time_p90": 11.4, "cpu_p75": 38.5, "cpu_p90": 44.1, "memory_p75": 29.6, "memory_p90": 33.2 } ], "page": 1, "per_page": 25, "total_pages": 3, "total_rows": 61 } } ``` Add `&format=csv` to the same call to get the export the dashboard download button produces. ### [Wiring an assistant to the data](#wiring-an-assistant-to-the-data)Copy Anchor Link Reading a dashboard is a person's job. Answering "which jobs regressed this week" is a query. WarpBuild hosts a Model Context Protocol server at `https://mcp.warpbuild.com/mcp`, so an MCP host such as Cursor can call the API directly. Generate an API key with the CI scope from the dashboard, then add the server: Copy Text ``` { "mcpServers": { "warpbuild": { "url": "https://mcp.warpbuild.com/mcp", "headers": { "Authorization": "Bearer " } } } } ``` With that connected, the assistant can pull the Jobs report, the Queue Timings report, and the runner recommendations in one turn, and answer questions about your GitHub Actions usage without anyone opening a browser tab. The setup steps are in the [MCP support docs](/docs/ci/mcp). ## [Sizing](#sizing)Copy Anchor Link Sizing is where observability turns into a decision. The Recommendations view compares each job's observed utilization against fixed thresholds and labels the ones that are out of range. | Metric | Threshold | Alert label | | -------------------------- | ------------------------------- | --------------------- | | Max sustained CPU | \>= 80% | High CPU Usage | | Max memory utilization | \>= 80% | High Memory Usage | | Max filesystem utilization | \>= 80% | High Filesystem Usage | | Max disk I/O | \>= 80% of supported throughput | High Disk IO | Jobs above those lines are under-provisioned. Jobs sitting far below them across many runs are over-provisioned, and that is the more common finding, because runner labels get chosen once during migration and then never revisited. The API returns the same analysis: Copy Text ``` curl -sS -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/org_metrics/job_runner_recommendations\ ?search=integration-tests&per_page=20" ``` Copy Text ``` { "organization_id": "worg_1a2b3c", "recommendations": [ { "repository": "acme/web", "workflow_name": "ci.yml", "job_name": "integration-tests", "total_runs": 412, "recommendation": { "type": "downgrade", "current_label": "warp-ubuntu-latest-x64-8x", "current_vcpu": 8, "current_memory_gb": 32, "recommended_label": "warp-ubuntu-latest-x64-4x", "recommended_vcpu": 4, "recommended_memory_gb": 16, "resources": ["cpu", "memory"], "already_at_max_size": false }, "runs": [ { "runner_display_name": "warp-ubuntu-latest-x64-8x", "runner_instance_id": "wrni_9f8e7d", "max_sustained_cpu": 34.8, "max_memory_utilization": 29.6, "max_filesystem_utilization": 22.1, "started_at": "2026-08-12T09:14:03Z", "ended_at": "2026-08-12T09:20:31Z", "job_link": "https://github.com/acme/web/actions/runs/1234567890/job/9876543210" } ] } ], "page": 1, "per_page": 20, "total_pages": 1, "total_rows": 7 } ``` Two fields carry most of the meaning. `resources` says which signals drove the call, so you can tell a memory-bound job from a CPU-bound one. `already_at_max_size` is set when a job is already on the largest runner and still saturating it, which means a label change will not help; the remaining levers are splitting the work across more jobs, caching more of it, or moving the heavy step to a remote Docker builder. ### [The rate table you are choosing from](#the-rate-table-you-are-choosing-from)Copy Anchor Link Linux x64 rates, from the WarpBuild [pricing page](/pricing): | Runner label | vCPU | RAM | Storage | USD per minute | | -------------------------- | ---- | ------ | --------- | -------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Linux ARM64 labels carry the same vCPU and RAM shapes at $0.003, $0.006, $0.012, $0.024, and $0.048 per minute. Windows x64 runs from $0.016 at 4 vCPU to $0.128 at 32 vCPU, and the macOS ARM64 6x label is $0.08 per minute. Runners are billed on a per-minute basis, so the sizing decision and the billing unit line up. ### [A worked model](#a-worked-model)Copy Anchor Link Take the `integration-tests` job above: 412 runs in 30 days, duration P90 of 604 seconds, so budget 11 minutes of billed time per run at the tail. Round the volume to 400 runs per month at 10 minutes each, which is 4,000 minutes. | Scenario | Rate per minute | Minutes per month | Monthly cost | | ------------------------------------------------------- | --------------- | ----------------- | ------------ | | GitHub-hosted 8-core Linux larger runner | $0.022 | 4,000 | $88.00 | | warp-ubuntu-latest-x64-8x | $0.016 | 4,000 | $64.00 | | warp-ubuntu-latest-x64-4x, duration holds | $0.008 | 4,000 | $32.00 | | warp-ubuntu-latest-x64-4x, duration grows to 13 minutes | $0.008 | 5,200 | $41.60 | The GitHub rate of $0.022 per minute for the 8-core Linux larger runner comes from GitHub's published [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), cross-checked against [github.com/pricing](https://github.com/pricing), checked on 2026-08-13\. WarpBuild rates come from the [pricing page](/pricing) and were checked on 2026-08-13\. Stated as a rate comparison: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. The first two rows hold minutes equal. Note the fourth row. A downgrade recommendation is a hypothesis about a job, and the outcome depends on how the work parallelizes. A test suite that saturates four cores for a third of its run will stretch when it loses four more. Ship the label change, wait for a week of runs, and compare duration P75 and P90 in the Jobs report against the same window before the change. The recommendation tells you where to look; the Jobs report tells you what actually happened. ## [Bottlenecks](#bottlenecks)Copy Anchor Link Utilization tells you about the machine. Queue time tells you about everything before the machine. Both live in the same reports, and confusing them sends teams to buy larger runners for a problem that larger runners cannot fix. ### [Queue time](#queue-time)Copy Anchor Link The Queue Timings section reports how long jobs waited before starting, broken down per runner label and stack, with run count, queue time P75, and queue time P90 per row, and a daily chart of average queue time next to the day's job count. Copy Text ``` curl -sS -H "Authorization: Bearer $WARPBUILD_API_KEY" \ "https://api.warpbuild.com/api/v1/reports/queue-timings\ ?start_date=2026-08-01T00:00:00Z\ &end_date=2026-08-13T00:00:00Z\ &runner_labels=warp-ubuntu-latest-x64-8x\ &sort_by=queue_time_p90&sort_order=desc&format=csv" ``` Read the daily chart against the job count series. Queue time that climbs with volume points at capacity or at workflow configuration. Queue time that is flat and high across all volumes usually points at a label that no runner matches, or at a workflow-level `concurrency` group serializing runs that could go wide. A queue that spikes only during the morning push wave is worth checking against your own `concurrency` groups and matrix limits before anyone changes runner labels, and [measuring and reducing GitHub Actions queue times](/guides/github-actions-queue-times) walks through that sequence. ### [CPU saturation](#cpu-saturation)Copy Anchor Link `max_sustained_cpu` at or above 80 percent with a duration P90 well above P75 is the classic under-provisioned pattern: the job finishes fine most of the time and falls off a cliff when the machine is busy. Move up one label and re-measure. ### [Memory pressure](#memory-pressure)Copy Anchor Link Memory shows up differently. A job that reaches high memory utilization often fails outright rather than running slowly, because the kernel kills the process. If a job's failure rate in the Jobs report moves together with `max_memory_utilization` in the recommendation payload, size for memory rather than cores. The label families step vCPU and RAM together, so moving from `warp-ubuntu-latest-x64-4x` to `warp-ubuntu-latest-x64-8x` takes RAM from 16 GB to 32 GB. ### [Disk I/O and filesystem](#disk-io-and-filesystem)Copy Anchor Link High disk I/O with low CPU is usually a caching problem. Dependency installs, Docker layer pulls, and artifact unpacks all show as sustained read and write throughput with idle cores. Filesystem utilization near 80 percent on a 150GB SSD points at artifacts left behind inside a job rather than at a sizing issue. ### [Network](#network)Copy Anchor Link Sustained network throughput with idle cores means the job is waiting on downloads: base images, packages, or artifacts. That is a caching and image question. Artifact-heavy deployment pipelines are covered on the [enterprise zero egress page](/enterprise/zero-egress). ### [Correlating with logs](#correlating-with-logs)Copy Anchor Link When a metric spike needs an explanation, the Usage view is where you get it, because it shows syslogs from the runner and the GitHub Actions logs for the same instance side by side. For failures that need a live machine rather than a chart, the Action Debugger opens an SSH session on the runner mid-workflow, which is covered in [debugging failing GitHub Actions jobs](/guides/debug-github-actions-failures). ## [Proof](#proof)Copy Anchor Link SonarQube runs its GitHub Actions fleet on WarpBuild BYOC runners in its own AWS account. Their setup leans on dashboards for visibility into job and queue metrics, and on custom automation built against the WarpBuild API for image updates and runner configuration changes. Read the full write-up on the [SonarQube case study](/customers/sonarqube). The agent that produces this telemetry is open source and published at [github.com/WarpBuilds/warpbuild-agent](https://github.com/WarpBuilds/warpbuild-agent), so the collection path is inspectable rather than a black box. The Action Debugger referenced above is also an open source GitHub Action, as are the [WarpBuild setup actions](https://github.com/WarpBuilds/setup-node) that handle dependency caching. For teams that need the data under access control, SSO is a flat $250 per month, whatever the user count, listed on the [pricing page](/pricing). Three companion guides go deeper than this page does: * [Collect and read GitHub Actions runner metrics](/guides/github-actions-runner-metrics) covers each utilization metric and how to interpret a single instance timeline. * [Debug failing GitHub Actions jobs on the runner](/guides/debug-github-actions-failures) covers the path from a red check to a live SSH session on the machine that failed. * [Connect the WarpBuild MCP server to an AI assistant](/guides/github-actions-mcp-server) covers the full MCP host setup and the questions an assistant can answer from this data. ## [FAQ](#faq)Copy Anchor Link ### [What does GitHub Actions observability measure that the workflow logs do not?](#what-does-github-actions-observability-measure-that-the-workflow-logs-do-not)Copy Anchor Link Workflow logs record which step ran and how long it took. Runner observability records what the machine did while those steps ran: CPU, memory, filesystem, disk I/O, and network utilization, plus the system logs from the runner itself. WarpBuild collects all five utilization metrics through an OpenTelemetry agent on port 33931 and shows them next to the GitHub Actions logs for the same job. ### [Does collecting runner telemetry read my source code or secrets?](#does-collecting-runner-telemetry-read-my-source-code-or-secrets)Copy Anchor Link No. The WarpBuild documentation states that no sensitive data is collected through telemetry, that collection is limited to syslogs and utilization metrics for CPU, memory, filesystem, and network, that all observability data is encrypted in transit, and that no data is used for training. Collection can also be paused per organization, which stops system logs and GitHub Actions log collection as well. ### [How do I decide when to move a job to a bigger or smaller runner?](#how-do-i-decide-when-to-move-a-job-to-a-bigger-or-smaller-runner)Copy Anchor Link Read the Recommendations view or call GetJobRunnerRecommendations. It flags a job as under-provisioned when max sustained CPU, max memory utilization, or max filesystem utilization reaches 80 percent, and it flags over-provisioned jobs from consistently low utilization. Change one label, then compare duration P75 and P90 in the Jobs report before and after, because a smaller runner can run longer and offset the lower per-minute rate. ### [Can an AI assistant answer questions about my GitHub Actions usage directly?](#can-an-ai-assistant-answer-questions-about-my-github-actions-usage-directly)Copy Anchor Link Yes. WarpBuild hosts a Model Context Protocol server at . Generate an API key with the CI scope, add the server URL and an Authorization Bearer header to your MCP host, and the assistant can call the same reports and recommendation endpoints the dashboard uses. --- # Go Builds on GitHub Actions: Sizing and Caching | WarpBuild URL: https://www.warpbuild.com/solutions/go-github-actions Description: Cache GOMODCACHE and GOCACHE with WarpBuilds/cache keyed on go.sum, then size warp- runners from 4 to 16 vCPU for go build and go test -race jobs. # Go Builds on GitHub Actions: Sizing and Caching Cache GOMODCACHE and GOCACHE with WarpBuilds/cache keyed on go.sum, then size warp- runners from 4 to 16 vCPU for go build and go test -race jobs. Last verified: 2026-08-13 To cache Go builds on GitHub Actions, persist two directories between jobs: the module cache (GOMODCACHE, default `~/go/pkg/mod`) and the build cache (GOCACHE, default `~/.cache/go-build`), keyed on the hash of `go.sum`. On WarpBuild runners, the [WarpBuilds/cache](https://github.com/WarpBuilds/cache) action is a drop-in replacement for `actions/cache@v4` and stores both in WarpBuild's cache backend, which is enabled by default on all Linux runners. Caching solves the download and recompile problem. Runner sizing solves the parallelism problem, because `go build` and `go test` both scale with core count up to the width of your package graph. This page covers the workflow configuration, the sizing decision between 4, 8, and 16 vCPU runners, the four bottlenecks that dominate Go pipelines, and the cost math against GitHub-hosted runner list prices. ## [Overview](#overview)Copy Anchor Link The Go toolchain maintains two caches, and both start empty on every fresh GitHub Actions virtual machine. GOMODCACHE holds downloaded module source. Without it, every job re-fetches every dependency in `go.sum` from the module proxy before compiling anything. GOCACHE holds compiled package artifacts. Without it, `go build` recompiles every package in the dependency graph from scratch. Since Go 1.20, the Go distribution no longer ships precompiled archives for the standard library, so a cold GOCACHE also means recompiling the standard library packages your code imports. Run `go env GOMODCACHE GOCACHE` on any machine to see where both live. Hosted runners are ephemeral by design, so the fix is a cache action that saves and restores those two directories across jobs. Key the cache on `hashFiles('**/go.sum')` so it rolls exactly when the module set changes, and add a `restore-keys` prefix so a dependency bump restores the previous cache instead of starting cold. The second half of the problem is hardware. `go build` compiles independent packages in parallel, bounded by GOMAXPROCS, which defaults to the vCPU count. `go test` goes further: it builds and runs the test binaries of different packages in parallel, controlled by the `-p` flag, which also defaults to GOMAXPROCS. A repository with a wide package graph will keep 8 or 16 cores busy; a repository with 15 packages will leave most of a 32-core machine idle. For Go, the Linux x64 sizes from 4 to 16 vCPU cover almost every repository, and moving a job onto them is a one-line change to `runs-on`. The [cloud runners documentation](/docs/ci/cloud-runners) lists the full catalog with per-minute rates, which also appear on the [pricing page](/pricing). ## [Configuration](#configuration)Copy Anchor Link The explicit form uses `WarpBuilds/cache` to save and restore GOMODCACHE and GOCACHE directly. This workflow builds and race-tests a Go module on an 8 vCPU WarpBuild runner: Copy Text ``` name: go-ci on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.24" cache: false - name: Restore Go module and build caches uses: WarpBuilds/cache@v1 with: path: | ~/go/pkg/mod ~/.cache/go-build key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- - run: go build ./... - run: go vet ./... - run: go test -race ./... ``` `cache: false` on the setup step matters: it disables the built-in caching that would otherwise write to GitHub Actions Cache, so the WarpBuild cache step owns both directories. The `cache-hit` output is available if you want to skip a warm-up step on an exact key match. The shorter form replaces the upstream setup action with [WarpBuilds/setup-go](https://github.com/WarpBuilds/setup-go), which installs Go and routes its toolchain and dependency caching through WarpBuild Cache automatically. Caching is on by default, keyed on `go.mod`; point it at `go.sum` for a key that tracks dependency versions: Copy Text ``` - uses: WarpBuilds/setup-go@v6 with: go-version: "1.24" cache-dependency-path: "**/go.sum" ``` Two behaviors from the [caching documentation](/docs/ci/features/caching) are worth knowing before you rely on either form. The cache is scoped to key, version, and branch, so a cache saved on a feature branch is separate from the one on `main`; seed the cache from `main` and let branch builds restore it through `restore-keys`. Entries expire 7 days after last use, so a repository with weekly activity stays warm and an abandoned branch stops costing storage on its own. Cache usage is metered, and the rates are small enough to state exactly: storage is $0.20 per GB-month and each write or restore operation is $0.0001\. A Go repository with a 2GB combined module and build cache and 3,000 cache operations a month adds about $0.70 to the bill. ## [Sizing](#sizing)Copy Anchor Link WarpBuild Linux x64 runners scale from 2 to 32 vCPU with 4GB of memory per core. The three sizes that matter for Go are 4x, 8x, and 16x: | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | Match the size to package-level parallelism, because that is what the Go toolchain can actually use: **warp-ubuntu-latest-x64-4x** fits a single service with a modest dependency graph, roughly under 50 packages including tests. `go build ./...` on a warm cache finishes quickly at this width, and a larger machine would spend most of its cores idle. **warp-ubuntu-latest-x64-8x** is the default for most Go repositories. Monorepos with several services, or any repository running `go test -race ./...`, benefit from the extra cores for parallel package tests and from 32GB of memory for race-instrumented binaries. **warp-ubuntu-latest-x64-16x** earns its rate when the package graph is wide enough to keep 16 cores busy: large monorepos, integration test suites that run many package test binaries concurrently, or race-detector runs that are memory-hungry at `-p 8`. It is also the practical choice for cross-compilation matrices collapsed into a single job, since each GOOS/GOARCH target is an independent compile that can run alongside the others. A useful calibration step: run your workflow once on 8x with `go test -race ./...` and watch utilization. If the test phase holds all 8 cores near full and memory stays comfortable, 8x is right. If cores sit idle, drop to 4x and keep the same wall time at $0.008 instead of $0.016 per minute. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners on the [GitHub Actions billing page](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and at [github.com/pricing](https://github.com/pricing). Checked on 2026-08-13, the standard `ubuntu-latest` runner for private repositories (2 vCPU) is $0.006 per minute, and larger Linux runners are $0.012 for 4 vCPU, $0.022 for 8 vCPU, and $0.042 for 16 vCPU. | Machine | vCPU | Per-minute rate | Source | | --------------------------------- | ---- | --------------- | ------------------------------------- | | GitHub-hosted standard Linux | 2 | $0.006 | GitHub list price, checked 2026-08-13 | | GitHub-hosted Linux larger runner | 8 | $0.022 | GitHub list price, checked 2026-08-13 | | warp-ubuntu-latest-x64-2x | 2 | $0.004 | WarpBuild pricing | | warp-ubuntu-latest-x64-8x | 8 | $0.016 | WarpBuild pricing | Take a concrete team: a Go monorepo running 1,500 jobs a month, averaging 6 minutes per job on an 8 vCPU machine with a warm cache. That is 9,000 runner minutes a month. | Scenario | Rate | Monthly minutes | Monthly cost | | --------------------------------------------- | ---------- | --------------- | ------------ | | GitHub-hosted Linux larger runner, 8 vCPU | $0.022/min | 9,000 | $198.00 | | warp-ubuntu-latest-x64-8x, 8 vCPU | $0.016/min | 9,000 | $144.00 | | Cache storage and operations (2GB, 3,000 ops) | see above | n/a | $0.70 | Stated as list-price arithmetic: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13\. At the smallest size, warp-ubuntu-latest-x64-2x (2 vCPU, 8 GB) costs $0.004 per minute against $0.006 per minute for ubuntu-latest (2 vCPU, 8 GB on private repositories): 33 percent lower list price. GitHub list price checked on 2026-08-13\. Every rate above is on the [pricing page](/pricing). Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four bottlenecks account for most slow Go pipelines on GitHub Actions. **Cold module download.** Without a restored GOMODCACHE, every job pulls the full module graph from the module proxy before the first compile starts. The fix is the cache configuration above. Verify it worked by checking the job log: a warm job shows no `go: downloading` lines. **Cold build cache.** A restored GOMODCACHE with an empty GOCACHE still recompiles every package, including the standard library packages your code imports. This is the more expensive of the two cold starts, and it is why the cache step lists both directories. If your key rolls on every commit, you are paying this cost on every build; key on `go.sum` and use `restore-keys` so only dependency changes invalidate the compiled artifacts. **Race-detector test runs.** The race detector instruments every memory access, so `go test -race` binaries need several times the memory of uninstrumented runs and execute markedly slower; the [Go race detector documentation](https://go.dev/doc/articles/race%5Fdetector) describes the overhead ranges. On small runners this shows up as swapping or an out-of-memory kill partway through the test phase. Move race runs to 8x or 16x, where the 4GB-per-core ratio absorbs the overhead, and keep a separate fast job running `go test ./...` without the flag for pull request feedback. **Cross-compilation matrices.** Release workflows that build for several GOOS/GOARCH pairs multiply the cold-cache problem, because each target populates its own build cache entries. Give each matrix leg its own cache key so restores stay relevant: Copy Text ``` strategy: matrix: include: - goos: linux goarch: amd64 - goos: linux goarch: arm64 - goos: darwin goarch: arm64 steps: - uses: WarpBuilds/cache@v1 with: path: | ~/go/pkg/mod ~/.cache/go-build key: go-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('**/go.sum') }} ``` For Linux ARM64 targets you can also skip cross-compilation and build natively on [Linux ARM64 runners](/runners/linux-arm64), which start at $0.003 per minute for 2 vCPU. If the matrix feeds container images, [multi-platform Docker builds](/guides/multi-platform-docker-builds) covers the buildx side of the same problem. When a job is slow and the cause is unclear, WarpBuild's CI observability shows OpenTelemetry-based system metrics from the runner agent correlated with GitHub Actions job logs, which separates a CPU-bound compile from a job waiting on network or disk. For interactive debugging, the Action Debugger pauses a workflow and opens an SSH session on the runner, so you can run `go env` and inspect cache directories on the machine itself. Broader workflow-level tactics are collected in the guide on [speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Proof](#proof)Copy Anchor Link The cheapest check is your own repository. The same sizing-plus-caching approach applies to other compiled toolchains; the [Rust on GitHub Actions](/solutions/rust-github-actions) page walks the equivalent decisions for Cargo. ## [FAQ](#faq)Copy Anchor Link **Should a Go cache key hash go.mod or go.sum?** Hash `go.sum`. It changes whenever any dependency in the module graph changes version, so the cache key rolls at exactly the right moments. WarpBuilds/setup-go keys on `go.mod` by default; set `cache-dependency-path` to `**/go.sum` for a tighter key. **Does caching GOCACHE still matter once GOMODCACHE is cached?** Yes. GOMODCACHE only saves the source download. GOCACHE stores compiled package artifacts, which is where most of the time in a cold `go build` goes, including the standard library packages your code imports. **What runner size does go test -race need?** Race-instrumented test binaries need several times the memory of a normal test run. Start on warp-ubuntu-latest-x64-8x with 8 vCPU and 32GB, and move to warp-ubuntu-latest-x64-16x when package-level parallelism keeps 8 cores busy or memory peaks near the limit. **What changes when a Go workflow moves to WarpBuild?** One line per job: the `runs-on` label. --- # Godot Game Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/godot-github-actions Description: Godot exports on GitHub Actions run on WarpBuild Linux, macOS, and Windows runners with a cached export template directory and rates from $0.004 per minute. # Godot Game Builds on GitHub Actions Godot exports on GitHub Actions run on WarpBuild Linux, macOS, and Windows runners with a cached export template directory and rates from $0.004 per minute. Last verified: 2026-08-13 A Godot export on GitHub Actions needs three things on the runner before it produces anything: the editor binary, the export templates that match that editor version exactly, and an `export_presets.cfg` committed to the repository. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so one workflow can fan a Godot release out across all three desktop targets by changing only the `warp-` label on `runs-on`, at $0.008 per minute for the Linux export job, $0.032 for the Windows job, and $0.08 for the macOS job. This page covers the export template cache strategy, the per-platform export configuration, a matrix workflow mapped onto Linux, macOS, and Windows labels, a worked cost model with each platform rate applied, and the four things that dominate wall clock time on export-heavy jobs. ## [Overview](#overview)Copy Anchor Link Godot's headless export is a two-command sequence. `godot --headless --import` walks the project and writes the imported representation of every asset into `.godot/imported`, and `godot --headless --export-release "" ` reads that imported output plus the matching export template and writes the shipping build. Both commands fail loudly when the export templates for the running editor version are absent, which is the most common first failure on a fresh runner. Which target needs which operating system comes down to what happens after the export, not to the export itself. The Linux and Web presets produce their output with no platform tooling beyond the editor, so they belong on a Linux runner. The Windows preset needs Windows when the build embeds icon and version metadata through rcedit or gets signed with `signtool`. The macOS preset needs macOS when Godot hands the bundle to `codesign` and when the workflow submits the result through `xcrun notarytool`, both of which ship with Xcode on the macOS images. That split is the whole reason a Godot pipeline goes multi-platform. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners with the same tooling as the GitHub-hosted images, so the three jobs live in one workflow file and differ by label. The full size and price matrix is in the [cloud runner documentation](/docs/ci/cloud-runners), and the Windows label families are listed on the [Windows runner hub](/runners/windows). Teams that also ship a second engine hit the same sizing questions from a different angle; the [Unity builds page](/solutions/unity-github-actions) covers that pipeline. Wiring the export matrix into a tag-triggered release is covered in the [release build workflow guide](/guides/release-builds-github-actions). ## [Configuration](#configuration)Copy Anchor Link Two caches carry a Godot pipeline, and they have opposite key strategies. **Export templates: key on the Godot version and nothing else.** The template archive published for a given Godot release is immutable, so a cache entry keyed on `4.4.1` stays valid until the project upgrades the engine. Every job in the matrix downloads the same archive, and the unpacked directory differs only by platform: `~/.local/share/godot/export_templates/4.4.1.stable` on Linux, `~/Library/Application Support/Godot/export_templates/4.4.1.stable` on macOS, and `%APPDATA%\Godot\export_templates\4.4.1.stable` on Windows. Restore the platform-appropriate path and skip the download step on a hit. **Imported assets: key on the asset tree.** `.godot/imported` holds textures recompressed for the target, audio transcoded, and meshes converted. Hash the source assets and the import configuration so the entry turns over exactly when the imported output stops being valid, and keep a prefix in `restore-keys` so a run that touches one texture starts from the previous entry instead of importing the project cold. Copy Text ``` name: godot-export on: push: tags: ["v*"] workflow_dispatch: env: GODOT_VERSION: "4.4.1" jobs: export: name: export ${{ matrix.target }} runs-on: ${{ matrix.runner }} timeout-minutes: 60 strategy: fail-fast: false matrix: include: - target: linux runner: warp-ubuntu-latest-x64-4x preset: "Linux" output: build/linux/game.x86_64 - target: web runner: warp-ubuntu-latest-x64-4x preset: "Web" output: build/web/index.html - target: windows runner: warp-windows-latest-x64-8x preset: "Windows Desktop" output: build/windows/game.exe - target: macos runner: warp-macos-latest-arm64-6x preset: "macOS" output: build/macos/game.zip steps: - uses: actions/checkout@v4 with: lfs: true - name: Resolve the export template directory shell: bash run: | case "${{ runner.os }}" in Linux) dir="$HOME/.local/share/godot/export_templates" ;; macOS) dir="$HOME/Library/Application Support/Godot/export_templates" ;; Windows) dir="$APPDATA/Godot/export_templates" ;; esac echo "TEMPLATES_DIR=$dir" >> "$GITHUB_ENV" - name: Install the Godot editor shell: bash run: ./ci/install-godot.sh "$GODOT_VERSION" - id: templates uses: actions/cache@v4 with: path: ${{ env.TEMPLATES_DIR }} key: godot-templates-${{ env.GODOT_VERSION }} - name: Download the export templates if: steps.templates.outputs.cache-hit != 'true' shell: bash run: ./ci/install-export-templates.sh "$GODOT_VERSION" "$TEMPLATES_DIR" - uses: actions/cache@v4 with: path: .godot/imported key: godot-import-${{ matrix.target }}-${{ env.GODOT_VERSION }}-${{ hashFiles('assets/**', '**/*.import', 'project.godot') }} restore-keys: | godot-import-${{ matrix.target }}-${{ env.GODOT_VERSION }}- - name: Import project assets shell: bash run: godot --headless --import --path . - name: Export ${{ matrix.target }} shell: bash run: | mkdir -p "$(dirname "${{ matrix.output }}")" godot --headless --export-release "${{ matrix.preset }}" "${{ matrix.output }}" - name: Sign the Windows build if: matrix.target == 'windows' shell: pwsh run: ./ci/sign-windows.ps1 -Path "${{ matrix.output }}" - name: Notarize the macOS bundle if: matrix.target == 'macos' run: | xcrun notarytool submit "${{ matrix.output }}" \ --keychain-profile godot-release --wait - uses: actions/upload-artifact@v4 with: name: godot-${{ matrix.target }} path: ${{ matrix.output }} retention-days: 14 compression-level: 0 ``` Four details in that file matter more than the rest. `shell: bash` keeps one script across all three operating systems, because bash is present on the Windows images as well. Without it the Windows leg of the matrix needs a PowerShell copy of every step, and the two copies drift. The import cache key carries `matrix.target`. Texture compression and audio format differ per export target, so a `.godot/imported` tree built for Web is wrong for macOS, and a shared key gives every job a reimport it did not ask for. Per-platform preset options live in `export_presets.cfg`, which belongs in the repository. Windows metadata embedding reads the rcedit path from the editor settings file, so a job that wants icon and version fields either provisions that settings file or drops the metadata step. The macOS preset decides whether Godot invokes `codesign` itself or leaves an unsigned bundle for a later step. On Linux labels, `actions/cache@v4` can be swapped for `WarpBuilds/cache@v1` as a drop-in replacement, which moves the entries onto the WarpBuild cache and off the 10 GB per-repository limit of GitHub's cache service. Details are in the [caching documentation](/docs/ci/features/caching). Windows and macOS jobs stay on `actions/cache`. ## [Sizing](#sizing)Copy Anchor Link The three labels this workflow uses, with per-minute rates from the [pricing page](/pricing): | Runner label | vCPU | Memory | Storage | Per minute | Job it runs | | -------------------------- | ---- | ------ | --------- | ---------- | ------------------------------ | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | Linux and Web exports | | warp-windows-latest-x64-8x | 8 | 32GB | 256GB SSD | $0.032 | Windows export plus signing | | warp-macos-latest-arm64-6x | 6 | 14GB | 120GB SSD | $0.08 | macOS bundle plus notarization | Two of those rows have a same-shape GitHub-hosted equivalent. `warp-windows-latest-x64-8x` costs $0.032 per minute against $0.042 per minute for the 8-core Windows larger runner at the same 8 vCPU and 32 GB shape: ($0.042 - $0.032) / $0.042 = 24 percent lower list price. `warp-macos-latest-arm64-6x` costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner: ($0.102 - $0.08) / $0.102 = 22 percent lower list price, and the WarpBuild shape carries one more vCPU and the same memory. GitHub rates come from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. ### [Worked cost model](#worked-cost-model)Copy Anchor Link Take a studio cutting 40 tagged releases a month, each one running the four-entry matrix above: two Linux-label jobs at 9 minutes each, a Windows job at 11 minutes, and a macOS job at 15 minutes including the notarization wait. | Job | Minutes per month | Rate | Monthly cost | | ----------------------- | ----------------- | ---------- | ------------ | | Linux export (4 vCPU) | 360 | $0.008/min | $2.88 | | Web export (4 vCPU) | 360 | $0.008/min | $2.88 | | Windows export (8 vCPU) | 440 | $0.032/min | $14.08 | | macOS export (6 vCPU) | 600 | $0.08/min | $48.00 | | **Total** | 1,760 | **$67.84** | | On the two rows with a same-shape GitHub-hosted equivalent, the same minutes cost $18.48 for Windows and $61.20 for macOS, a total of $79.68 against $62.08 here, so those two jobs land $17.60 lower a month at equal minutes. macOS dominates the bill at every size, which is why the macOS job is the one worth trimming first. Per-minute billing also settles the matrix shape. Four jobs of 9 to 15 minutes running in parallel cost the same as one 44 minute job that exports every target in sequence, and the release lands three times sooner. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **1\. Cold asset import.** On a runner with no `.godot/imported` entry, the editor recompresses every texture and transcodes every audio file before the export step starts. On an asset-heavy project this phase outweighs the export itself, which makes the import cache key the highest-value tuning decision on this page. Watch the cache hit rate in the job log; a key that turns over on every commit hands you a cold import every run. **2\. Export template downloads.** The template archive covers every platform in one file, and each of the four matrix jobs pulls it. Keyed on the version alone it is fetched once and restored afterward, so the download stops appearing in the job timeline until the next engine upgrade. **3\. Notarization wait.** `xcrun notarytool submit --wait` blocks on Apple's service, and the runner bills for the wait while using almost no CPU. A larger macOS label does nothing here. The levers are submitting one bundle per release instead of one per artifact, and moving notarization into a separate job so the export job releases its runner while the submission is pending. **4\. Artifact upload.** A desktop build plus a Web bundle runs to several gigabytes across the matrix, and the upload sits at the end of the job where the release is waiting on it. Upload only what the release job consumes, and set `compression-level: 0` for payloads that are already compressed so the step stops burning CPU for no size reduction. Telling these apart is a measurement problem. CI observability streams system metrics from the runner agent and correlates them with GitHub Actions job logs, so a job stuck on single-threaded import looks visibly different from one blocked on a network wait. The Action Debugger pauses a workflow and opens a session on the live runner, which is the quickest way to see what actually landed in the export template directory when an export fails on a missing template. ## [Proof](#proof)Copy Anchor Link The migration cost for an existing Godot workflow is the label. Runner images carry the same tooling as GitHub-hosted runners, so the editor install script, the export template layout, `signtool`, `codesign`, and every marketplace action behave the same way after the `runs-on` line changes. Every cost statement above carries its number, its source, and its checked-on date: WarpBuild rates from the [pricing page](/pricing), GitHub rates from the [minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) checked on 2026-08-13, and the arithmetic shown inline rather than rounded into a slogan. To confirm the numbers on your own project, run one tagged release on the labels above and compare the job durations and the billed minutes against the same release on GitHub-hosted runners. ## [FAQ](#faq)Copy Anchor Link **Which runner sizes should a Godot export matrix use?** Run the Linux and Web exports on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, the signed Windows export on `warp-windows-latest-x64-8x` at $0.032 per minute, and the macOS bundle with its notarization step on `warp-macos-latest-arm64-6x` at $0.08 per minute. Sizing up past those points buys little, because Godot's export step spends most of its time on asset import and on packing the PCK rather than on saturating cores. **Should export templates be downloaded on every run or cached?** Cache them, keyed on the Godot version alone. The template archive for a given Godot release never changes, so a version-keyed cache entry stays valid until you upgrade the engine. The directory differs per platform: `~/.local/share/godot/export_templates` on Linux, `~/Library/Application Support/Godot/export_templates` on macOS, and `%APPDATA%\Godot\export_templates` on Windows. **What do the Windows and macOS export jobs cost against GitHub-hosted runners?** `warp-windows-latest-x64-8x` costs $0.032 per minute against $0.042 per minute for the 8-core Windows larger runner at the same 8 vCPU and 32 GB shape, which is 24 percent lower list price. `warp-macos-latest-arm64-6x` costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner, which is 22 percent lower list price (GitHub pricing, checked on 2026-08-13). --- # Gradle Build Caches on GitHub Actions Runners | WarpBuild URL: https://www.warpbuild.com/solutions/gradle-github-actions Description: Gradle build caching on GitHub Actions: configure a remote build cache, restore the Gradle User Home, and size warp- runners so cached tasks stay warm. # Gradle Build Caches on GitHub Actions Runners Gradle build caching on GitHub Actions: configure a remote build cache, restore the Gradle User Home, and size warp- runners so cached tasks stay warm. Last verified: 2026-08-13 The Gradle build cache turns a build into a lookup: Gradle hashes each task's inputs, and when a matching output already exists in the local or the remote cache it copies that output in instead of running the task. On GitHub Actions the local cache dies with the runner, so a working setup pairs a remote build cache with a restored Gradle User Home, and WarpBuild runners give that restore a fast disk, a large memory ceiling, and an optional snapshot that brings back the whole Gradle User Home between runs. This page covers the Gradle side (the `buildCache` block, `org.gradle.caching`, the configuration cache) and the runner side (which `warp-` label to pick, how to size `--parallel` and `--max-workers`, what a cached Gradle job costs per month). ## [Overview](#overview)Copy Anchor Link Gradle keeps five separate pieces of state on a build machine, and teams lose time by treating them as one cache. | Layer | Location on the runner | What it stores | What invalidates it | | --------------------- | ----------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | | Dependency cache | \~/.gradle/caches/modules-2 | Artifacts resolved from Maven Central and other repositories | Version bumps, lockfile changes | | Wrapper distributions | \~/.gradle/wrapper/dists | The Gradle distribution the wrapper pins | gradle-wrapper.properties changes | | Local build cache | \~/.gradle/caches/build-cache-1 | Task outputs keyed by an input hash | Task input changes, removeUnusedEntriesAfterDays | | Remote build cache | An HTTP endpoint you run | The same task outputs, shared across machines | Task input changes, server retention policy | | Configuration cache | PROJECT/.gradle/configuration-cache | The serialized task graph | Build logic edits, system properties and environment variables read at configuration time | The remote build cache is the only layer that crosses machines by itself. Everything else is runner-local, which is why a workflow that turns on `org.gradle.caching=true` and stops there still pays for a full dependency download on every job. The build cache also has a stricter contract than a dependency cache. A task participates only when it declares its inputs and outputs and is marked cacheable, and an entry is reused only when the input hash matches exactly. Absolute paths, timestamps written into jars, and environment variables read inside a task all change that hash, so a build can be fully configured for caching and still miss on every run. WarpBuild fits under this as a runner fleet you select with a label. Gradle work belongs on the Linux runners, where WarpBuild cache is enabled by default and where snapshot runners are supported. Two of those, snapshot runners and CI observability, do real work for a Gradle build. Read the caching behavior in the [WarpBuild caching documentation](/docs/ci/features/caching) before wiring keys by hand, because the `WarpBuilds/*` setup actions already handle most of it. Maven repositories and general JVM runner setup are covered on the [Java builds on GitHub Actions runners](/solutions/java-github-actions) page; everything below stays on the Gradle build cache. ## [Configuration](#configuration)Copy Anchor Link Start in `settings.gradle`, where the `buildCache` block controls both layers. This version keeps the local cache at its default location inside the Gradle User Home so a single restore covers dependencies and task outputs together, and it writes to the remote cache only from the default branch. Copy Text ``` // settings.gradle def isCi = System.getenv("CI") == "true" def onMain = System.getenv("GITHUB_REF") == "refs/heads/main" buildCache { local { enabled = true removeUnusedEntriesAfterDays = 7 } remote(HttpBuildCache) { url = "https://gradle-cache.internal.example.com/cache/" push = isCi && onMain useExpectContinue = true credentials { username = System.getenv("GRADLE_CACHE_USER") password = System.getenv("GRADLE_CACHE_PASSWORD") } } } ``` In the Kotlin DSL the same properties are `isEnabled` and `isPush`, and the remote block is written `remote`. Gating `push` on the default branch keeps task outputs produced by unreviewed pull request code out of the cache every other build reads from. Turn the features on in `gradle.properties` so local runs and GitHub Actions runs behave the same way. Copy Text ``` org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.parallel=true org.gradle.workers.max=6 org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 kotlin.daemon.jvmargs=-Xmx4g ``` The workflow below runs on an 8 vCPU WarpBuild runner and restores `~/.gradle` explicitly. Use this shape when you want to see and control the cache key. Copy Text ``` name: build on: pull_request: push: branches: [main] jobs: gradle: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: "21" - name: Restore Gradle User Home uses: WarpBuilds/cache@v1 with: path: | ~/.gradle/caches ~/.gradle/wrapper key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle/libs.versions.toml', '**/gradle-wrapper.properties') }} restore-keys: | gradle-${{ runner.os }}- - name: Build run: ./gradlew build --build-cache --parallel --max-workers=6 env: GRADLE_CACHE_USER: ${{ secrets.GRADLE_CACHE_USER }} GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} ``` `WarpBuilds/cache@v1` is compatible with `actions/cache@v4` and takes the same `path`, `key`, and `restore-keys` inputs, so an existing Gradle cache step converts by changing one line. The `restore-keys` prefix matters more for Gradle than for most ecosystems: a dependency bump changes the hash, and without a prefix fallback the runner downloads every artifact again rather than the handful that moved. For a build with a large Gradle User Home, hand the whole job to the maintained Gradle action instead. `WarpBuilds/gradle-actions/setup-gradle@v5` caches the Gradle User Home including wrapper distributions, downloaded dependencies, and build caches, and it takes `cache-read-only`, `cache-write-only`, `cache-disabled`, and the `gradle-home-cache-includes` and `gradle-home-cache-excludes` filters. Setting `cache-read-only` on non-default branches keeps one canonical cache entry per branch line instead of one per pull request. Copy Text ``` jobs: gradle: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-16x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-16x;snapshot.key=gradle-main' }} steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: "21" - uses: WarpBuilds/gradle-actions/setup-gradle@v5 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - run: ./gradlew build --build-cache --parallel --max-workers=12 - name: Remove credentials before the snapshot if: github.ref == 'refs/heads/main' run: rm -rf $HOME/.ssh $HOME/.aws $HOME/.gradle/gradle.properties - uses: WarpBuilds/snapshot-save@v1 if: github.ref == 'refs/heads/main' with: alias: gradle-main fail-on-error: true wait-timeout-minutes: 60 ``` The `runs-on` expression is the pattern from the [snapshot runners documentation](/docs/ci/features/snapshot-runners): the default branch boots from the base image with `snapshot.enabled=true` and saves a fresh snapshot, and every other branch boots from the `gradle-main` alias. A restored snapshot arrives with `~/.gradle` already populated, so the first `./gradlew` invocation resolves dependencies from disk and reads local build cache entries produced by the last main build. Two constraints shape how far this goes. Snapshots are supported on WarpBuild cloud Ubuntu runners, and snapshot labels on macOS, Windows, or BYOC runners are ignored without an error. The `/tmp` directory is not preserved, so leave `GRADLE_USER_HOME` at its default under `$HOME` and expect anything written to `java.io.tmpdir` to be gone. Snapshots are deleted after 15 days. The full label syntax and lifecycle sit on the [snapshot runners](/runners/snapshot) page. ## [Sizing](#sizing)Copy Anchor Link Gradle's default `--max-workers` equals the processor count, and `org.gradle.parallel=true` lets independent projects execute at the same time. On a small runner those defaults produce more concurrent JVMs than the memory ceiling supports, and the build slows down or gets killed by the out of memory killer. Budget memory before you raise worker counts. Three consumers share the box: the Gradle daemon heap set by `org.gradle.jvmargs`, the Kotlin or Java compile daemons, and the forked test JVMs governed by `maxParallelForks` on the `Test` task. On `warp-ubuntu-latest-x64-8x` with 32GB, an 8GB daemon heap, a 4GB Kotlin daemon, and 4 test forks at 2GB each reserve 20GB and leave 12GB for the page cache that makes the local build cache reads fast. On `warp-ubuntu-latest-x64-16x` with 64GB, a 12GB daemon heap, a 6GB Kotlin daemon, and 6 test forks at 2GB each reserve 30GB with the same headroom pattern. Sizes, memory, storage, and rates below come from the WarpBuild runner catalog. The worker and heap columns are starting points to tune against your own build scan. | Label | vCPU | Memory | Storage | Suggested \--max-workers | Suggested daemon heap | Per minute | | ---------------------------- | ---- | ------ | --------- | ------------------------ | --------------------- | ---------- | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | 4 | \-Xmx4g | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | 6 | \-Xmx8g | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | 12 | \-Xmx12g | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | 24 | \-Xmx24g | $0.064 | | warp-ubuntu-latest-arm64-8x | 8 | 32GB | 150GB SSD | 6 | \-Xmx8g | $0.012 | | warp-ubuntu-latest-arm64-16x | 16 | 64GB | 150GB SSD | 12 | \-Xmx12g | $0.024 | Leaving two workers unused on the 8 vCPU and 16 vCPU sizes is deliberate. Gradle spawns compile daemons and test forks outside the worker count, and a build configured to use every core spends its last minutes in context switches while the daemon waits on garbage collection. Size against the critical path rather than the total task count. A build whose slowest module chain takes 6 minutes finishes no faster on 32 vCPU than on 16 vCPU, and the larger label doubles the per minute rate. The signal that a bigger runner will pay off is a build scan timeline where every worker stays busy for most of the run. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps. Splitting one long Gradle invocation into several matrix jobs on smaller labels is available as a strategy whenever the module graph allows it. ARM64 is worth testing for pure JVM builds. The Linux ARM64 runners carry the same memory per vCPU at a lower rate, and a Gradle build with no native toolchain dependency usually moves across by changing the label. Confirm any annotation processor, native library, or Docker step in the same job before switching. Android modules change the sizing picture, because an emulator leg needs nested virtualization on the x64 runners and holds memory for the duration of the instrumentation tests. That setup is covered on the [Android builds on GitHub Actions runners](/solutions/android-github-actions) page. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Cold Gradle User Home.** A first-run job downloads the wrapper distribution and every dependency before compilation starts. This is the largest fixed cost in a Gradle job on GitHub Actions and the one the cache restore removes. **Remote build cache latency.** Every cacheable task performs at least one round trip to the remote cache. When the cache server sits far from the runners, a build with thousands of small tasks spends longer on lookups than it saves on hits. Place the cache server close to the runners, and where the server has to live inside your own network, run the runners there: BYOC runs on AWS, GCP, and Azure. **Cache misses that look like hits.** Non-relocatable tasks, absolute paths in compiler arguments, and timestamps embedded in archives change the input hash on every run. Run `./gradlew build --build-cache --scan` twice on the same commit and compare which tasks report `FROM-CACHE` against which report `SUCCESS`. **Configuration cache invalidation.** The configuration cache is stored per project and per set of build parameters. Reading a system property or environment variable during configuration ties the entry to that value, so a workflow that passes a run number or a timestamp into the build invalidates the entry on every run. Pass volatile values to task inputs instead of to configuration logic. **Daemon cold start.** A fresh JVM interprets bytecode before the JIT compiler warms up, which shows up as a slower first Gradle invocation. Snapshot runners cut the disk part of that cost by restoring a populated Gradle User Home. The daemon process starts fresh after the boot, and a snapshot runner boots in 45 to 60 seconds, so treat the snapshot as a disk warmer. **Snapshot hygiene.** A snapshot captures the filesystem, so credentials written during the job are captured too. Remove them before `snapshot-save` runs. A full `git clean -ffdx` also deletes the project's `.gradle/configuration-cache` directory and the `build` outputs you wanted to preserve, so on Gradle repositories prefer targeted removals of credential paths. Snapshots are addressed by alias, and WarpBuild provisions runners at the organization level, so avoid the pattern entirely on public repositories where a pull request workflow could boot from your alias. **Windows Gradle jobs.** WarpBuild caching is not supported on Windows runners, so a Windows Gradle matrix leg has to rely on the remote build cache for task outputs and pay dependency download costs each run. **Cache spend.** Gradle User Home archives are large, and Gradle repositories often store tens of gigabytes across branches. WarpBuild cache entries expire 7 days after last use, which keeps abandoned branch caches from accruing. Key design across branches and jobs is covered in the guide on [persistent caches for GitHub Actions workflows](/guides/persistent-caches-github-actions). Add-on rates come from the pricing evidence. | Add-on | Rate | | ---------------------- | ------------------------ | | Cache storage | $0.20 per GB-month | | Cache write or restore | $0.0001 per operation | | Snapshot restore | $0.04 per job | | Snapshot storage | $0.025 per snapshot-hour | | Networking (Tailscale) | $0 per minute | ## [Proof](#proof)Copy Anchor Link Work the numbers on a concrete build. Take a Gradle monorepo that runs 1,000 jobs per month, each 12 minutes long on 8 vCPU after the cache work above, holding roughly 25GB of Gradle User Home archives and performing 8 cache operations per job. | Line item | Arithmetic | Monthly | | ---------------- | -------------------------- | ------- | | Runner minutes | 12,000 minutes at $0.016 | $192.00 | | Cache storage | 25GB at $0.20 per GB-month | $5.00 | | Cache operations | 8,000 at $0.0001 | $0.80 | | Total | $197.80 | | The same 12,000 minutes on the comparable GitHub-hosted Linux 8-core runner bill at $0.022 per minute, which is $264.00\. GitHub publishes those rates on its [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and on the [GitHub pricing page](https://github.com/pricing), both checked on 2026-08-13. | Size | WarpBuild label | WarpBuild per minute | GitHub-hosted comparison | GitHub per minute | | ------------------ | --------------------------- | -------------------- | ------------------------ | ----------------- | | 8 vCPU Linux x64 | warp-ubuntu-latest-x64-8x | $0.016 | Linux 8-core | $0.022 | | 16 vCPU Linux x64 | warp-ubuntu-latest-x64-16x | $0.032 | Linux 16-core | $0.042 | | 8 vCPU Linux ARM64 | warp-ubuntu-latest-arm64-8x | $0.012 | Linux 8-core arm64 | $0.014 | The same rows stated as list-price arithmetic, with rates for every label on the [pricing page](/pricing). warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. warp-ubuntu-latest-arm64-8x (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB): 14 percent lower list price. GitHub list prices checked on 2026-08-13. Snapshots carry their own arithmetic. One alias held for a 30 day month is 720 snapshot-hours at $0.025, or $18.00, plus 1,000 restores at $0.04, or $40.00\. That $58.00 buys 3,625 minutes at the 8 vCPU rate, so the snapshot pays for itself when it removes more than 3.6 minutes from the average job. A build that spends 5 minutes resolving dependencies clears that bar; a build that already restores a warm Gradle User Home in 40 seconds does not. The model above is the whole bill for these runners. Current rates for every label sit on the [WarpBuild pricing page](/pricing). Verify wall clock time on your own repository: run the same commit on your current label and on a `warp-` label, then compare the build scan timelines and the `FROM-CACHE` task counts. CI observability reports system metrics from the runner agent alongside the job logs, which is where CPU saturation and memory pressure during a Gradle run become visible. The same numbers can be pulled into your own reporting from there. Teams working through a migration can use them directly. ## [FAQ](#faq)Copy Anchor Link **Does a remote Gradle build cache replace caching the Gradle User Home?** No. The remote build cache serves task outputs only. Resolved dependencies, wrapper distributions, and the configuration cache still have to be restored onto the runner, either with a cache action or with a snapshot runner. **Should pull request builds push to the remote build cache?** Usually not. Let the default branch write entries and let pull request builds read them, which keeps unreviewed task outputs out of the shared cache. Set push to true only for the branch you trust. **Which WarpBuild runner size fits a Gradle build?** Start at warp-ubuntu-latest-x64-8x with 8 vCPU and 32GB of memory at $0.016 per minute, then move to warp-ubuntu-latest-x64-16x if the build saturates every worker for most of the run. **Do snapshot runners keep the Gradle daemon warm?** A snapshot preserves the disk, so the Gradle User Home and the local build cache come back populated. The daemon process itself starts fresh after the boot, which takes 45 to 60 seconds on a snapshot runner. --- # Haskell Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/haskell-github-actions Description: Haskell builds on GitHub Actions need a cabal store cache keyed by GHC version, a warp- Linux label sized for parallel GHC, and tests split from the build. # Haskell Builds on GitHub Actions Haskell builds on GitHub Actions need a cabal store cache keyed by GHC version, a warp- Linux label sized for parallel GHC, and tests split from the build. Last verified: 2026-08-13 A Haskell build on GitHub Actions spends most of its wall clock rebuilding dependencies that an earlier run already compiled, because every job gets a fresh VM and the cabal store or stack root arrives empty. Two changes fix that: point `runs-on` at a warp- Linux label with enough memory for parallel GHC processes, and restore the store with a key whose prefix pins the GHC version so a dependency bump still lands on a warm store. Adoption is a label change plus a restore step. The sections below cover the directories worth caching and the key shape that survives a version bump, a workflow that splits the dependency build from the test run, which runner size fits a compile-bound build at which rate, and the bottlenecks that a larger machine cannot solve. ## [Overview](#overview)Copy Anchor Link A cabal or stack run spends GitHub Actions minutes in six places: installing the toolchain, downloading the Hackage index, resolving and building the dependency set into the store, compiling the modules of local packages, linking executables and test binaries, and running the test suites. Two of those parallelize on vCPU count. cabal builds independent packages concurrently with `-j`, and GHC compiles independent modules inside one package concurrently with its own `-j`. Everything else is serial: linking is one process, Template Haskell splices run compiled code during compilation and force their dependencies to finish first, and a long chain of modules that each import the previous one compiles one module at a time no matter how many cores are free. Package graph shape decides how much of the runner a build can use. Memory is the constraint that catches people out. Each parallel GHC process holds its own heap, and a large module compiled with `-O2` can hold several GB on its own. Running `cabal build -j8` with `ghc-options: -j4` starts up to 32 compiler threads, which is how a build that passes locally gets killed with signal 9 on a small runner. Haskell work belongs on the Linux runners, which run Ubuntu images from 2 to 32 vCPUs with 150GB SSDs and carry the same tooling as GitHub-hosted runners, as listed in the [cloud runners documentation](/docs/ci/cloud-runners). Runners are ephemeral VMs, allocated per job and destroyed afterward, which is why the store is empty unless a cache step fills it. Cache is enabled by default on Linux runners. Two tools matter here. Snapshot runners carry the local build tree that a cache archive handles badly, and CI observability shows whether a slow job has all cores busy or one core pinned in a serial module chain. ## [Configuration](#configuration)Copy Anchor Link Start with what to cache. Haskell build tools keep compiled output in two separate places, and only one of them belongs in a cache archive. | Path | Tool | Contents | Cache it | | -------------------------------------- | ----- | -------------------------------------------------------- | --------------------------- | | ${{ steps.setup.outputs.cabal-store }} | cabal | Compiled dependency packages, keyed by ABI | Yes | | \~/.cache/cabal/packages | cabal | Hackage index and downloaded source tarballs | Yes | | dist-newstyle | cabal | Local package objects and interface files | No | | ${{ steps.setup.outputs.stack-root }} | stack | Snapshot package database and compiled snapshot packages | Yes | | .stack-work | stack | Local package objects and interface files | No | | \~/.ghcup | ghcup | GHC, cabal, and stack binaries | Handled by the setup action | Read the store path from the setup action output rather than hardcoding it. cabal moved to XDG directories, so the store is `~/.local/state/cabal/store` on a cabal 3.12 machine with no pre-existing `~/.cabal`, and `~/.cabal/store` everywhere else. `steps.setup.outputs.cabal-store` and `steps.setup.outputs.stack-root` resolve to whichever layout the pinned version uses. Here is a working cabal pipeline on WarpBuild runners. Copy Text ``` name: haskell on: push: branches: [main] pull_request: jobs: build-and-test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - id: setup uses: haskell-actions/setup@v2 with: ghc-version: '9.8.2' cabal-version: '3.12.1.0' cabal-update: true - name: Restore cabal store id: store uses: WarpBuilds/cache/restore@v1 with: path: | ${{ steps.setup.outputs.cabal-store }} ~/.cache/cabal/packages key: >- ${{ runner.os }}-ghc${{ steps.setup.outputs.ghc-version }}-cabal-${{ hashFiles('**/*.cabal', 'cabal.project', 'cabal.project.freeze') }} restore-keys: | ${{ runner.os }}-ghc${{ steps.setup.outputs.ghc-version }}-cabal- - name: Build dependencies run: cabal build all --enable-tests --only-dependencies -j - name: Save cabal store if: steps.store.outputs.cache-hit != 'true' uses: WarpBuilds/cache/save@v1 with: path: | ${{ steps.setup.outputs.cabal-store }} ~/.cache/cabal/packages key: ${{ steps.store.outputs.cache-primary-key }} - name: Build project run: cabal build all --enable-tests -j - name: Test run: cabal test all --test-show-details=direct ``` Four details matter. **The GHC version belongs in the key prefix.** Store entries are keyed by ABI hash, and objects built by another GHC are unusable, so a restore-key that falls back across GHC versions restores gigabytes that the build then ignores. Keeping the version in the prefix and the package set hash in the suffix means a bumped dependency restores the newest store for that same compiler, and only the packages that actually changed rebuild. **Save the store before the tests run.** The split restore and save form writes the archive right after the dependency build, so a failing test suite still leaves a warm store for the next run. The `cache-hit` guard skips the upload when nothing changed, which also keeps the per-operation cache charge off the bill. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` with identical syntax, documented in the [caching documentation](/docs/ci/features/caching). **Build the test suites, then run them.** `cabal build all --enable-tests` compiles test binaries without executing them, so a compile error surfaces in the build step and a rerun of the test step costs no recompilation. Passing `--enable-tests` to the dependency step matters too, since test-only dependencies otherwise land in the store on the second pass. **Pin `index-state` in `cabal.project`.** A pinned index state makes resolution reproducible and keeps the cached Hackage index valid instead of being refreshed on every run. For stack, set `enable-stack: true` and `stack-no-global: true` on the setup action, cache `${{ steps.setup.outputs.stack-root }}` with a key hashing `stack.yaml`, `stack.yaml.lock`, and `package.yaml`, then run `stack build --system-ghc --test --no-run-tests` followed by `stack test --system-ghc`. ## [Sizing](#sizing)Copy Anchor Link The [Linux x64 catalog](/runners/linux-x64), with per-minute rates from the [WarpBuild pricing page](/pricing): | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Pick the size by memory per compiler process and by how wide the package graph is. **8 vCPU, 32 GB, at $0.016 per minute.** This is the default for a build and test job. Give the two parallelism layers one shared budget instead of multiplying them: on GHC 9.8 with cabal 3.12, `cabal build all --semaphore -j8` lets package-level and module-level parallelism draw from a single pool of 8\. On older toolchains, set `-j8` at the cabal level and leave `ghc-options` without a `-j`, which keeps the peak near 8 compiler heaps. **16 vCPU, 64 GB, at $0.032 per minute.** Move up when the observability CPU chart shows 8 vCPUs saturated through the whole build, which in practice means dozens of local packages with independent subtrees. warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price is from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. If the chart instead shows one core busy while the rest idle, the build is walking a serial module chain and the larger machine changes nothing. **4 vCPU, 16 GB, at $0.008 per minute.** hlint, fourmolu, and haddock jobs reuse the same store and rarely hold more than a couple of cores. A GHC version matrix is also better sharded across parallel 4 or 8 vCPU jobs than run sequentially on one large machine, because each version needs its own store anyway. ARM64 is available at $0.024 per minute for the 16 vCPU size and $0.012 for 8 vCPU on the [pricing page](/pricing). ghcup publishes aarch64 Linux bindists, so a native ARM64 job is possible; confirm that the setup action version you pin resolves an aarch64 GHC before switching the label. The same method applied to a cargo build is written out in the [guide to sizing runners for Rust builds](/guides/sizing-runners-for-rust-builds). ### [Worked cost model](#worked-cost-model)Copy Anchor Link Take a Haskell service consuming 12,000 runner-minutes per month on 16 vCPU machines, holding 8 GB of cabal store in cache, and running 3,000 cache operations: | Line item | Rate | Volume | Monthly cost | | ------------------------------------------- | --------------------- | ---------------- | ------------ | | 16-core Linux larger runner (GitHub-hosted) | $0.042 per minute | 12,000 minutes | $504.00 | | warp-ubuntu-latest-x64-16x | $0.032 per minute | 12,000 minutes | $384.00 | | Cache storage | $0.20 per GB-month | 8 GB | $1.60 | | Cache operations | $0.0001 per operation | 3,000 operations | $0.30 | The WarpBuild total is $385.90 against $504.00 for the same minutes on the GitHub-hosted 16-core Linux larger runner, with the cache line items already included. Every rate above is on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Cold store.** A fresh VM has no compiled dependencies, so every transitive package in the plan builds from source before a single local module compiles. On a service with a few hundred packages in its plan this dominates the run. The cabal store plays the role that a [compiler cache](/glossary/compiler-cache) plays in other toolchains, and the configuration above is the fix. The dependency step is deliberately separate so the store is saved even when later steps fail. **Hackage index download.** `cabal update` fetches the package index on a machine that has never seen it. Caching `~/.cache/cabal/packages` alongside the store keeps that off the network, and an `index-state` pin in `cabal.project` keeps the cached copy usable instead of forcing a refresh. **Local build tree lost between runs.** `dist-newstyle` and `.stack-work` hold the interface files and object code that make a rebuild incremental, and an ephemeral runner deletes both. A cache archive is a poor container for them: they change on every commit, so the archive is rewritten each run, and the key can only be built from inputs known before compilation starts. A snapshot runner carries the whole disk instead. The label syntax appends to the runner label: Copy Text ``` jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=haskell-main' }} ``` `snapshot.enabled=true` boots from the base image, which is what a main-branch job wants before saving a clean snapshot with `WarpBuilds/snapshot-save@v1`. `snapshot.key=` boots from the existing snapshot and falls back to the base image when none exists. Three constraints apply: snapshots are supported on WarpBuild Cloud Ubuntu runners only and the labels are ignored on BYOC, Windows, and macOS runners; every snapshot is deleted after 15 days; and `/tmp` is cleaned on boot. Details are in the [snapshot runners documentation](/docs/ci/features/snapshot-runners), and the same technique for other toolchains is covered in the [guide to persistent caches on GitHub Actions](/guides/persistent-caches-github-actions). **Template Haskell and optimization level.** A splice runs compiled code during compilation, so every module it depends on has to be built first, and that serializes a section of the graph. `-O2` compounds it by making each of those modules expensive. Build test jobs at `-O1` and reserve `-O2` for release jobs, and keep splice-heavy code in its own package so the rest of the graph compiles alongside it. **Memory pressure from parallel GHC.** A job killed with signal 9 and no error output is usually the OOM killer, and the cause is the product of the cabal `-j` value and any `-j` in `ghc-options`. Use the shared semaphore, lower the parallelism, or take the next size up with double the RAM. ## [Proof](#proof)Copy Anchor Link No public Haskell repository publishes a workflow file with warp- labels today, so the citable evidence is compile-bound builds in other statically typed languages running on the same Linux images. [near/nearcore](https://github.com/near/nearcore/blob/master/.github/workflows/ci.yml) runs the NEAR protocol node's pipeline on `warp-ubuntu-2404-x64-16x` for its stable, nightly, and large pytest matrix jobs, with smaller jobs on `warp-ubuntu-2404-x64-8x`. [FuelLabs/sway](https://github.com/FuelLabs/sway/blob/master/.github/workflows/ci.yml) builds and tests the Sway compiler across `warp-ubuntu-latest-x64-4x` and `warp-ubuntu-latest-x64-8x` jobs. Both are compiler and toolchain workloads with the same shape as a Haskell build: a large dependency graph compiled into a shared store, parallelism limited by graph width, and memory per compiler process as the real ceiling. The runner labels, cache action, and sizing arithmetic on this page are the same ones those workflows use. ## [FAQ](#faq)Copy Anchor Link **Which WarpBuild runner size should a cabal build start on?** Start the build and test job on `warp-ubuntu-latest-x64-8x` at $0.016 per minute, which gives 8 vCPUs and 32 GB for parallel GHC processes. Move to `warp-ubuntu-latest-x64-16x` at $0.032 per minute when the package graph is wide enough to keep 8 vCPUs busy through the whole build. Keep hlint, fourmolu, and haddock jobs on `warp-ubuntu-latest-x64-4x` at $0.008 per minute. **What cabal cache key survives a dependency bump?** Put the GHC version in the key prefix and the package set hash in the suffix: key on `runner.os` plus the GHC version plus a hash of `**/*.cabal`, `cabal.project`, and `cabal.project.freeze`, with a restore-key that stops after the GHC version. The cabal store is additive and ABI-keyed, so a bumped dependency set restores the newest store built by the same GHC and only the changed packages compile again. **Should I cache dist-newstyle or .stack-work?** No. Both directories churn on every commit, so the archive is rewritten each run, and the key can only be computed from inputs known before compilation starts. Use a snapshot runner instead, which boots the job from a disk image where `dist-newstyle` or `.stack-work` is already populated. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only and every snapshot is deleted after 15 days. --- # Helm Chart Pipelines on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/helm-charts-github-actions Description: A Helm chart pipeline on GitHub Actions lints, renders, and installs the chart into a kind cluster, then packages and pushes it to an OCI registry on a tag. # Helm Chart Pipelines on GitHub Actions A Helm chart pipeline on GitHub Actions lints, renders, and installs the chart into a kind cluster, then packages and pushes it to an OCI registry on a tag. Last verified: 2026-08-13 A Helm chart pipeline on GitHub Actions is two jobs with different triggers: a chart test job on every pull request that lints the chart, renders it, and installs it into a throwaway cluster, and a publish job gated on a tag that packages the chart and pushes it to an OCI registry. The test job needs Docker and a few cores, the publish job needs credentials and almost no compute, and pricing them as one job is the usual reason a chart repository costs more than it should. This page gives the five stages of a chart pipeline and what each one catches, a workflow file with both jobs, the registry publishing path with the credential handling written out, runner sizes and per-minute rates for each job, and the bottlenecks that show up once the pipeline runs on every chart change. ## [Overview](#overview)Copy Anchor Link A chart is a directory of templates plus a `values.yaml`, so most of what can go wrong is invisible until something renders it or applies it. The pipeline exists to move each class of failure earlier than the cluster. | Stage | Command | What it catches | Needs a cluster | | -------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------- | | Lint | helm lint or ct lint | Missing required fields, invalid Chart.yaml, values that fail the JSON schema, a chart version that was not bumped | No | | Template | helm template | Template errors, malformed YAML after substitution, manifests that reference values nobody set | No | | Test | ct install | Manifests the API server rejects, images that fail to pull, workloads that never become ready, failing chart test hooks | Yes | | Package | helm package | A chart directory that cannot be archived under the version in Chart.yaml | No | | Publish | helm push | Credential and permission problems on the registry | No | `helm lint` examines a chart for possible issues and reports them as warnings and errors ([helm lint reference](https://helm.sh/docs/helm/helm%5Flint/)). `helm template` renders the templates locally and prints the manifests without contacting a cluster ([helm template reference](https://helm.sh/docs/helm/helm%5Ftemplate/)). The chart-testing tool wraps both and adds the checks a chart repository needs across changed charts, including the rule that a changed chart carries an incremented version ([helm/chart-testing](https://github.com/helm/chart-testing)). Only the test stage needs a Kubernetes API server, and kind supplies one by running cluster nodes as Docker containers on the job machine ([kind quick start](https://kind.sigs.k8s.io/docs/user/quick-start/)). That single dependency decides the platform for the job. The chart test job belongs on Linux because macOS runners cannot run Docker, which is documented with the rest of the catalog in the [cloud runners documentation](/docs/ci/cloud-runners). Packaging and publishing are separate from all of it. `helm package` produces a versioned `.tgz` archive from the chart directory ([helm package reference](https://helm.sh/docs/helm/helm%5Fpackage/)), and Helm pushes that archive to any OCI compliant registry, which is where charts live alongside the images they deploy ([Helm registries topic](https://helm.sh/docs/topics/registries/)). The [container registry definition](/glossary/container-registry) covers what the registry stores on the other end. ## [Configuration](#configuration)Copy Anchor Link The workflow below runs the chart test job on pull requests that touch `charts/`, across two Kubernetes versions, and runs the publish job only on a tag push. Copy Text ``` name: charts on: pull_request: paths: ["charts/**"] push: tags: ["chart-v*"] permissions: contents: read jobs: chart-test: if: github.event_name == 'pull_request' runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: k8s: ["v1.31.4", "v1.32.2"] steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: azure/setup-helm@v4 with: version: v3.16.2 - uses: helm/chart-testing-action@v2 - name: Lint changed charts run: ct lint --target-branch ${{ github.event.repository.default_branch }} - name: Render with the CI values run: helm template api charts/api --values charts/api/ci/test-values.yaml - uses: helm/kind-action@v1 with: node_image: kindest/node:${{ matrix.k8s }} - name: Install and run chart tests run: ct install --target-branch ${{ github.event.repository.default_branch }} publish: if: startsWith(github.ref, 'refs/tags/chart-v') runs-on: warp-ubuntu-latest-x64-2x permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - uses: azure/setup-helm@v4 with: version: v3.16.2 - name: Package the chart run: helm package charts/api --destination dist - name: Log in to the registry run: | echo "${{ secrets.GITHUB_TOKEN }}" \ | helm registry login ghcr.io \ --username ${{ github.actor }} \ --password-stdin - name: Push the chart run: helm push dist/*.tgz oci://ghcr.io/${{ github.repository_owner }}/charts ``` Four details in that file carry the design. The trigger split is the security boundary. `on.push.tags` starts the publish job only for a matching tag ref ([events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows)), so a pull request from a fork runs the test job and can never reach the publish job. The `if` guards make the intent readable in the run list when both triggers live in one file. Permissions are scoped per job. The workflow-level `permissions` block is `contents: read`, and `packages: write` is granted on the publish job alone, which is the narrowest grant that lets `helm push` write to GitHub Packages ([automatic token authentication](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication)). Charts and container images share one registry path under the same owner ([working with the GitHub container registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)). The credential never appears as a process argument. `helm registry login --password-stdin` reads the token from standard input, which keeps it out of the process table and out of any shell trace the job prints. The chart version is the release. `ct lint` fails when a changed chart keeps its old version, so a tag that publishes an unchanged version is caught on the pull request rather than at `helm push`. ### [Publishing to a cloud registry instead](#publishing-to-a-cloud-registry-instead)Copy Anchor Link For ECR or another cloud registry, replace the login step with a short lived credential exchanged through OpenID Connect ([security hardening with OpenID Connect](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)). The job adds `id-token: write` to its permissions and stores no registry password: Copy Text ``` - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-charts aws-region: us-east-1 - name: Log in to ECR run: | aws ecr get-login-password --region us-east-1 \ | helm registry login 111122223333.dkr.ecr.us-east-1.amazonaws.com \ --username AWS --password-stdin ``` Each WarpBuild runner is a virtual machine created for one job and destroyed when the job ends, with its own encrypted storage volume, and WarpBuild does not access or store build secrets ([security documentation](/docs/ci/security)). The credential in a publish job therefore lives as long as the job does. ## [Sizing](#sizing)Copy Anchor Link Size the two jobs separately, because only one of them runs a cluster. Rates are from the [pricing page](/pricing), billed per minute, checked on 2026-08-13. | Label | vCPU | RAM | Storage | USD per minute | Job it fits | | --------------------------- | ---- | ----- | --------- | -------------- | ------------------------------------------------------------------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | Lint, template, package, publish | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | ct install against one kind cluster | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | Several charts per job or a multi node kind cluster | | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | 150GB SSD | $0.006 | The same test job when every image under test publishes an arm64 manifest | The 4 vCPU row is the practical floor for the test job. A kind cluster runs an API server, etcd, a scheduler, and a controller manager as containers before your chart installs anything, and the workloads the chart creates then share the same machine. The lint, template, package, and publish steps are close to idle by comparison and belong on the 2 vCPU row. ### [Worked model for a chart repository](#worked-model-for-a-chart-repository)Copy Anchor Link Assumptions: 12 charts in one repository, 110 pull request runs a month, each fanning out to the two Kubernetes versions in the matrix, so 220 chart test jobs at 7 minutes each. Releases are tagged 24 times a month and the publish job takes 3 minutes. Substitute your own run counts from the workflow run history. | Line | Arithmetic | Monthly | | --------------- | -------------------- | ------- | | Chart test jobs | 220 x 7 min x $0.008 | $12.32 | | Publish jobs | 24 x 3 min x $0.004 | $0.29 | | Total | $12.61 | | Those same 1,540 chart test minutes list at $0.012 per minute on the 4-core Linux larger runner, which is $18.48 ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). Stated as list price arithmetic: `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13. ## [Bottlenecks](#bottlenecks)Copy Anchor Link ### [Cluster creation repeats per matrix entry](#cluster-creation-repeats-per-matrix-entry)Copy Anchor Link Every matrix job builds its own kind cluster from scratch and pulls the `kindest/node` image for its Kubernetes version. Runner storage is ephemeral and is deleted when the runner terminates, so no local image layer survives to the next run. Two Kubernetes versions means paying that startup twice per pull request. Pin the node image tags so the pull is a cache hit at the registry rather than a resolution of a floating tag, and keep the matrix to the versions you actually support. ### [Readiness waiting is billed runner time](#readiness-waiting-is-billed-runner-time)Copy Anchor Link `ct install` waits for the release to become ready, and a chart with a slow init container or a long `--timeout` holds the runner while nothing computes. Set the timeout to the value your chart needs rather than a round number, and keep the waiting job on the smallest size that runs the cluster, since a 4 vCPU runner waits at $0.008 per minute while an 8 vCPU runner waits at $0.016 for the same result. ### [Images pulled into the cluster](#images-pulled-into-the-cluster)Copy Anchor Link A chart under test pulls every image it references into the kind cluster. Charts that deploy a database, a cache, and an application pull three images per matrix entry, and those bytes are the largest part of many chart test jobs. Point the CI values file at the smallest images that still exercise the templates, and set `imagePullPolicy: IfNotPresent` for anything you preload with `kind load`. ### [Chart version churn on a shared repository](#chart-version-churn-on-a-shared-repository)Copy Anchor Link `ct lint` requires an incremented version for each changed chart, so two pull requests touching the same chart collide on the version bump and the second one fails after merge conflicts are resolved. Keeping one chart per pull request, or bumping the version as the first commit of a change, removes most of that friction. ### [Fork pull requests carry no secrets](#fork-pull-requests-carry-no-secrets)Copy Anchor Link The token available to a workflow run from a fork is read-only, which is what makes the tag gate above the right shape rather than an inconvenience. A chart test job that quietly depends on a registry secret passes on branches and fails on every external contribution. Keep the test path free of credentials, and let the [deployment job guide](/guides/deployment-jobs-github-actions) cover the pipeline stage where credentials are unavoidable. ### [What a bigger runner does not fix](#what-a-bigger-runner-does-not-fix)Copy Anchor Link A template that renders an invalid manifest, a values schema that contradicts the chart, or a readiness probe pointed at the wrong port fails the same way on every runner size. Faster machines shorten the loop on those failures without preventing them. ## [Proof](#proof)Copy Anchor Link Public OSS repositories running `warp-` labels are checkable evidence, and the workflow file is one click away. [kintsugi-tax/killbill-kintsugi-plugin](https://github.com/kintsugi-tax/killbill-kintsugi-plugin/blob/main/.github/workflows/release.yml) runs its release workflow on `warp-ubuntu-latest-x64-2x`, building the plugin with `mvn -B clean verify` before it publishes, which is the same small-runner shape as the publish job on this page (checked on 2026-08-13). [triggerdotdev/trigger.dev](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml) runs its end-to-end matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x`, which is the matrix fan-out shape the chart test job uses (checked on 2026-08-13). Both labels come from the catalog on the [cloud runners page](/docs/ci/cloud-runners). Every cost figure above carries its arithmetic, a source link, and a checked-on date, and the same per-minute rates appear on the [pricing page](/pricing). For the job that consumes the published chart, see [Kubernetes deploy jobs on GitHub Actions](/solutions/kubernetes-deployments-github-actions). ## [FAQ](#faq)Copy Anchor Link **How do I test a Helm chart in GitHub Actions?** Run `ct lint` to catch schema and chart version problems, render the chart with `helm template`, then create a throwaway Kubernetes cluster with `helm/kind-action` and run `ct install` against it. kind runs its nodes as Docker containers, so the job belongs on a Linux runner such as `warp-ubuntu-latest-x64-4x` at $0.008 per minute. macOS runners cannot run Docker and cannot host the cluster. **How do I publish a Helm chart to an OCI registry from GitHub Actions?** Run `helm package` to produce the versioned `.tgz`, authenticate with `helm registry login` using `--password-stdin`, and push with `helm push dist/chart.tgz oci:////charts`. Gate the job on a tag push so no pull request can publish, and grant `packages: write` on that job alone when the target is GitHub Packages. **What does a Helm chart pipeline cost on WarpBuild runners?** --- # iOS Builds on GitHub Actions with macOS Runners | WarpBuild URL: https://www.warpbuild.com/solutions/ios-github-actions Description: Run iOS builds on GitHub Actions macOS runners with xcodebuild, DerivedData and SPM caching, and code signing, sized across WarpBuild 6 and 12 vCPU labels. # iOS Builds on GitHub Actions with macOS Runners Run iOS builds on GitHub Actions macOS runners with xcodebuild, DerivedData and SPM caching, and code signing, sized across WarpBuild 6 and 12 vCPU labels. Last verified: 2026-08-13 An iOS build on GitHub Actions has to run on a macOS runner, because `xcodebuild`, the iOS simulator runtimes, and the code signing toolchain only exist on macOS. WarpBuild provides macOS runners on Apple Silicon in two configurations, 6 vCPU with 14GB of memory at $0.08 per minute and 12 vCPU with 28GB at $0.16 per minute, selected by putting a `warp-` label in `runs-on`. This page covers the workflow YAML for a test job and a signed archive job, how to choose between the two macOS configurations, the four bottlenecks that dominate iOS wall clock time on GitHub Actions, and the arithmetic against GitHub-hosted macOS list prices. ## [Overview](#overview)Copy Anchor Link A typical iOS repository ends up with three job shapes on GitHub Actions. The first is a fast feedback job on every pull request push: SwiftLint, unit tests, and a small set of UI tests against one simulator destination. The second is a wider matrix job on merge to the main branch: more simulator destinations, more test plans, sometimes a build for a physical device. The third is the release job: a signed `xcodebuild archive`, an `-exportArchive` step that produces an IPA, and an upload to App Store Connect or TestFlight. All three need macOS. Only the first two can skip code signing, which is why they are usually split from the release job. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The macOS fleet runs on Apple Silicon with ARM64 architecture, and WarpBuild offers multiple sizes and configurations per chip, so a test job and an archive job can sit on different labels inside the same workflow file. ### [macOS runner catalog](#macos-runner-catalog)Copy Anchor Link | Runner label | macOS | vCPU | Memory | Storage | Price | Alias | | ----------------------- | -------- | ---- | ------ | --------- | ------------ | --------------------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08/minute | | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16/minute | | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08/minute | warp-macos-latest-arm64-6x | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16/minute | warp-macos-latest-arm64-12x | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08/minute | | Source: [WarpBuild cloud runners documentation](/docs/ci/cloud-runners), verified 2026-08-13\. The `latest` aliases track macOS 15, in sync with GitHub's `macos-latest` tag. macOS 13 runners were removed on June 8, 2026, so a workflow still pinned to a macOS 13 label needs to move to macOS 14, macOS 15, or macOS 26. The `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` images ship Xcode 27.0 (build 27A266a) on top of the Xcode versions in the upstream GitHub macOS 26 image, together with iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes. The Xcode 27.0 addition is maintained until the official macOS 27 images land. Everything else on the image matches the GitHub-hosted equivalent, which is documented in the [preinstalled software reference](/docs/ci/preinstalled-software) and in the upstream `actions/runner-images` readme for each macOS version. Two limits shape how an iOS pipeline is laid out. macOS runners do not support nested virtualization and cannot run Docker, so any containerized step belongs on a Linux label in a separate job. And the WarpBuild cache is enabled by default on Linux runners, so macOS jobs use `actions/cache` against the GitHub Actions cache backend. ## [Configuration](#configuration)Copy Anchor Link Point `runs-on` at a `warp-` macOS label. The rest of the workflow is standard GitHub Actions, because the images carry the same tooling as GitHub-hosted macOS runners. The workflow below splits the two jobs that matter. The test job runs on every push against 6 vCPU. The archive job runs only on the main branch, on 12 vCPU, and does the code signing work in a throwaway keychain. Copy Text ``` name: ios on: pull_request: push: branches: [main] concurrency: group: ios-${{ github.ref }} cancel-in-progress: true env: DEVELOPER_DIR: /Applications/Xcode_${{ vars.XCODE_VERSION }}.app/Contents/Developer SCHEME: Kitchen SIMULATOR: iPhone 17 jobs: test: runs-on: warp-macos-15-arm64-6x timeout-minutes: 30 steps: - uses: actions/checkout@v4 - name: Report the selected toolchain run: | xcodebuild -version xcrun simctl list runtimes - name: Cache resolved Swift packages uses: actions/cache@v4 with: path: SourcePackages key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }} restore-keys: spm-${{ runner.os }}- - name: Cache DerivedData build products uses: actions/cache@v4 with: path: DerivedData/Build key: dd-test-${{ runner.os }}-${{ hashFiles('**/Package.resolved', '**/*.xcodeproj/project.pbxproj') }} restore-keys: dd-test-${{ runner.os }}- - name: Boot the simulator run: | xcrun simctl boot "$SIMULATOR" || true xcrun simctl bootstatus "$SIMULATOR" -b - name: Unit and UI tests run: | xcodebuild test \ -scheme "$SCHEME" \ -destination "platform=iOS Simulator,name=$SIMULATOR" \ -derivedDataPath DerivedData \ -clonedSourcePackagesDirPath SourcePackages \ -resultBundlePath TestResults.xcresult \ -skipPackagePluginValidation \ CODE_SIGNING_ALLOWED=NO - uses: actions/upload-artifact@v4 if: always() with: name: xcresult path: TestResults.xcresult archive: needs: test if: github.ref == 'refs/heads/main' runs-on: warp-macos-26-arm64-12x timeout-minutes: 60 steps: - uses: actions/checkout@v4 - name: Import the signing certificate and profile env: CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12 }} CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }} PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE }} KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }} run: | KEYCHAIN="$RUNNER_TEMP/signing.keychain-db" echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/dist.p12" echo "$PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/dist.mobileprovision" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security set-keychain-settings -lut 3600 "$KEYCHAIN" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security import "$RUNNER_TEMP/dist.p12" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \ -T /usr/bin/codesign -T /usr/bin/security security set-key-partition-list -S apple-tool:,apple: \ -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" security list-keychains -d user -s "$KEYCHAIN" login.keychain-db mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" cp "$RUNNER_TEMP/dist.mobileprovision" \ "$HOME/Library/MobileDevice/Provisioning Profiles/" - name: Resolve Swift packages run: | xcodebuild -resolvePackageDependencies \ -scheme "$SCHEME" \ -clonedSourcePackagesDirPath SourcePackages - name: Archive run: | xcodebuild archive \ -scheme "$SCHEME" \ -configuration Release \ -destination 'generic/platform=iOS' \ -archivePath build/Kitchen.xcarchive \ -clonedSourcePackagesDirPath SourcePackages \ -derivedDataPath DerivedData - name: Export the IPA run: | xcodebuild -exportArchive \ -archivePath build/Kitchen.xcarchive \ -exportPath build/export \ -exportOptionsPlist ci/ExportOptions.plist - name: Upload to TestFlight env: API_KEY: ${{ secrets.APP_STORE_CONNECT_KEY }} API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} API_ISSUER: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} run: | mkdir -p "$HOME/.appstoreconnect/private_keys" echo "$API_KEY" | base64 --decode \ > "$HOME/.appstoreconnect/private_keys/AuthKey_$API_KEY_ID.p8" xcrun altool --upload-app -f build/export/Kitchen.ipa -t ios \ --apiKey "$API_KEY_ID" --apiIssuer "$API_ISSUER" - name: Remove the signing keychain if: always() run: security delete-keychain "$RUNNER_TEMP/signing.keychain-db" ``` Four details in that file are worth calling out. `DEVELOPER_DIR` pins the toolchain for every step in the job without a separate `xcode-select` call. Set the repository variable `XCODE_VERSION` to a version the image actually ships; the per-image list lives on the [Xcode images page](/runners/macos/xcode-images). `-clonedSourcePackagesDirPath SourcePackages` moves the resolved Swift Package Manager checkouts to a fixed path in the workspace, which is what makes them cacheable. Without it the checkouts land inside DerivedData under a hashed directory name and the cache key stops being stable. The DerivedData cache stores `DerivedData/Build` rather than the whole DerivedData tree. The index store and the module cache are large, rebuild quickly, and inflate both the upload and the restore. The keychain is created in `$RUNNER_TEMP`, unlocked, given a partition list, and deleted in an `always()` step. The `security set-key-partition-list` call is the one people skip, and skipping it makes `codesign` block on a UI prompt that never appears on a headless runner, so the job hangs until the timeout fires. Nothing else changes when moving an existing workflow across. Swapping `macos-latest` for `warp-macos-15-arm64-6x` is the whole migration for most repositories. ## [Sizing](#sizing)Copy Anchor Link The two macOS configurations differ in three dimensions that matter to Xcode. | Configuration | vCPU | Memory | Storage | Price | Fits | | --------------------------------------------------------------------------- | ---- | ------ | --------- | ------------ | --------------------------------------------------------------------------------------- | | 6x (warp-macos-15-arm64-6x, warp-macos-26-arm64-6x, warp-macos-14-arm64-6x) | 6 | 14GB | 120GB SSD | $0.08/minute | Unit tests, one or two simulator destinations, incremental debug builds, lint | | 12x (warp-macos-15-arm64-12x, warp-macos-26-arm64-12x) | 12 | 28GB | 270GB SSD | $0.16/minute | Release archives, whole-module optimization, wide simulator matrices, large DerivedData | Start every job on 6x. The Swift compiler parallelizes across files within a module, so a test job on a medium app rarely saturates 12 vCPU, and a debug build with an incremental DerivedData cache spends much of its time on linking and simulator work that extra cores do not shorten. Move the archive job to 12x first. Release configuration turns on whole-module optimization, which raises both peak memory and the amount of work available to parallelize, and the archive step also produces dSYMs and runs `-exportArchive` on top of the compile. Storage decides more of this than people expect. A 120GB SSD holds Xcode, the bundled simulator runtimes, the repository, DerivedData, and the resolved packages. A large app with several simulator runtimes downloaded at job time, a fat DerivedData tree, and an `.xcarchive` plus IPA in the same job can run that disk close to full. The 12x configuration carries 270GB, which removes the cleanup steps that a tight disk forces into the workflow. The pricing arithmetic is simple because both rates are flat. At $0.16 per minute against $0.08 per minute, the 12 vCPU configuration lowers the cost of a job only when it cuts that job's wall clock time by more than half. For a release archive that runs once per merge, wall clock usually matters more than the per-job cost, so the larger label is the right default there even when it does not pay for itself in minutes. For a test job that runs on every push, the per-job cost dominates and 6x is usually the better trade. The [CI observability](/docs/ci/features/observability) view in the WarpBuild dashboard reports runner right-sizing recommendations from measured CPU and memory utilization per repository, workflow, job, and instance type, which is a faster way to settle the question than guessing from a single run. ### [Worked cost model](#worked-cost-model)Copy Anchor Link Take an eight-engineer iOS team on a 22 weekday month. Test job: 45 pull request pushes per weekday, 11 minutes each, on `warp-macos-15-arm64-6x`. * 45 pushes x 22 days = 990 test jobs * 990 jobs x 11 minutes = 10,890 minutes * 10,890 minutes x $0.08 = **$871.20** Archive job: 6 merges to the main branch per weekday, 16 minutes each, on `warp-macos-26-arm64-12x`. * 6 merges x 22 days = 132 archive jobs * 132 jobs x 16 minutes = 2,112 minutes * 2,112 minutes x $0.16 = **$337.92** Monthly total: $871.20 + $337.92 = **$1,209.12**. That total is the whole bill for runner time. ### [Against GitHub-hosted macOS list prices](#against-github-hosted-macos-list-prices)Copy Anchor Link GitHub publishes its per-minute rates in the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and its plan pricing at [github.com/pricing](https://github.com/pricing). The macOS rows below were checked on 2026-08-13. | Runner | Architecture | Cores | Per-minute rate | | --------------------------------------------- | ------------ | ------------- | --------------- | | GitHub-hosted macOS standard (actions\_macos) | arm64 or x64 | 3 or 4 | $0.062 | | GitHub-hosted macOS larger, arm64 (macos\_xl) | arm64 | 5 | $0.102 | | GitHub-hosted macOS larger, x64 (macos\_l) | x64 | 12 | $0.077 | | warp-macos-15-arm64-6x | arm64 | 6 vCPU, 14GB | $0.080 | | warp-macos-15-arm64-12x | arm64 | 12 vCPU, 28GB | $0.160 | The like-for-like row is the arm64 larger runner: warp-macos-latest-arm64-6x (6 vCPU, 14 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price. GitHub list price checked on 2026-08-13\. The difference is $0.102 - $0.080 = **$0.022 per minute** in WarpBuild's favor, on one more core and the same memory. Applied to the test job in the model above: 10,890 minutes x $0.102 = $1,110.78 on the GitHub-hosted arm64 larger runner, against $871.20 on `warp-macos-15-arm64-6x`. The gap is $239.58 per month on that job alone. Two honest caveats on the other rows. GitHub's standard macOS runner is cheaper per minute at $0.062, and it is a 3-core or 4-core machine, so the comparison is between different amounts of hardware rather than between the same machine at two prices. GitHub's 12-core macOS larger runner at $0.077 is x64, so it is not an Apple Silicon comparison at all. Also note that GitHub rounds the minutes and partial minutes each job uses up to the nearest whole minute, which matters when a repository runs many short macOS jobs. ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four things dominate iOS wall clock time on GitHub Actions, and none of them are the Swift compiler alone. ### [Simulator boot](#simulator-boot)Copy Anchor Link `xcodebuild test` boots the simulator implicitly on first use, and the boot includes launching SpringBoard and the runtime services before the test host installs. Booting explicitly with `xcrun simctl boot` followed by `xcrun simctl bootstatus -b` moves that cost into a named step, which makes it visible in the job timeline instead of hiding inside the test step. Reuse one destination across test plans in the same job. Each additional simulator name in `-destination` boots another device. A matrix over four device names inside a single job pays four boots serially; a matrix over four jobs pays them in parallel, at the cost of four cold runners. If a project targets a simulator runtime that the image does not bundle, Xcode downloads it at job time. That download is measured in gigabytes and runs on every job, because the runner is ephemeral. Check the runtimes shipped on each image on the [Xcode images page](/runners/macos/xcode-images) and target one of those where the test matrix allows it. ### [DerivedData cold start](#deriveddata-cold-start)Copy Anchor Link Every job starts on a fresh ephemeral VM with an empty DerivedData directory, so the first build compiles every module from scratch, including all Swift package dependencies. This is the single largest difference between a laptop build and a GitHub Actions build. Cache `DerivedData/Build` and the cloned package checkouts separately. The package checkouts key cleanly on `Package.resolved` and change rarely. The build products key on `Package.resolved` plus `project.pbxproj` and change often, so give that entry a `restore-keys` prefix and accept a partial hit. Watch the restore time. A DerivedData archive for a large app can reach several gigabytes, and past a certain size the download and decompress cost more than recompiling the modules it saves. Measure both before keeping the cache. ### [Code signing and provisioning](#code-signing-and-provisioning)Copy Anchor Link Code signing fails in ways that look like build failures. The common causes, in the order they show up: 1. The keychain is created in one step and used in another without `security unlock-keychain`, so `codesign` cannot read the private key. 2. `security set-key-partition-list` is skipped, and `codesign` waits on an interactive authorization prompt on a headless machine until the job times out. 3. The provisioning profile is decoded but never copied into `~/Library/MobileDevice/Provisioning Profiles`, so `-exportArchive` reports that no profile matches the bundle identifier. 4. The profile or the distribution certificate expired, which surfaces late, during export, after the full archive has already been compiled. 5. The keychain outlives the job on a persistent machine. On ephemeral runners the VM is destroyed anyway, and the explicit `security delete-keychain` step keeps the workflow correct if it is ever run elsewhere. Put the whole signing sequence in one step, as in the workflow above, so the keychain state cannot drift between steps. ### [Xcode version drift](#xcode-version-drift)Copy Anchor Link The runner image ships several Xcode versions with one of them selected as default, and the default moves when the image is updated. A project that requires a specific Swift version or a specific SDK will fail on a Monday for no visible reason if the workflow relies on the image default. Pin `DEVELOPER_DIR` in the workflow `env` block and print `xcodebuild -version` in the first step so the log records what actually ran. When the pinned version is retired from the image, the failure is loud and immediate at the first step rather than subtle and late. macOS 26 images ship Xcode 27.0 (build 27A266a) alongside the upstream versions, so a project moving to Xcode 27 can pin it on `warp-macos-26-arm64-6x` or `warp-macos-26-arm64-12x` while the rest of the fleet stays on macOS 15. When a job fails only on the runner and not on a laptop, the [Action Debugger](/docs/ci/tools/action-debugger) pauses the workflow and opens an SSH session on the runner machine, which is the shortest path to inspecting a keychain, a provisioning profile, or a simulator log in place. ## [Proof](#proof)Copy Anchor Link The migration cost is a label. The macOS images carry the same tooling as GitHub-hosted macOS runners, so `xcodebuild`, `xcrun`, `simctl`, `security`, Fastlane, and CocoaPods behave the same way, and existing actions from the marketplace keep working. The catalog and the tooling references are in the [cloud runners documentation](/docs/ci/cloud-runners) and the [preinstalled software reference](/docs/ci/preinstalled-software). Beyond the runners themselves, CI observability and the Action Debugger are the two parts of the WarpBuild product surface that iOS teams use most: the first to right-size the macOS labels from measured utilization, the second to open an SSH session on a runner when a signing or simulator failure will not reproduce locally. Rates, sizes, and labels for every platform are on the [WarpBuild pricing page](/pricing), and the full macOS lineup with its Xcode coverage is on the [macOS runners hub](/runners/macos). To model a specific repository's bill before switching, work through [the macOS runner cost guide](/guides/github-actions-macos-runner-cost). For package-level Swift work without an Xcode project, see [Swift builds on GitHub Actions](/solutions/swift-github-actions). ## [FAQ](#faq)Copy Anchor Link **Which macOS runner label should an iOS test job use?** Start on `warp-macos-15-arm64-6x` (6 vCPU, 14GB, 120GB SSD, $0.08 per minute) for `xcodebuild test` against one or two simulator destinations. Move to `warp-macos-26-arm64-12x` (12 vCPU, 28GB, 270GB SSD, $0.16 per minute) when a release archive or a wide simulator matrix runs long or fills the disk. **Can a macOS runner run Docker as part of an iOS build?** No. WarpBuild macOS runners do not support nested virtualization and cannot run Docker. Put containerized steps such as backend integration fixtures on a Linux runner label in a separate job and pass artifacts between the jobs. **How do I cache DerivedData and Swift Package Manager dependencies?** Use `actions/cache@v4` with two entries, one for the resolved package checkout directory keyed on `Package.resolved` and one for the DerivedData build products keyed on `Package.resolved` plus `project.pbxproj`. The WarpBuild cache is enabled on Linux runners, so macOS jobs use the GitHub Actions cache backend. **What does a macOS minute cost compared with GitHub-hosted macOS runners?** The WarpBuild 6 vCPU macOS runner with 14GB of memory is $0.08 per minute. GitHub lists its arm64 macOS larger runner at $0.102 per minute and its standard 3-core or 4-core macOS runner at $0.062 per minute (checked on 2026-08-13). On the like-for-like Apple Silicon larger runner the difference is $0.022 per minute. --- # Faster Java Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/java-github-actions Description: Faster Java builds on GitHub Actions come from bigger warp- runners, a warm Maven repository cache, and parallel Surefire forks. Sizing and cost math inside. # Faster Java Builds on GitHub Actions Faster Java builds on GitHub Actions come from bigger warp- runners, a warm Maven repository cache, and parallel Surefire forks. Sizing and cost math inside. Last verified: 2026-08-13 Faster Java builds on GitHub Actions come from three levers: enough vCPUs to run Maven modules and test forks in parallel, a local repository that stays warm between jobs, and JVM heap settings sized to the machine instead of left to defaults. WarpBuild covers the first two directly, with warp- labeled runners from 2 to 32 vCPUs billed per minute and a cache that keeps `~/.m2/repository` populated across runs. Switching is a one line change. Point `runs-on` at a warp- label, add a cache step for the Maven local repository, and the same workflow runs on a machine with the cores and memory a multi-module reactor build can use. The sections below cover the exact configuration, which runner size fits which build phase, and the arithmetic behind the bill. ## [Overview](#overview)Copy Anchor Link Java spends GitHub Actions minutes in five places: resolving and downloading dependencies, compiling modules with javac, running unit tests under the Maven Surefire Plugin, running integration tests under Failsafe, and packaging jars. Two of those phases parallelize well. Maven builds independent modules concurrently with `-T 1C` (one build thread per core), and Surefire forks multiple test JVMs with `forkCount`. Both scale with vCPU count, which is why the runner size shows up directly in wall-clock time for multi-module projects. Memory matters as much as cores here, because every forked JVM owns a heap. A runner that forks eight test JVMs needs room for eight heaps plus the Maven reactor JVM plus the operating system's file cache, which javac and jar packaging use heavily. The sizing section below has the arithmetic. For Java, the Linux runners do the work: [the runner catalog](/docs/ci/cloud-runners) lists Ubuntu 22.04, 24.04, and 26.04 images on x64 and Ubuntu 24.04 and 26.04 on ARM64, each in sizes from 2 to 32 vCPUs with 150GB SSDs. The images carry the same tooling as GitHub-hosted runners, so JDK distributions and Maven are already installed. Runners are ephemeral VMs, freshly allocated per job and destroyed afterward, which is exactly why the cache step matters: without it, every job starts with an empty `~/.m2`. Cache is enabled by default on all Linux runners. The [WarpBuild cache documentation](/docs/ci/features/caching) covers the general mechanics; the next section applies them to Maven. One scope note. This page covers Maven and general JVM tuning, which also applies to sbt and plain javac builds. Gradle projects share the JVM behavior described here, and Gradle's own layers, the build cache and the configuration cache, are covered on the [Gradle solutions page](/solutions/gradle-github-actions). Android builds add the SDK and emulator layers on top, covered on the [Android solutions page](/solutions/android-github-actions). ## [Configuration](#configuration)Copy Anchor Link Here is a working Java pipeline on WarpBuild runners. The unit test job runs on 8 vCPUs and stops at the `test` phase, so Failsafe never runs there. The integration job runs on 16 vCPUs with a Postgres service container and activates a profile that binds Failsafe to the `integration-test` and `verify` phases. Both jobs restore the Maven local repository through `WarpBuilds/cache`. Copy Text ``` name: java on: push: branches: [main] pull_request: env: MAVEN_OPTS: -Xmx3g jobs: build-and-test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' - uses: WarpBuilds/cache@v1 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: | ${{ runner.os }}-maven- - run: mvn -B -T 1C test integration-tests: runs-on: warp-ubuntu-latest-x64-16x services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: ci ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' - uses: WarpBuilds/cache@v1 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} restore-keys: | ${{ runner.os }}-maven- - run: mvn -B -T 1C verify -Pintegration-tests ``` Four details matter here. **The cache key hashes every pom.xml.** The key changes exactly when the dependency set can change, and `restore-keys` falls back to the newest previous cache, so a version bump starts from a mostly warm repository instead of an empty one. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4`; the syntax is identical. Cache entries expire after 7 days without use. **Heap sizes are explicit.** The JVM's default ergonomics cap the maximum heap at a quarter of physical memory. On a 32 GB runner that hands a single JVM up to 8 GB, which looks safe until Surefire forks eight JVMs and each fork applies the same default. `MAVEN_OPTS: -Xmx3g` bounds the reactor JVM, and the Surefire `argLine` below bounds each fork at 2 GB. On `warp-ubuntu-latest-x64-8x` that budgets 3 GB for the reactor plus eight forks at 2 GB each, 19 GB total against 32 GB of RAM, leaving headroom for the OS file cache. On `warp-ubuntu-latest-x64-16x`, sixteen forks plus the reactor stay under 36 GB of the 64 GB available. **Surefire forks per core.** Set `forkCount` to `1C` in the plugin configuration so the fork count follows the runner size without workflow edits: Copy Text ``` org.apache.maven.plugins maven-surefire-plugin 1C true -Xmx2g ``` **The integration profile keeps Failsafe out of PR unit runs.** `mvn test` stops before the `integration-test` phase by definition, so the fast job never pays for the service stack. The `-Pintegration-tests` profile in the second job is the conventional place to bind the Failsafe plugin and point the JDBC URL at the service container on `localhost:5432`. If you prefer one less step, replace `actions/setup-java` with [WarpBuilds/setup-java@v5](/docs/ci/features/caching/setup-actions) and set `cache: maven`. It accepts the same inputs as the upstream action and routes the dependency cache to WarpBuild Cache automatically, so the explicit `WarpBuilds/cache` step can be dropped. For ARM64 targets, swap the label to a size such as `warp-ubuntu-latest-arm64-8x`. Temurin and the other common JDK distributions publish aarch64 Linux builds, so the same workflow compiles and tests natively, with ARM64 rates starting at $0.003 per minute for the 2 vCPU size. ## [Sizing](#sizing)Copy Anchor Link The Linux x64 catalog, with per-minute rates from the [WarpBuild pricing page](/pricing): | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Map the sizes to build phases rather than to repository size. **Compile phase.** javac itself runs largely single-threaded per module, so compile parallelism comes from the width of the reactor. `-T 1C` on a reactor with 20 independent modules keeps 8 or 16 cores busy; a deep chain of dependent modules serializes no matter how many cores are present. A single-module project or a reactor under roughly ten modules compiles comfortably on `warp-ubuntu-latest-x64-4x`. Wide reactors with dozens of modules are where `warp-ubuntu-latest-x64-16x` shortens the compile phase, because the module scheduler finally has enough lanes. **Surefire phase.** Unit tests are usually the largest share of the wall clock and the easiest to scale. With `forkCount` at `1C` and independent, CPU-bound tests, the phase spreads across every core: four forks on the 4x, eight on the 8x, sixteen on the 16x, each fork within its 2 GB `argLine` budget. Suites with shared static fixtures or heavy I/O stop scaling earlier; if the CPU chart sags while the test phase drags, the constraint is the tests themselves rather than the machine. **Integration test phase.** Failsafe is usually the serial tail. A bigger runner helps only when the integration tests actually run in parallel. `warp-ubuntu-latest-x64-16x` earns its rate when the job runs a service container stack plus parallel forks, since Postgres, the application under test, and eight or more forks compete for cores on anything smaller. When the integration suite is strictly serial, buy the smallest runner that holds the service stack, often the 4x, and get parallelism by sharding the suite across matrix jobs instead. Static analysis jobs, Checkstyle, SpotBugs, and Error Prone javac passes, reuse the cached repository and rarely hold more than a few cores busy; keep them on `warp-ubuntu-latest-x64-4x`. The 32x size pays off only when a 16x run shows sustained full-core saturation through compile and test, which in Java usually means a very wide reactor with a large, well-parallelized suite. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes list prices for its hosted runners: the standard Linux runner meters at $0.006 per minute on private repositories, and Linux larger runners meter at $0.012 for 4 vCPU, $0.022 for 8 vCPU, and $0.042 for 16 vCPU. Rates are from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and [github.com/pricing](https://github.com/pricing), checked on 2026-08-13. Take a Java monorepo whose pipeline consumes 25,000 runner-minutes per month on 8 vCPU machines, storing 12 GB of Maven cache and performing 6,000 cache operations: | Line item | Rate | Volume | Monthly cost | | ---------------------------------------- | --------------------- | ---------------- | ------------ | | GitHub-hosted Linux 8 vCPU larger runner | $0.022 per minute | 25,000 minutes | $550.00 | | warp-ubuntu-latest-x64-8x | $0.016 per minute | 25,000 minutes | $400.00 | | WarpBuild cache storage | $0.20 per GB-month | 12 GB | $2.40 | | WarpBuild cache operations | $0.0001 per operation | 6,000 operations | $0.60 | The WarpBuild total is $403.00 against $550.00 on GitHub-hosted larger runners for the same minutes, a difference of $147.00 per month. The shapes match exactly, so the gap in the table is pure rate difference: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13\. Every rate above is on the [pricing page](/pricing). Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four failure modes account for most slow Java pipelines on GitHub Actions. **Cold `~/.m2`.** Every runner starts as a fresh VM with an empty local repository, so an uncached job resolves and downloads every dependency and every Maven plugin before the first line of javac output. A mid-size Spring Boot service pulls hundreds of artifacts this way on every push. The fix is the cache step above: `~/.m2/repository` keyed on the pom.xml hash, with `restore-keys` for partial reuse. Maven repositories grow past what teams expect once every plugin version and transitive dependency lands in them; if cache eviction is undoing the work, the [guide to GitHub Actions cache size limits](/guides/github-actions-cache-size-limit) covers where limits come from and what to do about them. **JVM warmup on short-lived jobs.** Every forked JVM starts cold: class loading, bytecode verification, then interpreted execution until the JIT compiler warms the hot paths. A fork that lives 30 seconds spends much of its life warming up rather than testing. Keep `reuseForks` set to `true` so each fork amortizes warmup across many test classes, and avoid configurations that fork per class. For build-tool JVMs and short smoke-test jobs, `-XX:TieredStopAtLevel=1` trades peak JIT output for fast startup, a good trade for a JVM that runs under a minute. Leave full tiered compilation on for long suites, where peak throughput wins the trade back. **Serialized integration tests behind service containers.** The common pattern is one Postgres in the `services` block, one shared schema, and a suite forced to run serially because every test writes to the same tables. The runner then idles fifteen cores while one fork walks the suite. Three fixes stack: give each fork its own schema or database by templating `${surefire.forkNumber}` into the JDBC URL, move to Testcontainers so each fork owns an isolated container, or shard the integration suite across matrix jobs so the serial sections at least run side by side on separate runners. **Annotation processing.** Lombok, MapStruct, Dagger, and similar processors run inside javac, and on a fresh VM every build is effectively a clean build, so their full cost recurs on each push. Declare processors explicitly in the maven-compiler-plugin's `annotationProcessorPaths` so javac skips scanning the whole compile classpath for them, keep processor-heavy generated-code modules separate so the rest of the reactor compiles without waiting on them, and remove processor declarations from modules that generate nothing. Telling these four apart is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so a job idling through a serial integration suite looks visibly different from one saturating every core through Surefire. Workflow-level tactics that apply beyond Java, including job parallelization and cancellation of superseded runs, are in the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Proof](#proof)Copy Anchor Link Public Java projects run production workloads on warp- labels, and the workflow files are open to read. * [restatedev/sdk-java](https://github.com/restatedev/sdk-java/blob/main/.github/workflows/tests.yml) builds and tests the Restate Java SDK with Gradle on `warp-ubuntu-latest-x64-4x`, compiling on a Temurin JDK and uploading JUnit test results on every pull request and push to main. * [kintsugi-tax/killbill-kintsugi-plugin](https://github.com/kintsugi-tax/killbill-kintsugi-plugin/blob/main/.github/workflows/release.yml) builds a plugin for the Kill Bill billing platform with `mvn -B clean verify` on `warp-ubuntu-latest-x64-2x` in its release workflow. ## [FAQ](#faq)Copy Anchor Link **Which WarpBuild runner size should a Java project start with?** Start on warp-ubuntu-latest-x64-8x at $0.016 per minute with Maven's `-T 1C` and Surefire `forkCount` at `1C`. Move the build and test job to warp-ubuntu-latest-x64-16x when the CPU chart in WarpBuild's CI observability shows all 8 vCPUs saturated through compile and test, and keep Checkstyle and SpotBugs jobs on warp-ubuntu-latest-x64-4x. **How do I cache the Maven local repository on WarpBuild runners?** Add WarpBuilds/cache@v1 with `~/.m2/repository` as the path and a key built from `hashFiles('**/pom.xml')`. Or replace actions/setup-java with WarpBuilds/setup-java@v5 and set `cache: maven`. Both are drop-in compatible with the upstream actions they replace. **Do WarpBuild runners work with Gradle projects too?** Yes. The runner images carry the same tooling as GitHub-hosted runners, so Gradle wrappers run unchanged on warp- labels. Gradle-specific caching, including the build cache and configuration cache, is covered on the [Gradle solutions page](/solutions/gradle-github-actions). **Is WarpBuild SOC 2 compliant?** The audit evidence is published at [trust.warpbuild.com](https://trust.warpbuild.com/). --- # Kotlin Multiplatform Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/kotlin-multiplatform-github-actions Description: Kotlin Multiplatform on GitHub Actions needs Linux and macOS runners. Workflow YAML, warp- labels, Gradle and konan caching, sizing, and per-minute rates. # Kotlin Multiplatform Builds on GitHub Actions Kotlin Multiplatform on GitHub Actions needs Linux and macOS runners. Workflow YAML, warp- labels, Gradle and konan caching, sizing, and per-minute rates. Last verified: 2026-08-13 A Kotlin Multiplatform build on GitHub Actions has to span two operating systems, because Apple targets link only on macOS while the JVM, Android, JS, Wasm, and Linux native targets all build on Linux. WarpBuild runs that split with two `runs-on` labels, `warp-ubuntu-latest-x64-8x` at $0.016 per minute for the JVM and Android half and `warp-macos-15-arm64-6x` at $0.08 per minute for the Apple half, so the macOS minutes stay scoped to the work that genuinely requires macOS. ## [Overview](#overview)Copy Anchor Link Kotlin Multiplatform compiles one `commonMain` source set into a separate binary per target, and each target carries a host requirement that the workflow file has to respect. Putting every target in one job forces the whole build onto macOS, which is the most expensive minute in the catalog. | Target group | Gradle target functions | Host required | Runner label | | ---------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------- | | JVM | jvm() | Linux, macOS, or Windows | warp-ubuntu-latest-x64-8x | | Android | androidTarget() | Linux, macOS, or Windows | warp-ubuntu-latest-x64-8x | | JS and Wasm | js(IR), wasmJs() | Linux, macOS, or Windows | warp-ubuntu-latest-x64-8x | | Linux native | linuxX64(), linuxArm64() | Linux | warp-ubuntu-latest-x64-8x | | Apple | iosArm64(), iosSimulatorArm64(), macosArm64(), watchosArm64(), tvosArm64() | macOS | warp-macos-15-arm64-6x | | Full publication | publishAllPublicationsToMavenRepository | macOS, because the Apple klibs are part of the publication | warp-macos-15-arm64-6x | Publication is the case teams get wrong most often. A release job that publishes the whole coordinate set needs the Apple klibs present, so either the publish job runs on macOS or the build uses host-specific publication and stitches the Linux and macOS halves together against the same version. Both work. Only one of them keeps macOS minutes off the pull request path. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, which covers the whole target matrix above with label changes rather than a second CI system. Sizes, images, and rates for every label live in the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners). ## [Configuration](#configuration)Copy Anchor Link The workflow below splits the build in two. The Linux job owns the JVM, Android, and JS targets. The macOS job owns the Apple targets. Each job configures its own Gradle cache through `WarpBuilds/gradle-actions/setup-gradle@v5`, and the Apple job adds a second entry for the Kotlin/Native toolchain directory, which sits outside the Gradle User Home and is therefore missed by a Gradle-only cache step. Copy Text ``` name: kmp on: pull_request: push: branches: [main] jobs: jvm-android: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: "21" - uses: WarpBuilds/gradle-actions/setup-gradle@v5 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - run: ./gradlew jvmTest testDebugUnitTest jsNodeTest --build-cache --parallel apple: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: "21" - uses: WarpBuilds/gradle-actions/setup-gradle@v5 with: cache-read-only: ${{ github.ref != 'refs/heads/main' }} - name: Restore the Kotlin/Native toolchain uses: actions/cache@v4 with: path: ~/.konan key: konan-macos-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }} restore-keys: | konan-macos- - run: ./gradlew iosSimulatorArm64Test macosArm64Test linkDebugFrameworkIosArm64 --build-cache ``` Two details in that file are worth stating plainly. `WarpBuilds/gradle-actions/setup-gradle@v5` is the WarpBuild fork of `gradle/actions`, takes the same inputs, and caches the Gradle User Home including wrapper distributions, resolved dependencies, and build caches. The WarpBuild cache backend is enabled by default on WarpBuild runners for this fork, so both the Linux job's entries and the macOS job's entries are served by it without extra configuration. The full input list for the fork and its sibling actions is in the [setup actions documentation](/docs/ci/features/caching/setup-actions). `cache-read-only` on non-default branches is the pattern that keeps a Kotlin Multiplatform repository from writing one cache entry per pull request. The Gradle User Home for a multiplatform project is large, because it holds the dependency cache for every target plus the build cache, and letting every branch write it turns cache storage into the second-largest line on the bill. Set the shared Gradle properties once so local runs and GitHub Actions runs agree. Copy Text ``` org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.parallel=true org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g kotlin.daemon.jvmargs=-Xmx4g kotlin.native.ignoreDisabledTargets=true kotlin.incremental.native=true ``` `kotlin.native.ignoreDisabledTargets=true` is what lets the same `build.gradle.kts` be evaluated on both hosts, so the Linux job skips the Apple targets rather than failing configuration. ## [Sizing](#sizing)Copy Anchor Link The Linux half behaves like any Gradle build. Budget memory across three consumers, the Gradle daemon heap, the Kotlin compile daemon, and the forked test JVMs, then set worker counts against what is left. On `warp-ubuntu-latest-x64-8x` with 32GB, an 8GB daemon heap plus a 4GB Kotlin daemon plus four test forks at 2GB each reserves 20GB and leaves headroom for the page cache that keeps build cache reads fast. The Apple half is sized differently, because the constraint is usually disk rather than cores. Multiple sizes and configurations per chip are available, and the two macOS shapes differ in storage as well as vCPU. | Label | macOS | vCPU | Memory | Storage | Per minute | | -------------------------- | ------------ | ---- | ------ | --------- | ---------- | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64GB | 150GB SSD | $0.032 | Count what an Apple job holds on disk before choosing between them. The Kotlin/Native toolchain and its platform libraries under `~/.konan` run to several gigabytes on their own. Add the Gradle User Home, a `build` directory per target, the klib outputs for every Apple target you declare, and DerivedData whenever the job also drives `xcodebuild`. A library with five Apple targets plus simulator variants clears 120GB more often than it saturates six cores, and that is the signal to move to `warp-macos-15-arm64-12x`. The macOS 26 labels ship the Xcode 27.0 SDKs and simulator runtimes on top of the upstream GitHub macOS 26 image while GitHub's upstream macOS 27 runner image is in beta, and a dedicated macOS 27 image follows once that image is released. Pin the macOS version explicitly rather than relying on an alias, so a toolchain move is a reviewed commit. Fan out on the Linux side. macOS concurrency is governed by a per-organization quota since July 27, 2026, and jobs beyond the quota wait for capacity, so a 20 way matrix over Apple targets buys queueing rather than throughput. Split by target group on macOS only when each leg is long enough to justify a separate runner, and keep wide matrices on the Ubuntu labels. Teams that need the Linux half inside their own cloud account can run it on BYOC, which runs on AWS, GCP, and Azure. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Kotlin/Native compilation.** The link step for each Apple target is the slowest task in the build and does not parallelize across a single target the way JVM compilation does. Two moves help: keep `kotlin.incremental.native=true` so unchanged source sets skip recompilation, and build `linkDebug*` on pull requests while reserving `linkRelease*` for the release workflow, since release linking runs full optimization. **konan dependency downloads.** A cold macOS job downloads the Kotlin/Native compiler distribution and the platform libraries for each Apple target into `~/.konan` before any code compiles. This is the largest fixed cost in a first run and the one the cache step above removes. Key the entry on the Kotlin version so a version bump invalidates it exactly once rather than on every run. **Gradle configuration cache misses.** A multiplatform build has a wide configuration phase, so the configuration cache pays off more here than in a single-target project. It also invalidates more easily. Reading a system property, an environment variable, or `git describe` output at configuration time ties the entry to that value, and a workflow that passes a run number into the build invalidates the cache on every run. Pass volatile values to task inputs instead. **Duplicated work across targets.** `commonMain` is compiled once per target plus once for metadata, so adding a target adds compilation rather than reusing it. Two habits keep the growth linear rather than compounding: run common tests once on the JVM target instead of on every native target, and declare `iosSimulatorArm64` for tests while keeping `iosArm64` build only, so simulator test runs are not duplicated across device targets. **Cross-host cache keys.** Cache versions are derived from the compression tool and the set of cached paths, both of which differ by runner operating system. An entry written on a macOS runner cannot be restored on an Ubuntu runner. Treat the Linux and macOS halves as two separate cache scopes and give each its own key prefix. **Missing visibility into which half is slow.** CI observability reports OpenTelemetry system metrics from the runner agent alongside GitHub Actions job logs, which is where a link task pinned to one core or a daemon thrashing against its heap ceiling becomes visible. ## [Proof](#proof)Copy Anchor Link Work the numbers on a concrete repository. Take a Kotlin Multiplatform library running 800 pull request and merge builds per month, where the Linux job takes 14 minutes on `warp-ubuntu-latest-x64-8x` and the Apple job takes 18 minutes on `warp-macos-15-arm64-6x`, holding 40GB of cache and performing 6 cache operations per Linux job. | Line item | Arithmetic | Monthly | | ---------------- | -------------------------- | --------- | | Linux minutes | 11,200 at $0.016 | $179.20 | | macOS minutes | 14,400 at $0.08 | $1,152.00 | | Cache storage | 40GB at $0.20 per GB-month | $8.00 | | Cache operations | 4,800 at $0.0001 | $0.48 | | Total | $1,339.68 | | The same minutes on the comparable GitHub-hosted runners bill at $0.022 per minute for Linux and $0.102 per minute for the largest macOS ARM64 shape, which is $246.40 plus $1,468.80, or $1,715.20\. That is $375.52 more on the month for the same job durations. GitHub publishes those rates on its [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. Stated as list-price arithmetic, with rates for every label on the [WarpBuild pricing page](/pricing): warp-macos-latest-arm64-6x (6 vCPU, 14 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price. warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list prices checked on 2026-08-13. Verify the split on your own repository rather than taking the model on trust. Run the same commit with every target in one macOS job and again with the two-job layout above, then compare total billed minutes across both. Gradle-side caching detail sits on the [Gradle build caches on GitHub Actions runners](/solutions/gradle-github-actions) page, the Android leg including emulator tests is covered on the [Android builds on GitHub Actions runners](/solutions/android-github-actions) page, the Swift and SwiftPM side of an Apple target is on the [Swift package builds on GitHub Actions](/solutions/swift-github-actions) page, and the macOS image matrix is on the [WarpBuild macOS runners](/runners/macos) page. ## [FAQ](#faq)Copy Anchor Link **Why does a Kotlin Multiplatform build need a macOS runner at all?** The Kotlin/Native compiler links Apple target binaries against the Apple SDKs and the linker that ships with Xcode, so `iosArm64`, `iosSimulatorArm64`, `macosArm64`, `watchosArm64`, and `tvosArm64` can only be produced on macOS. JVM, Android, JS, Wasm, and Linux native targets all build on a Linux runner. **Which WarpBuild labels should the two halves of the build use?** Start the JVM and Android half on `warp-ubuntu-latest-x64-8x` at $0.016 per minute and the Apple half on `warp-macos-15-arm64-6x` at $0.08 per minute. Move the Apple job to `warp-macos-15-arm64-12x` when the target set overflows the 120GB disk or when link tasks run out of memory. **Can one cache entry serve both the Linux job and the macOS job?** No. Cache versions are derived from the compression tool and the cached paths, and those differ across runner operating systems, so a cache written on a macOS runner cannot be restored on an Ubuntu runner. Key the two halves separately and expect two sets of entries. **How many Apple jobs can run in parallel on WarpBuild?** macOS concurrency is governed by a per-organization quota since July 27, 2026, and jobs beyond the quota wait for capacity to free up. Linux and Windows runners on generally available features carry no such quota, so wide matrix fan-out belongs on the Linux half of the build. --- # Kubernetes Deploy Jobs on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/kubernetes-deployments-github-actions Description: Kubernetes deployments on GitHub Actions split into a build job and a rollout job. Here are the warp- labels, sizes, per-minute rates and transfer costs. # Kubernetes Deploy Jobs on GitHub Actions Kubernetes deployments on GitHub Actions split into a build job and a rollout job. Here are the warp- labels, sizes, per-minute rates and transfer costs. Last verified: 2026-08-13 A Kubernetes deployment on GitHub Actions is two jobs with different shapes: a build job that produces an image and a deploy job that authenticates to the cluster and rolls the image out. Sizing them as one job is the most common way teams overpay, because the build job is compute bound for a few minutes and the deploy job is idle for most of its life while `kubectl rollout status` waits on the cluster. This page gives the workflow for both jobs on `warp-` labels, the catalog rows and per-minute rates for each, the private cluster access path through the networking addon, the gigabytes a rollout pulls and what they cost at published cloud rates, and the failure modes that show up once the pipeline runs every merge. ## [Overview](#overview)Copy Anchor Link Split the pipeline at the point where the work changes character. The build job compiles, packages, and pushes. It wants cores, a warm layer cache, and a fast path to the registry. Remote Docker builders run outside the GitHub Actions runner with a persistent layer cache on local disks, so repeat builds reuse layers instead of rebuilding them. The build side is covered in depth on [Docker builds on GitHub Actions with remote builders](/solutions/docker-builds-github-actions). The deploy job does something else entirely. It assumes a cloud role, writes a kubeconfig, applies a manifest or a chart, and then blocks until the rollout reports healthy. Its CPU sits near zero for most of its runtime. What it needs is network reach to the cluster API server and credentials that expire. Three costs sit in that second job, and none of them are compute. The first is wait time. A runner billed per minute is billed while `helm --wait` and `kubectl rollout status` poll the API server. A ten minute readiness gate is ten billed minutes on whatever shape you picked. The second is reach. Clusters with a private API endpoint reject a runner that has no route into the network, and the usual workarounds are a bastion, a self-hosted fleet inside the cluster network, or an allowlisted static IP address that then carries its own data processing charges. The third is bytes. Deploy jobs pull images and release artifacts out of stores you own, and the meter counts gigabytes rather than jobs. That line lands on your cloud bill instead of the GitHub Actions invoice, which is why it survives so many cost reviews. [What ECR pulls cost in a GitHub Actions pipeline](/guides/ecr-pull-costs-github-actions) works that number in detail. ## [Configuration](#configuration)Copy Anchor Link ### [The two-job workflow](#the-two-job-workflow)Copy Anchor Link The build job runs on a 4 vCPU runner and hands the image build to a builder profile. The deploy job runs on a 2 vCPU runner carrying the networking addon label, and consumes the image reference through a job output so it never resolves a floating tag. Copy Text ``` name: deploy-api on: push: branches: [main] permissions: id-token: write contents: read jobs: build: runs-on: warp-ubuntu-latest-x64-4x outputs: image: ${{ steps.ecr.outputs.registry }}/api@${{ steps.push.outputs.digest }} steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy aws-region: us-east-1 - uses: aws-actions/amazon-ecr-login@v2 id: ecr - name: Build and push id: push uses: Warpbuilds/build-push-action@v6 with: context: . push: true tags: ${{ steps.ecr.outputs.registry }}/api:${{ github.sha }} profile-name: "api-images-x64" timeout: 600000 deploy: needs: build runs-on: warp-ubuntu-latest-x64-2x;network.name=production-tailnet steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/github-actions-deploy aws-region: us-east-1 - uses: azure/setup-helm@v4 with: version: v3.16.2 - name: Write kubeconfig run: aws eks update-kubeconfig --name prod-use1 --region us-east-1 - name: Roll out run: | helm upgrade api ./charts/api \ --install \ -n api \ --set image.reference=${{ needs.build.outputs.image }} \ --wait --timeout 10m - name: Confirm rollout run: kubectl rollout status deployment/api -n api --timeout=5m ``` Two details in that file are worth calling out. The build job publishes a digest rather than a tag, so the rollout pins the exact bytes that were built and a retag upstream cannot change what the cluster runs. And the deploy job carries no builder profile, because it builds nothing. ### [Private cluster access](#private-cluster-access)Copy Anchor Link The `network.name=` modifier on the `runs-on` label joins the runner to your Tailscale tailnet at job start. WarpBuild authenticates the runner with Tailscale OIDC and ephemeral node keys, the runner reaches any device on the tailnet, and the ephemeral node is removed when the job finishes. The configuration name in the label is the Network Addon Configuration you created in the dashboard. Full setup is in the [networking documentation](/docs/ci/features/networking). Modifiers stack on one label, so a deploy job can join the tailnet and restore a snapshot at the same time: Copy Text ``` jobs: deploy: runs-on: warp-ubuntu-latest-x64-2x;network.name=production-tailnet migrate: runs-on: warp-ubuntu-latest-x64-2x;network.name=production-tailnet;snapshot.key=migrations ``` Extra `tailscale up` flags go in the Additional Arguments field on the addon configuration, not in the workflow file. These are the ones that matter for a deploy job: | Flag | Effect on a deploy job | | ------------------------------- | -------------------------------------------------------------------------- | | \--advertise-tags=tag:ci | Applies the ACL tag that grants the runner reach to the API server | | \--accept-routes | Accepts subnet routes advertised by a node in front of the cluster network | | \--accept-dns=false | Leaves resolver behavior to the runner when your kubeconfig uses cloud DNS | | \--advertise-routes=10.0.0.0/24 | Advertises routes from the runner itself | Five flags are reserved and set by WarpBuild: `--hostname`, `--auth-key`, `--client-id`, `--id-token`, and `--state`. Passing any of them in Additional Arguments is rejected, so identity and state handling stay with the platform. Tags passed through `--advertise-tags` must be a subset of the tags configured on the Tailscale trust credential, and the ACL policy decides what the tagged runner can reach. Tailscale is pre-installed on all WarpBuild runner images, and the setup script runs only when the addon is requested. BYOC runners on custom images need `jq` present. Both jobs also run outside GitHub Actions if you need them to. BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS, so the runner fleet can live in the same module as the cluster it deploys to. Infrastructure jobs that plan and apply that module are covered on [Terraform plan and apply jobs on GitHub Actions](/solutions/terraform-github-actions). ## [Sizing](#sizing)Copy Anchor Link Size the two jobs against different constraints, and price them separately. ### [The deploy job](#the-deploy-job)Copy Anchor Link The deploy job needs enough memory for `helm template` on a large chart and nothing else. Start at 2 vCPU and move up only if manifest rendering or a migration container is genuinely slow. | Runner label | OS | vCPU | RAM | Storage | Price per minute | | --------------------------- | ------------ | ---- | ----- | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-arm64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.003 | | warp-ubuntu-latest-arm64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.006 | Rates from the [pricing page](/pricing), checked on 2026-08-13\. ARM64 labels are the cheaper row for a job whose tooling ships ARM64 binaries, which `kubectl`, `helm`, and the AWS CLI all do. ### [The build job](#the-build-job)Copy Anchor Link The build job is two billed resources: the runner that ships the build context and the builder profile that runs the build. Size the profile against the widest fan-out that hits it at once, at the documented minimum of roughly 8 vCPU and 16GB memory per concurrent build job. | Resource | Shape | Price per minute | | -------------------------------- | -------------------------- | ---------------- | | warp-ubuntu-latest-x64-4x runner | 4 vCPU, 16 GB, 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x runner | 8 vCPU, 32 GB, 150GB SSD | $0.016 | | Builder profile | 16 vCPU, 32GB, 100GB disk | $0.06 | | Builder profile | 32 vCPU, 64GB, 200GB disk | $0.12 | | Builder profile | 64 vCPU, 128GB, 200GB disk | $0.24 | Rates from the [pricing page](/pricing), checked on 2026-08-13\. Builder sessions bill per minute from the first job's builder start to the last job's completion, and a multi-architecture profile opens one session per architecture. ### [Worked model for a deploy pipeline](#worked-model-for-a-deploy-pipeline)Copy Anchor Link Assumptions: eight services, each deploying three times per weekday, 22 weekdays a month, so 528 rollouts. Each build job holds the runner 6 minutes and the builder 4 minutes on one 16 vCPU profile. Each deploy job holds a 2 vCPU runner 8 minutes, of which about 5 are readiness waiting. | Line | Arithmetic | Monthly | | ----------------------- | -------------------- | ------- | | Build runner minutes | 528 x 6 min x $0.008 | $25.34 | | Builder session minutes | 528 x 4 min x $0.06 | $126.72 | | Deploy runner minutes | 528 x 8 min x $0.004 | $16.90 | | Total | $168.96 | | The runner lines are the ones with a GitHub-hosted equivalent. Those 3,168 build minutes list at $0.012 per minute on the 4-core Linux larger runner and those 4,224 deploy minutes list at $0.006 per minute on `ubuntu-latest` for private repositories, which totals $63.36 against $42.24 of WarpBuild runner minutes ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). The builder line has no GitHub-hosted counterpart, so compare it against the build minutes it removes from the runner rather than against a list price. ### [What the rollout pulls](#what-the-rollout-pulls)Copy Anchor Link Runner minutes are the smaller half of this pipeline once the registry is involved. Price the bytes with the same discipline: a per-rollout figure, a published rate, and a date. | Input | Value | Source | | ---------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------- | | Rollouts per month | 528 | Your workflow run history | | Image bytes pulled by the deploy job | 1.2 GB | Your registry metrics | | Chart, manifest, and release artifacts from S3 | 0.4 GB | Your bucket metrics | | Internet data transfer rate | $0.09 per GB | [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/) | | ECR to same-Region compute | $0.00 per GB | [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/) | | Free internet transfer allowance | 100 GB per month, all services | [Amazon EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/) | All AWS rates checked on 2026-08-13\. Per rollout the deploy job moves 1.2 plus 0.4, which is 1.6 GB. Across 528 rollouts that is 844.8 GB a month. | Runner placement | Arithmetic | Monthly | | ---------------------------------------------------- | ------------------------- | ------- | | Outside your account, pulling over the internet path | 744.8 billable GB x $0.09 | $67.03 | | Inside your account and Region | 844.8 GB x $0.00 | $0.00 | The first row credits the 100 GB monthly allowance once, and that allowance is shared with production, so a busy account has usually spent it already. The cluster-side pulls are a separate volume: six replicas pulling a 1.2 GB image is 7.2 GB per rollout, priced at $0.00 per GB by AWS when the nodes and the registry share a Region. On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you, whether your runners live in your cloud or ours; the scope and a worked model are on [zero egress for the enterprise tier](/enterprise/zero-egress). ## [Bottlenecks](#bottlenecks)Copy Anchor Link ### [Readiness waiting is billed runner time](#readiness-waiting-is-billed-runner-time)Copy Anchor Link In the model above, roughly 5 of the 8 deploy minutes are the runner polling the API server. That is 528 x 5 x $0.004, or $10.56 a month, and it is the line that grows when someone raises a `--timeout` after a flaky release. Two things shrink it. Set the readiness gate to the value your rollout actually needs rather than a round number nobody revisits. And keep the waiting job on the smallest shape, because a 2 vCPU runner waits at $0.004 per minute while a 4 vCPU runner waits at $0.008 for the same result. ### [The builder session stays open longer than the build](#the-builder-session-stays-open-longer-than-the-build)Copy Anchor Link Billing runs from the first job's builder start to the last job's completion on that profile. A build job that assigns a builder, then runs a test suite, then builds, keeps the session open through the tests. Put the build step next to the builder setup and let tests run in their own job on a plain runner. The runner and the builder are separate resources on the invoice. Reading the builder line as though it were runner minutes is the usual source of surprise on the first bill. ### [Image size drives both halves](#image-size-drives-both-halves)Copy Anchor Link Every gigabyte in the image is paid twice: once on the deploy job's pull and again on every node that runs a replica. A multi-stage Dockerfile that copies only the compiled output into a slim runtime base cuts both. Ordering the Dockerfile so dependency manifests are installed before application source is what decides the cache hit rate on the builder, and no builder makes a badly ordered Dockerfile cache well. ### [Cold profiles on slow-moving services](#cold-profiles-on-slow-moving-services)Copy Anchor Link A builder profile that goes unused for more than 10 days is reset automatically. Services that deploy weekly can land on a cold cache and see build times that look like a regression. Point several low-frequency services at one shared profile so it stays warm, and remember that jobs sharing a profile share its CPU, memory, and disk while they run. ### [Credentials and blast radius](#credentials-and-blast-radius)Copy Anchor Link A deploy job holds cloud credentials and cluster credentials at the same time. Use short-lived OIDC credentials rather than static keys, scope the assumed role to the workloads the job touches, and keep the tailnet ACL tag narrow enough that a compromised job reaches the API server and nothing else. The ephemeral node leaves the tailnet when the job ends, so a stale node never accumulates access. ### [What none of this fixes](#what-none-of-this-fixes)Copy Anchor Link A rollout that fails on a bad readiness probe, a migration that locks a table, or a chart that renders an invalid manifest fails the same way on any runner. Faster machines shorten the feedback loop on those failures without preventing them. ## [Proof](#proof)Copy Anchor Link The configuration on this page is verifiable in public. Public OSS repositories running `warp-` labels are citable evidence, and the clearest one for the build half is the [warp-e2e workflow](https://github.com/WarpBuilds/build-push-action/blob/main/.github/workflows/warp-e2e.yml) in the `Warpbuilds/build-push-action` repository. It runs on `runs-on: warp-ubuntu-latest-x64-4x`, builds against a named builder profile, then asserts that the active buildx driver is `remote` and that the node endpoint is a `tcp://` address, which proves the build left the runner. It rebuilds and compares digests, which proves the profile served layers from cache on the second pass. The [WarpBuild runner agent](https://github.com/WarpBuilds/warpbuild-agent) is open source as well. Every cost figure on this page carries its arithmetic, a source link, and a checked-on date, and the same rates appear on the [pricing page](/pricing). Cloud list prices move, so re-check the AWS and GitHub links before quoting these totals internally, and substitute your own image size and rollout count. ## [FAQ](#faq)Copy Anchor Link ### [Can a GitHub Actions runner reach a private Kubernetes cluster?](#can-a-github-actions-runner-reach-a-private-kubernetes-cluster)Copy Anchor Link Yes. Add `network.name=` to the `runs-on` label and the runner joins your Tailscale tailnet as an ephemeral node at job start, then leaves it when the job finishes. Tailscale is pre-installed on all WarpBuild runner images, and the networking addon is listed at $0.00 per minute on the [pricing page](/pricing). ### [Should the build job and the deploy job run on the same runner size?](#should-the-build-job-and-the-deploy-job-run-on-the-same-runner-size)Copy Anchor Link No. The build job needs cores and a remote Docker builder profile. The deploy job runs `kubectl` or `helm` and then waits on the cluster, so `warp-ubuntu-latest-x64-2x` at $0.004 per minute covers it. Sizing the deploy job like the build job pays 4 vCPU rates for a process that is idle most of its life. ### [What does one Kubernetes rollout cost in cloud data transfer?](#what-does-one-kubernetes-rollout-cost-in-cloud-data-transfer)Copy Anchor Link Count the gigabytes the deploy job pulls, not the jobs. A rollout that pulls a 1.2 GB image and 0.4 GB of chart and release artifacts moves 1.6 GB. At 528 rollouts a month that is 844.8 GB, which AWS prices at $0.00 per GB when the runner sits in the same Region as the registry and at internet transfer rates when it does not. Checked on 2026-08-13. ### [Do I need a Docker builder profile for a deploy-only workflow?](#do-i-need-a-docker-builder-profile-for-a-deploy-only-workflow)Copy Anchor Link No. A workflow that only rolls out an image someone else built needs a runner and cluster credentials. Builder profiles are billed per session and only bill when a job assigns one, so a deploy-only repository never opens a session. --- # Laravel Test Suites on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/laravel-github-actions Description: Laravel suites on GitHub Actions stall on cold Composer installs, per-class migrations, and serial tests. Fix all three with warp- labels and sizing. # Laravel Test Suites on GitHub Actions Laravel suites on GitHub Actions stall on cold Composer installs, per-class migrations, and serial tests. Fix all three with warp- labels and sizing. Last verified: 2026-08-13 ## [Overview](#overview)Copy Anchor Link A Laravel suite on GitHub Actions is usually slow for three reasons that have nothing to do with your test code: `composer install` starts cold on every job, the database is migrated and seeded again for each test class, and the suite runs serially on a 2 vCPU machine. Point `runs-on` at a WarpBuild `warp-` label, cache the Composer directories with `WarpBuilds/cache@v1` keyed on `composer.lock`, load a squashed schema instead of replaying migrations, and run `php artisan test --parallel` on a runner with enough cores to hold the processes you ask for. The default GitHub-hosted Linux runner on private repositories carries 2 vCPU and 8 GB of RAM, per GitHub's published [runner specs](https://docs.github.com/en/actions/reference/runners/github-hosted-runners) (checked on 2026-08-13). Laravel's `--parallel` flag defaults to the number of available cores, so on that shape it spawns two PHP processes that then compete with a MySQL service container running on the same virtual machine. WarpBuild's Linux images carry the same tooling as GitHub-hosted runners, so your PHP setup action, Composer, PHPUnit, Pest, and Artisan commands run unchanged, and the only required edit to a working workflow is the `runs-on` line. Labels, shapes, and regions are listed on the [cloud runners documentation page](/docs/ci/cloud-runners). The rest of this page gives a working workflow, a sizing table with the arithmetic against GitHub's list prices, the three bottlenecks in order of cost, and how to verify the numbers in your own repository. For the sizing method applied to a different language, see the [guide to sizing runners for Python test suites](/guides/sizing-runners-for-python-test-suites). ## [Configuration](#configuration)Copy Anchor Link The workflow below runs a Laravel feature suite on `warp-ubuntu-latest-x64-8x` with MySQL and Redis as service containers, caches both Composer directories against the lockfile, and runs the suite in parallel. Copy Text ``` name: laravel-tests on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-8x services: mysql: image: mysql:8.4 env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: laravel_test ports: - 3306:3306 options: >- --health-cmd "mysqladmin ping -h 127.0.0.1" --health-interval 5s --health-timeout 5s --health-retries 20 redis: image: redis:7 ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 5s --health-retries 10 env: DB_CONNECTION: mysql DB_HOST: 127.0.0.1 DB_PORT: 3306 DB_DATABASE: laravel_test DB_USERNAME: root DB_PASSWORD: password REDIS_HOST: 127.0.0.1 steps: - uses: actions/checkout@v4 - name: Set up PHP uses: shivammathur/setup-php@v2 with: php-version: "8.3" extensions: mbstring, intl, pdo_mysql, redis, bcmath coverage: none - name: Resolve the Composer cache directory id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" - name: Restore Composer cache and vendor uses: WarpBuilds/cache@v1 with: path: | ${{ steps.composer-cache.outputs.dir }} vendor key: ${{ runner.os }}-php83-composer-${{ hashFiles('composer.lock') }} restore-keys: | ${{ runner.os }}-php83-composer- - name: Install dependencies run: composer install --prefer-dist --no-interaction --no-progress - name: Prepare the application run: | cp .env.example .env php artisan key:generate php artisan migrate --force - name: Run the suite in parallel run: php artisan test --parallel --processes=6 --recreate-databases ``` Four details in that file are worth calling out. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4`, so `path`, `key`, `restore-keys`, and the `cache-hit` output behave as before. Entries are scoped to the key, a version hash over the cached path list, and the branch, so adding a directory to `path` starts a fresh entry rather than reusing the old one. Entries expire 7 days after last use, which explains a single slow run on a branch that went quiet for a week. The full input list and the split restore and save variants are in the [caching documentation](/docs/ci/features/caching). Keep the PHP version inside the cache key, as `php83` does above. Extension-linked packages compiled against 8.3 should never restore into an 8.4 job. The `restore-keys` prefix carries most of the value. When `composer.lock` changes by one package the exact key misses, the prefix restores the newest entry on the branch, and `composer install` fetches only the delta instead of paying for a full cold install. Do not run `php artisan config:cache` before `php artisan test`. Cached configuration ignores the environment variables set in `phpunit.xml`, which is the usual cause of a suite that passes locally and connects to the wrong database inside GitHub Actions. ## [Sizing](#sizing)Copy Anchor Link `php artisan test --parallel` spawns one PHP process and one database per worker, and service containers run on the same virtual machine as the job. Both draw from the same CPU and memory budget, so size the runner for processes plus database rather than processes alone. | Runner label | vCPU | RAM | Storage | Price per minute | | -------------------------- | ---- | ----- | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | Rates and shapes come from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), checked on 2026-08-13. At the middle size the arithmetic is direct: warp-ubuntu-latest-x64-4x costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner at the same 4 vCPU and 16 GB shape, which is 33 percent lower list price ([GitHub billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked 2026-08-13). **4x (4 vCPU, 16 GB).** Unit-heavy suites, and feature suites run with `--processes=3` so MySQL keeps a core during migration. Below three workers the parallel overhead of building per-worker databases often cancels the gain. **8x (8 vCPU, 32 GB).** The common landing spot for a Laravel application with database-backed feature tests. Six workers plus MySQL plus Redis fit with headroom, and 32 GB holds six connection pools and six fixture sets without swapping. **16x (16 vCPU, 64 GB).** Worth it when one job consolidates a matrix, or when a monorepo runs several packages in the same run. Measure the migration phase first, since it runs once and no runner size shortens it. ### [A worked cost model](#a-worked-cost-model)Copy Anchor Link Take a suite that runs 400 times a month and finishes in 10 minutes at 4 vCPU. Substitute your own measured wall clock, because parallel efficiency depends on how much of the suite waits on the database. | Setup | Rate per minute | Cost per run | Cost per month (400 runs) | | ---------------------------------- | --------------- | ------------ | ------------------------- | | GitHub-hosted 4-core larger runner | $0.012 | $0.120 | $48.00 | | warp-ubuntu-latest-x64-4x | $0.008 | $0.080 | $32.00 | The same wall clock at the same shape leaves $16.00 a month on the table before any change to the workflow itself. ## [Bottlenecks](#bottlenecks)Copy Anchor Link Three things dominate a slow Laravel job, in descending order of typical cost. ### [Dependency install](#dependency-install)Copy Anchor Link A cold `composer install` resolves the lockfile, downloads every package archive, extracts each one, and writes thousands of small files. A mid-sized Laravel application pulls several hundred packages, and extraction is disk-bound rather than CPU-bound. Cache the Composer cache directory and `vendor` together as shown above, pass `--prefer-dist` so Composer takes archives instead of cloning repositories, and add `--classmap-authoritative` on jobs that never autoload a class outside the map. If `post-autoload-dump` runs package discovery and cache clears you do not need in the test job, install with `--no-scripts` and call the one script you actually want. The key design, the prefix rules, and the vendor-versus-cache-directory question are covered in the [answer on caching Composer dependencies](/answers/how-do-i-cache-composer-dependencies-in-github-actions). ### [Database setup per test class](#database-setup-per-test-class)Copy Anchor Link `RefreshDatabase` migrates once and wraps each test in a transaction, but suites that use `DatabaseMigrations` replay every migration per test class, and that cost grows with every migration you ever wrote. Two fixes stack. Run `php artisan schema:dump --prune` and commit `database/schema/mysql-schema.sql`, so a fresh database loads one SQL file instead of replaying hundreds of migration classes. Then let parallel workers reuse their databases between runs and pass `--recreate-databases` only when the schema file changes, so the per-worker build happens on schema changes rather than on every push. ### [Serial test execution](#serial-test-execution)Copy Anchor Link Without `--parallel`, one PHP process executes every test while the other cores idle and the database sits mostly unused. Install `brianium/paratest`, set `--processes` explicitly rather than taking the core-count default, and give the database user permission to create the per-worker databases Laravel names `laravel_test_1`, `laravel_test_2`, and so on. Tests that assume a shared cache or a fixed record ID surface as flakes at this step; move that state into `ParallelTesting::setUpTestDatabase` hooks or per-worker key prefixes for Redis. ## [Proof](#proof)Copy Anchor Link Every number on this page is reproducible without taking our word for it. WarpBuild rates come from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), GitHub list prices link to GitHub's own billing reference with the date they were checked, and the cost model is arithmetic you can repeat with a calculator after substituting your suite's wall clock. Cost and performance statements on WarpBuild pages carry a number, a source link, and a checked-on date, and the same number appears on every surface. To measure the workflow rather than model it, WarpBuild's CI observability reports CPU and memory from the runner agent correlated with the GitHub Actions job logs, so you can see whether six workers saturate the machine or whether four of them wait on MySQL. When a failure reproduces only inside GitHub Actions, the Action Debugger pauses the workflow and opens an SSH session on the live runner, so you can query the per-worker databases and inspect the cache state on the machine that failed instead of pushing another commit with echo statements. Snapshot runners, remote Docker builders, and an MCP server round out the same product surface. Two adjacent pages carry the parts this one compresses. The [PHP on GitHub Actions page](/solutions/php-github-actions) covers extension installs and autoloader tuning in more depth, and the [service containers guide](/guides/service-containers-github-actions) covers health checks, port mapping, and the failure modes of databases that start slower than the first test. ## [FAQ](#faq)Copy Anchor Link ### [How do I run Laravel tests in parallel on GitHub Actions?](#how-do-i-run-laravel-tests-in-parallel-on-github-actions)Copy Anchor Link Install `brianium/paratest` and run `php artisan test --parallel --processes=N`, where N leaves at least one vCPU for the database service container. Laravel creates one test database per process, so grant the database user permission to create databases and let the first run build them with `--recreate-databases`. ### [Which directories should a Laravel workflow cache?](#which-directories-should-a-laravel-workflow-cache)Copy Anchor Link Cache the Composer cache directory reported by `composer config cache-files-dir` together with `vendor`, keyed on a hash of `composer.lock` with a `restore-keys` prefix. The cache directory holds package archives and survives a lockfile change; `vendor` holds the extracted tree and makes a warm install close to a no-op. ### [What does a Laravel suite cost to run on WarpBuild?](#what-does-a-laravel-suite-cost-to-run-on-warpbuild)Copy Anchor Link In the worked model above, a 10-minute run on `warp-ubuntu-latest-x64-4x` costs $0.080, or $32.00 across 400 runs. Substitute your suite's measured wall clock and the current rate from the [pricing page](/pricing). --- # Maven Build Cache on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/maven-github-actions Description: Cache Maven on GitHub Actions with WarpBuilds/setup-java, cache: maven and a pom.xml dependency path, then map mvn -T threads onto warp- runner sizes. # Maven Build Cache on GitHub Actions Cache Maven on GitHub Actions with WarpBuilds/setup-java, cache: maven and a pom.xml dependency path, then map mvn -T threads onto warp- runner sizes. Last verified: 2026-08-13 ## [Overview](#overview)Copy Anchor Link A Maven job on GitHub Actions begins with an empty `~/.m2/repository`, because each runner is a fresh virtual machine, so the reactor downloads every dependency and every plugin again before javac produces a single class file. The shortest working setup is `WarpBuilds/setup-java@v5` with `cache: maven` and `cache-dependency-path` pointed at your `pom.xml` files, running on a `warp-` runner sized to the width of the reactor. Maven keeps one local repository per machine, shared by every module in the build and by every plugin the build invokes. Restoring that directory converts dependency resolution from a network operation into a disk read, which is why it is the first thing to cache and usually the only thing worth caching in a Maven pipeline. | Path | What it holds | What changes it | | ------------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------- | | \~/.m2/repository | Resolved release artifacts and plugin jars | Version bumps in any pom.xml, new plugins | | \~/.m2/repository/\*\*/\*-SNAPSHOT/ | Snapshot artifacts governed by the repository updatePolicy | Every upstream snapshot deploy | | \~/.m2/repository/\*\*/resolver-status.properties | Last-update timestamps and failed-resolution markers | Repository outages, offline runs | | \~/.m2/settings.xml | Mirrors, server entries, credentials | Written from secrets per job, so keep it out of the cache | | /target/ | Compiled classes, Surefire reports, packaged jars | Rebuilt on every run | Three scoping rules shape how keys behave. A cache entry is scoped to the key, the cache version, and the branch. The version is a hash over the compression tool and the list of cached paths, so an entry written on a macOS runner will fail to restore on a Linux runner even under an identical key. Entries expire 7 days after their last use, which retires abandoned branch entries on its own. Maven work belongs on the Linux runners, where cache is enabled by default and where [the runner catalog](/docs/ci/cloud-runners) lists Ubuntu 22.04, 24.04, and 26.04 images in sizes from 2 to 32 vCPUs, each with a 150GB SSD and the same tooling as GitHub-hosted runners. One scope note. This page stays on Maven: the local repository, reactor threading, and Surefire. General JVM heap tuning that applies across build tools sits on the [faster Java builds page](/solutions/java-github-actions), the Gradle build cache and configuration cache sit on the [Gradle build caches page](/solutions/gradle-github-actions), and sbt sits on the [Scala and sbt page](/solutions/scala-github-actions). ## [Configuration](#configuration)Copy Anchor Link `WarpBuilds/setup-java@v5` installs the JDK and restores the dependency cache in one step. Caching is off by default in that action, so `cache: maven` has to be set explicitly, and `cache-dependency-path` decides which files feed the key. Point it at every `pom.xml` in the reactor rather than the root one alone, because a version bump in a leaf module changes what has to be downloaded. Copy Text ``` name: maven on: pull_request: push: branches: [main] env: MAVEN_OPTS: -Xmx4g -Dmaven.artifact.threads=12 jobs: reactor: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: "21" cache: maven cache-dependency-path: | pom.xml **/pom.xml - name: Build and unit test the reactor run: mvn -B -ntp -T 4 verify -DskipITs ``` `-B` puts Maven in batch mode and `-ntp` drops the transfer progress lines, which together cut several thousand lines of noise out of the job log and make the reactor summary readable. `-DskipITs` keeps Failsafe out of the pull request job so the fast path stops at unit tests. The full list of maintained forks is on the [setup actions documentation](/docs/ci/features/caching/setup-actions). Each one accepts the same inputs as the action it replaces, so adopting it is a one line change per step. When you need to see and control the key yourself, drop back to the cache action directly. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` and takes the same `path`, `key`, and `restore-keys` inputs. Copy Text ``` - uses: WarpBuilds/cache@v1 with: path: ~/.m2/repository key: maven-${{ runner.os }}-${{ hashFiles('**/pom.xml') }} restore-keys: | maven-${{ runner.os }}- ``` The `restore-keys` prefix earns its place on Maven repositories. A single dependency bump changes the exact key, and without a prefix fallback the job downloads the entire dependency set again instead of the handful of artifacts that moved. Surefire needs a fixed fork count once `-T` is in play, because Maven multiplies build threads by forks. `-T 1C` with `forkCount` at `1C` on a 16 vCPU runner asks for up to 256 test JVMs, each one sizing its default heap at a quarter of machine memory. Copy Text ``` org.apache.maven.plugins maven-surefire-plugin 2 true -Xmx2g ``` One naming collision is worth flagging. Maven SNAPSHOT artifacts and WarpBuild snapshot runners share a word and share nothing else: the first is a version suffix with an update policy, the second is a runner that boots from a saved disk image so `~/.m2` arrives populated. Snapshot runners are covered on the [Gradle build caches page](/solutions/gradle-github-actions), and the same label syntax applies to a Maven reactor. ## [Sizing](#sizing)Copy Anchor Link Reactor width sets the ceiling on `-T`, and machine memory sets the ceiling on forks. Start from the dependency graph: `-T 8` on a reactor whose widest independent layer holds four modules keeps four threads idle, and no runner size fixes that. The reactor summary printed at the end of each build gives the per-module durations you need to find the chain that actually determines wall clock time. Rates below come from the WarpBuild runner catalog and [pricing page](/pricing). The thread and fork columns are starting points to tune against your own reactor summary. | Label | vCPU | Memory | Suggested \-T | Surefire forkCount | Peak test JVMs | Per minute | | -------------------------- | ---- | ------ | ------------- | ------------------ | -------------- | ---------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | \-T 1 | 1 | 1 | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | \-T 2 | 2 | 4 | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | \-T 4 | 2 | 8 | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | \-T 8 | 2 | 16 | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | \-T 16 | 2 | 32 | $0.064 | Check the memory budget before raising either number. On `warp-ubuntu-latest-x64-8x`, a 4 GB reactor JVM plus eight forks at 2 GB reserves 20 GB of the 32 GB available and leaves 12 GB for the page cache that makes local repository reads fast. On `warp-ubuntu-latest-x64-16x`, a 6 GB reactor JVM plus sixteen forks at 2 GB reserves 38 GB of 64 GB. On `warp-ubuntu-latest-x64-32x`, an 8 GB reactor plus thirty two forks at 2 GB reserves 72 GB of 128 GB, which is the point where a wide suite starts saturating the disk instead of the cores. Peak test JVMs exceed the vCPU count on purpose. A `-T` build thread spends most of the test phase waiting on its forks, so a small amount of oversubscription keeps cores busy while forks start and stop. Push past a factor of two and the run degrades into context switching. Map sizes to phases. A reactor under roughly ten modules compiles comfortably on `warp-ubuntu-latest-x64-4x`, and static analysis jobs running Checkstyle or SpotBugs against a restored repository belong there too. Wide reactors with dozens of independent modules are where `warp-ubuntu-latest-x64-16x` shortens the compile phase, because the module scheduler finally has lanes to fill. Integration jobs that run Failsafe against service containers need room for the containers plus the forks, which is the other case that justifies 16 vCPUs. For pure JVM reactors, test the ARM64 catalog. `warp-ubuntu-latest-arm64-8x` carries the same 8 vCPU and 32 GB shape at $0.012 per minute, and Temurin publishes aarch64 Linux builds, so most reactors move across by changing the label. Confirm every annotation processor and native library in the job before switching. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Cold `~/.m2`.** An uncached job resolves and downloads the full dependency set and the full plugin set before compilation starts. A mid-size Spring Boot reactor pulls hundreds of artifacts this way on every push, and the cost recurs identically on every pull request. The cache step above removes it. **Dependency resolution against remote repositories.** A warm repository still pays for metadata. Maven checks `maven-metadata.xml` for every SNAPSHOT dependency according to the repository `updatePolicy`, and version ranges force a metadata fetch per resolution. Set `updatePolicy` to `daily` or `interval:60` on internal snapshot repositories rather than leaving it at `always`, raise `-Dmaven.artifact.threads` so downloads run in parallel, and declare a single mirror in `settings.xml` so the resolver stops walking a list of repositories per artifact. When the internal repository sits inside your own network, run the runners there: BYOC runs on AWS, GCP, and Azure. **Serialized module order.** The reactor is a dependency graph, and `-T` extracts parallelism only where the graph is wide. A chain of ten modules that each depend on the previous one runs at the same speed on 4 vCPUs and 32\. Read the per-module times in the reactor summary, find the longest chain, and fix the graph before buying a larger label: split the shared `common` module that everything depends on, or move a slow module with no dependents to the end. **Surefire forks.** Fork startup is pure overhead. A JVM loads classes, verifies bytecode, and interprets until the JIT compiler warms up, so a fork that lives 30 seconds spends most of its life warming. Keep `reuseForks` at `true` so warmup amortizes across many test classes. Fork isolation also breaks against shared state: suites that write to one database schema serialize themselves, and the fix is to template `${surefire.forkNumber}` into the JDBC URL or give each fork its own container. Telling these apart is a measurement problem. CI observability streams system metrics from the runner agent and correlates them with GitHub Actions job logs, so a job stalled on network resolution looks visibly different from one saturating every core through Surefire. ## [Proof](#proof)Copy Anchor Link Work the arithmetic on a concrete pipeline. Take a reactor of 34 modules with 2,000 jobs per month: 1,600 pull request jobs at 9 minutes on 8 vCPU, and 400 verify jobs at 14 minutes on 16 vCPU, holding 9 GB of local repository archives and performing 4 cache operations per job. | Line item | Arithmetic | Monthly | | --------------------------------------- | -------------------------- | ------- | | Unit jobs, warp-ubuntu-latest-x64-8x | 14,400 minutes at $0.016 | $230.40 | | Verify jobs, warp-ubuntu-latest-x64-16x | 5,600 minutes at $0.032 | $179.20 | | Cache storage | 9 GB at $0.20 per GB-month | $1.80 | | Cache operations | 8,000 at $0.0001 | $0.80 | | WarpBuild total | $412.20 | | The same minutes on GitHub-hosted larger runners bill at $0.022 for the 8 vCPU shape and $0.042 for the 16 vCPU shape, which is $316.80 plus $235.20, or $552.00\. GitHub publishes those rates on its [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and on the [GitHub pricing page](https://github.com/pricing), both checked on 2026-08-13\. The gap is $139.80 per month, or $1,677.60 per year, on identical machine shapes. Stated as list-price arithmetic per size, with every rate on the [pricing page](/pricing): warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list prices checked on 2026-08-13. Those rows hold minutes constant. Public repositories run Maven on `warp-` labels in the open. [kintsugi-tax/killbill-kintsugi-plugin](https://github.com/kintsugi-tax/killbill-kintsugi-plugin/blob/main/.github/workflows/release.yml) builds a plugin for the Kill Bill billing platform with `mvn -B clean verify` on `warp-ubuntu-latest-x64-2x` in its release workflow, which is the shape most single-module Maven projects land on. ## [FAQ](#faq)Copy Anchor Link **How do I cache the Maven local repository on GitHub Actions?** Use `WarpBuilds/setup-java@v5` with `cache: maven` and `cache-dependency-path` pointed at your `pom.xml` files. Java caching is off by default in that action, so the `cache` input has to be set explicitly. On WarpBuild runners the dependency cache is then served by WarpBuild Cache with no other workflow change. **Which warp- runner size fits a multi-module Maven reactor?** Start on `warp-ubuntu-latest-x64-8x` at $0.016 per minute with `mvn -T 4` and a fixed Surefire `forkCount` of 2\. Move the job to `warp-ubuntu-latest-x64-16x` at $0.032 per minute once the reactor has enough independent modules to keep 8 build threads busy through the compile phase. **Why does mvn -T 1C with forkCount 1C exhaust memory on a runner?** Maven multiplies the two. Eight build threads each forking eight test JVMs is up to 64 JVMs, and each one sizes its default heap from total machine memory. Set `forkCount` to a fixed small number and bound both heaps with `MAVEN_OPTS` and the Surefire `argLine`. **How long does a Maven cache entry survive?** WarpBuild cache entries expire 7 days after their last use. Each entry is scoped to the key, the cache version, and the branch, and the version hash covers the compression tool and the cached paths, so an entry saved on a macOS runner cannot restore on a Linux runner. --- # Monorepo Pipelines on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/monorepo-github-actions Description: Split monorepo GitHub Actions work into a change detection job and per-shape matrix shards on warp- labels sized from 2 to 16 vCPU, with warm snapshot runners. # Monorepo Pipelines on GitHub Actions Split monorepo GitHub Actions work into a change detection job and per-shape matrix shards on warp- labels sized from 2 to 16 vCPU, with warm snapshot runners. Last verified: 2026-08-13 ## [Overview](#overview)Copy Anchor Link Run a monorepo on GitHub Actions by computing the changed projects in one small job, then fanning that list into matrix jobs whose `runs-on` label is sized to the work each job shape actually does. On WarpBuild that means a lint shard on `warp-ubuntu-latest-x64-2x` at $0.004 per minute and an integration shard on `warp-ubuntu-latest-x64-8x` at $0.016 per minute inside the same workflow, with snapshot runners carrying the toolchain and the working tree between runs. The shape most monorepos start with is one job called `build` that installs everything, builds everything, and tests everything. That job has three problems and they compound. Every step waits for the step before it, so wall clock is the sum of the pipeline rather than the width of it. The label has to be large enough for the heaviest step, so the lint phase burns 16 vCPU rates while it reads files. And a change to one package rebuilds all of them, because the job has no notion of which projects the commit touched. The fix is a three stage pipeline. Stage one is a cheap job that diffs the commit range and emits a JSON array of changed project paths. Stage two is a set of matrix jobs, one per shape, each consuming that array and each pinned to a label sized for its shape. Stage three is a single gate job that collects the matrix results so branch protection has one required check to point at. A monorepo that holds a Go service, an iOS app, and a Windows agent can address all three from one workflow file with `warp-` labels. Runner storage is ephemeral on every platform and is deleted when the runner terminates, which is why the caching and snapshot decisions below matter more in a monorepo than in a single project repository. This page owns the tool agnostic shape. Build system specific configuration lives on its own pages: remote cache and action keys on [running Bazel on GitHub Actions](/solutions/bazel-github-actions), base and head SHA selection on [Nx affected builds on GitHub Actions](/solutions/nx-github-actions), and task graph configuration on [moon task pipelines on GitHub Actions](/solutions/moon-github-actions). If you want the runners inside your own account and region, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. The pipeline shape below is identical on BYOC labels except for the snapshot behavior, which is called out where it appears. ## [Configuration](#configuration)Copy Anchor Link The detection job does three things: check out enough history to diff against, map changed files to project directories, and publish those directories as job outputs. Keep it on the smallest label, because it reads git metadata and runs a few string operations. Copy Text ``` name: monorepo on: push: branches: [main] pull_request: concurrency: group: monorepo-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: changed: runs-on: warp-ubuntu-latest-x64-2x outputs: projects: ${{ steps.select.outputs.projects }} services: ${{ steps.select.outputs.services }} steps: - uses: actions/checkout@v5 with: fetch-depth: 0 filter: blob:none - id: select env: BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} run: | to_json() { printf '%s\n' "$1" | jq -R . | jq -sc 'map(select(. != ""))'; } DIFF=$(git diff --name-only "$BASE" "$GITHUB_SHA") PROJECTS=$(printf '%s\n' "$DIFF" | grep -E '^(apps|packages)/' | cut -d/ -f1-2 | sort -u) SERVICES=$(printf '%s\n' "$DIFF" | grep -E '^services/' | cut -d/ -f1-2 | sort -u) { echo "projects=$(to_json "$PROJECTS")" echo "services=$(to_json "$SERVICES")" } >> "$GITHUB_OUTPUT" lint: needs: changed if: needs.changed.outputs.projects != '[]' runs-on: warp-ubuntu-latest-x64-2x strategy: fail-fast: false matrix: project: ${{ fromJSON(needs.changed.outputs.projects) }} steps: - uses: actions/checkout@v5 with: fetch-depth: 1 sparse-checkout: | ${{ matrix.project }} tooling - run: make -C ${{ matrix.project }} lint unit: needs: changed if: needs.changed.outputs.projects != '[]' runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: project: ${{ fromJSON(needs.changed.outputs.projects) }} steps: - uses: actions/checkout@v5 with: fetch-depth: 1 - uses: WarpBuilds/cache@v1 with: path: ~/.cache/toolchain key: toolchain-${{ hashFiles('tooling/versions.lock') }} restore-keys: toolchain- - run: make -C ${{ matrix.project }} test integration: needs: changed if: needs.changed.outputs.services != '[]' runs-on: warp-ubuntu-latest-x64-8x;snapshot.key=mono-integration strategy: fail-fast: false matrix: service: ${{ fromJSON(needs.changed.outputs.services) }} steps: - uses: actions/checkout@v5 - run: make -C ${{ matrix.service }} integration-test build: needs: changed if: needs.changed.outputs.projects != '[]' runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-16x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-16x;snapshot.key=mono-build' }} steps: - uses: actions/checkout@v5 - run: echo "booted from snapshot ${WARPBUILD_SNAPSHOT_KEY:-none}" - run: make build-changed PROJECTS='${{ needs.changed.outputs.projects }}' - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "mono-build" fail-on-error: true wait-timeout-minutes: 60 gate: needs: [lint, unit, integration, build] if: always() runs-on: warp-ubuntu-latest-x64-2x steps: - run: test "${{ contains(needs.*.result, 'failure') }}" = "false" ``` Four things in that file are worth reading closely. The snapshot label syntax is a suffix on the runner label, separated by a semicolon. `snapshot.enabled=true` always boots from the base image and lets `WarpBuilds/snapshot-save@v1` capture a fresh snapshot at the end of the job. `snapshot.key=` boots from the existing snapshot for that alias and falls back to the base image when none exists. A runner booted from a snapshot exports `WARPBUILD_SNAPSHOT_KEY`, which is the cheapest way to confirm the alias resolved. Snapshots are deleted after 15 days, so the `main` branch job doubles as the refresher; add a scheduled trigger if `main` goes quiet for two weeks. The [snapshot runner docs](/docs/ci/features/snapshot-runners) carry the full behavior. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. Snapshot labels on macOS, Windows, or BYOC runners are silently ignored and the job runs normally without snapshot behavior. In a monorepo that also builds an iOS target, that means the macOS half of the pipeline plans around a cache archive and a narrow checkout instead. Aliases are per shape rather than per project. `mono-integration` holds the container images and fixtures that the service tests pull, and `mono-build` holds the compiler caches and the warm working tree. Keying a snapshot per project multiplies restore fees and storage without holding anything the next project needs. The unit shards stay on the base image and restore a cache archive instead, for the break-even reason in the bottlenecks section below. The gate job exists because a matrix that skips has no result to require. Branch protection points at `gate`, which stays green when a matrix is empty and fails when any leg fails. ## [Sizing](#sizing)Copy Anchor Link Size the label to the job shape, not to the repository. The four shapes below cover most monorepo pipelines, and the rates come from the [cloud runner catalog](/docs/ci/cloud-runners). | Job shape | What it does | Label | vCPU | RAM | Storage | USD per minute | | ------------------ | ------------------------------------- | -------------------------- | ---- | ------ | --------- | -------------- | | Change detection | git diff, path mapping, matrix emit | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | Lint and typecheck | one project per shard, IO bound | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | Unit tests | one project per shard, CPU bound | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | Integration tests | containers, database, fixtures | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | Build and package | full compile and link across projects | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | Release build | whole graph on a tag, no fan-out | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Every Ubuntu size carries 4 GB of RAM per vCPU, which is the number that decides where a shape lands. Typecheck and lint hold one project's syntax tree, so they rarely exceed 8 GB. Unit shards run test workers in parallel and are bounded by cores. Integration shards run the service under test plus its dependencies as containers, and memory is what pushes them from the 4x to the 8x label. A link step over a large native graph is the one shape that genuinely wants 16 or 32 vCPU, and it is also the shape that runs once per pipeline instead of once per project. Linux ARM64 carries the same size ladder from 2x through 32x at $0.003 to $0.048 per minute. Pick one architecture per pipeline stage and stay on it, because cache entries and native modules do not cross architectures. The list prices below are the arithmetic against GitHub-hosted Linux x64 runners at the same shape, checked on 2026-08-13 against the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). | vCPU | WarpBuild label | WarpBuild USD per minute | GitHub-hosted USD per minute | Lower list price | | ---- | -------------------------- | ------------------------ | ---------------------------- | ---------------- | | 2 | warp-ubuntu-latest-x64-2x | $0.004 | $0.006 | 33 percent | | 4 | warp-ubuntu-latest-x64-4x | $0.008 | $0.012 | 33 percent | | 8 | warp-ubuntu-latest-x64-8x | $0.016 | $0.022 | 27 percent | | 16 | warp-ubuntu-latest-x64-16x | $0.032 | $0.042 | 24 percent | | 32 | warp-ubuntu-latest-x64-32x | $0.064 | $0.082 | 22 percent | The 2 vCPU row compares against `ubuntu-latest` on private repositories, which is the shape paying teams run. GitHub gives public repositories a 4 vCPU, 16 GB shape at no charge. The full ladder including macOS and Windows sits on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Full checkout on every shard.** A fan-out that turns one job into thirty multiplies the clone thirty times. On a repository with several years of history and large binary fixtures, that is often the single largest line in the shard budget. Three levers cut it: `fetch-depth: 1` on every job that does not compute a diff, `filter: blob:none` on the detection job that does, and `sparse-checkout` on shards that only touch one project directory plus shared tooling. A snapshot alias helps further, because the runner boots with a warm `.git` directory and the checkout becomes a fetch of the new commits. The mechanics of each lever are written up in [faster git checkout on GitHub Actions](/guides/faster-git-checkout-github-actions). **Cache key churn across projects.** The default monorepo cache key hashes a root lockfile, so a dependency bump in one package invalidates the archive for every package. Every shard then misses, reinstalls, and writes a new archive, which multiplies both restore time and cache operations. Build keys from the narrowest input that actually changes the content: the toolchain version plus the lockfile section for that project. Add `restore-keys` prefixes so a near miss restores a slightly stale archive instead of nothing. Keep the dependency store cache separate from build output caches, because the two churn on different schedules. Cache storage is billed at $0.20 per GB-month and cache write or restore operations at $0.0001 each, so thirty shards writing their own archive is a real line item rather than a rounding error. **Jobs that serialize behind a single build step.** The common shape is a `build` job that produces artifacts, followed by test jobs with `needs: build`. The whole test stage then waits for the slowest project in the build, even when the tests it runs need one of them. Two fixes work. Publish per project artifacts so a test shard downloads only what it consumes. Or let each shard build its own dependencies from the shared cache, which trades a little duplicated compute for a much shorter critical path. Measure before choosing: if the build stage is two minutes and the artifact upload is four, the artifact is the problem. **Runner size mismatch inside the fan-out.** A shard pinned to a large label that spends its time waiting on the network costs the large rate for the whole wall clock. CI observability reports system metrics from the runner agent correlated with the job logs, which tells you whether a shard was CPU bound, memory bound, or idle on IO. That distinction is what decides whether a shape moves up a label or down one. When a failure reproduces only on the runner, the Action Debugger pauses the workflow and opens an SSH session on the machine so you can inspect the working tree directly. **Snapshot boot overhead.** Boot times for snapshot runners can be slower than the default runners and take 45 to 60 seconds, and snapshot restore is billed at $0.04 per job. Both are fixed costs paid before the first step runs. On a `warp-ubuntu-latest-x64-8x` label at $0.016 per minute, the $0.04 restore fee equals 150 seconds of runner time, so the alias pays off only when it removes more than that. Run the same arithmetic on a 4 vCPU unit shard at $0.008 per minute and the fee equals 300 seconds, which is longer than the shard itself, so unit shards belong on the base image with a cache archive. Apply snapshots to the heavy shapes where install and image pull dominate, and leave the small shards alone. ## [Proof](#proof)Copy Anchor Link Public OSS repositories running `warp-` labels are citable evidence for this shape. The [near/nearcore CI workflow](https://github.com/near/nearcore/blob/master/.github/workflows/ci.yml) runs the NEAR protocol node pipeline across two different sizes in the same file, using `warp-ubuntu-2404-x64-16x` for the heavy compile and test legs and `warp-ubuntu-2404-x64-8x` for the lighter ones, which is the per shape sizing described above (checked on 2026-08-13). Verify the shape on your own repository with two numbers from the workflow run view. The first is the critical path: the longest single chain from the detection job to the gate. The second is total billable minutes across every job. A healthy fan-out drives the first number down while the second rises modestly. When both rise, a shard is oversized or the matrix is selecting projects the commit did not touch. ### [A worked monthly cost model](#a-worked-monthly-cost-model)Copy Anchor Link Take a monorepo running 900 pipelines per month across pull requests and `main`. A typical commit touches four projects and two services. The shard durations below are measured on the label each shape is assigned. | Stage | Label | USD per minute | Shards per run | Minutes per shard | Monthly minutes | Monthly USD | | ------------------ | -------------------------- | ------------------------ | -------------- | ----------------- | --------------- | ----------- | | Change detection | warp-ubuntu-latest-x64-2x | $0.004 | 1 | 1 | 900 | $3.60 | | Lint and typecheck | warp-ubuntu-latest-x64-2x | $0.004 | 4 | 2 | 7,200 | $28.80 | | Unit tests | warp-ubuntu-latest-x64-4x | $0.008 | 4 | 5 | 18,000 | $144.00 | | Integration tests | warp-ubuntu-latest-x64-8x | $0.016 | 2 | 9 | 16,200 | $259.20 | | Build and package | warp-ubuntu-latest-x64-16x | $0.032 | 1 | 7 | 6,300 | $201.60 | | Snapshot restore | integration and build | $0.04 per job | 3 | 2,700 jobs | $108.00 | | | Snapshot storage | 2 aliases | $0.025 per snapshot-hour | 1,460 hours | $36.50 | | | | Cache storage | unit shard toolchain | $0.20 per GB-month | 60 GB | $12.00 | | | | Cache operations | unit shard toolchain | $0.0001 per operation | 27,000 | $2.70 | | | | **Total** | **48,600 minutes** | **$796.40** | | | | | Three readings come out of that table. Runner minutes alone are $637.20\. The same pipeline collapsed into one serialized job on `warp-ubuntu-latest-x64-16x` at 22 minutes per run is 19,800 minutes for $633.60, so the fan-out buys a critical path of about 10 minutes instead of 22 for roughly the same money. Fan-out trades total minutes for wall clock, and per shape sizing is what keeps the bill flat while it does. Running the identical fan-out entirely on the 16x label costs 48,600 minutes at $0.032, which is $1,555.20\. The $918.00 difference against the sized version is the whole argument for reading the sizing table before copying a `runs-on` line between jobs. The same 48,600 minutes on GitHub-hosted Linux x64 runners at matching shapes costs $885.60: 8,100 minutes at $0.006, 18,000 at $0.012, 16,200 at $0.022, and 6,300 at $0.042\. GitHub list prices checked on 2026-08-13 at the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). GitHub-hosted runners have no snapshot equivalent to fold into the comparison. SSO is available for a flat $250 per month, whatever the user count, which is the one org level line that does not scale with the pipeline. Every rate above is listed on the [pricing page](/pricing). ## [FAQ](#faq)Copy Anchor Link ### [How should a monorepo split jobs across runner sizes?](#how-should-a-monorepo-split-jobs-across-runner-sizes)Copy Anchor Link Size each job shape to the work it does rather than to the repository. Lint and typecheck shards fit `warp-ubuntu-latest-x64-2x` at $0.004 per minute, unit shards fit the 4x label at $0.008, integration shards that start containers fit the 8x label at $0.016, and a full build or link step fits the 16x label at $0.032\. A single label for every shape means you pay the largest rate for the smallest work. ### [Do snapshot runners work for every job in a monorepo pipeline?](#do-snapshot-runners-work-for-every-job-in-a-monorepo-pipeline)Copy Anchor Link No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. Snapshot labels on macOS, Windows, or BYOC runners are silently ignored and the job runs normally, so plan those jobs around a cache archive and a narrow checkout instead. ### [Why does the dependency cache miss on every pull request in a monorepo?](#why-does-the-dependency-cache-miss-on-every-pull-request-in-a-monorepo)Copy Anchor Link Because the key is usually built from a root lockfile hash, so any dependency change in any project invalidates the archive for every project. Build keys per toolchain and per project, add `restore-keys` prefixes so a near miss still restores, and keep the dependency store cache separate from build output caches. ### [Does fanning out across matrix jobs cost more than one large job?](#does-fanning-out-across-matrix-jobs-cost-more-than-one-large-job)Copy Anchor Link It costs more runner minutes and about the same money when the labels are sized per shape. In the worked model on this page, 900 pipelines per month run 19,800 minutes as one 16 vCPU job for $633.60, or 48,600 minutes fanned across four sized labels for $637.20 in runner minutes with a much shorter critical path. Running that same fan-out entirely on the 16x label costs $1,555.20. --- # moon Monorepo Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/moon-github-actions Description: moon ci selects tasks by hash across changed projects. Cache .moon/cache and the proto toolchain, shard with --job, and size warp- runners per shard. # moon Monorepo Builds on GitHub Actions moon ci selects tasks by hash across changed projects. Cache .moon/cache and the proto toolchain, shard with --job, and size warp- runners per shard. Last verified: 2026-08-13 ## [Overview](#overview)Copy Anchor Link To run moon on GitHub Actions, point `runs-on` at a `warp-` label, let `moon ci` select the affected task subset against a base ref, and persist two directories between runs: `~/.proto` for the toolchain and `.moon/cache` for task hashes, states, and outputs. Those two directories decide most of a moon pipeline's wall clock, because a cold toolchain install and a cold hash store both scale with the size of the repository rather than with the size of the change. moon builds a task graph from four configuration layers. `.moon/workspace.yml` lists the projects, `.moon/toolchain.yml` pins the language versions that proto installs, `.moon/tasks.yml` holds the task definitions every project inherits, and each project's own `moon.yml` adds or overrides tasks locally. A task carries a command, an `inputs` list, an `outputs` list, and a `deps` list pointing at other tasks. Every task run starts with a hash. moon folds in the resolved command and arguments, the contents of the files matched by `inputs`, the values of any environment variables declared as inputs, the hashes of every task named in `deps`, and the toolchain versions the task runs under. That hash addresses an entry under `.moon/cache`. A hit replays the stored outputs and logs. A miss runs the command and writes a new entry. `moon ci` adds two behaviors on top of `moon run`. It diffs the working tree against a base ref to find touched files, then narrows the task list to the projects those files affect, so a one-package change stops short of walking the whole graph. It also shards: `--job` and `--jobTotal` split the resulting task list deterministically across parallel GitHub Actions jobs, and every shard computes the same list before taking its slice. moon work belongs on the Linux runners. The sizes reach 32 vCPU, and [WarpBuild Cache](/docs/ci/features/caching) is enabled by default on Linux runners while Windows runners do not support it. ## [Configuration](#configuration)Copy Anchor Link Start with the toolchain. `.moon/toolchain.yml` is what proto reads when it installs into `~/.proto`, and its contents are the right cache key for that directory: Copy Text ``` # .moon/toolchain.yml node: version: '22.14.0' packageManager: 'pnpm' pnpm: version: '9.15.0' addEnginesConstraint: true ``` Then a project. This one declares narrow inputs so unrelated churn stays out of the hash: Copy Text ``` # packages/api/moon.yml type: 'library' language: 'typescript' dependsOn: - 'shared' tasks: build: command: 'tsc --build' inputs: - 'src/**/*' - 'tsconfig.json' outputs: - 'dist' deps: - '^:build' test: command: 'vitest run' inputs: - 'src/**/*' - 'tests/**/*' - 'vitest.config.ts' deps: - '~:build' lint: command: 'eslint src' inputs: - 'src/**/*' - '.eslintrc.cjs' ``` `^:build` means the same task in every project this one depends on. `~:build` means another task in this project. Both edges enter the hash, so a change in `shared` moves the hash of everything downstream of it. Now the workflow. Four shards on 8 vCPU runners, with both warm directories restored before moon starts: Copy Text ``` name: ci on: push: branches: [main] pull_request: jobs: ci: runs-on: warp-ubuntu-latest-x64-8x strategy: fail-fast: false matrix: index: [0, 1, 2, 3] steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Restore the proto toolchain uses: WarpBuilds/cache@v1 with: path: ~/.proto key: ${{ runner.os }}-proto-${{ hashFiles('.moon/toolchain.yml') }} restore-keys: | ${{ runner.os }}-proto- - name: Restore the moon cache uses: WarpBuilds/cache/restore@v1 with: path: .moon/cache key: ${{ runner.os }}-moon-${{ github.sha }} restore-keys: | ${{ runner.os }}-moon- - uses: moonrepo/setup-toolchain@v0 with: auto-install: true - name: Run the affected tasks for this shard run: >- moon ci --base origin/${{ github.base_ref || 'main' }} --job ${{ matrix.index }} --jobTotal ${{ strategy.job-total }} - name: Save the moon cache if: matrix.index == 0 uses: WarpBuilds/cache/save@v1 with: path: .moon/cache key: ${{ runner.os }}-moon-${{ github.sha }} ``` Five details in that file earn their place. `fetch-depth: 0` gives moon the history it needs. `moon ci` resolves `--base` to a real commit and diffs against it, and a shallow clone leaves that commit missing. The toolchain key hashes `.moon/toolchain.yml`, because `~/.proto` changes only when a pinned version changes. The `restore-keys` prefix still catches the previous toolchain after a version bump, so the install becomes incremental instead of complete. The moon key uses the commit SHA with a prefix fallback, because `.moon/cache` changes on every commit and the most recent entry is the one worth having. The prefix is what turns a per-commit key into a rolling warm cache. Only shard 0 saves. Four shards writing one key produce three collisions and three wasted uploads, and each shard holds a different slice of the outputs anyway. Restoring everywhere and saving once keeps the operation count down. `--jobTotal` reads `strategy.job-total`, so the shard count lives in one place. Change the matrix length and moon follows. ### [When the toolchain install dominates the run](#when-the-toolchain-install-dominates-the-run)Copy Anchor Link A cache action moves the toolchain over the network on every job. [Snapshot runners](/docs/ci/features/snapshot-runners) capture the runner disk mid-workflow instead, so `~/.proto`, `node_modules`, and `.moon/cache` sit on disk when the job boots: Copy Text ``` jobs: ci: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=moon-warm' }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 clean: false - uses: moonrepo/setup-toolchain@v0 with: auto-install: true - run: moon ci --base origin/main - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: rm -rf $HOME/.ssh $HOME/.aws - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "moon-warm" fail-on-error: true wait-timeout-minutes: 60 ``` Four constraints shape where this works. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. A snapshot label on a BYOC, Windows, or macOS runner is silently ignored, and the job runs normally without snapshot behavior. `clean: false` matters more for moon than for most tools, because `.moon/cache` sits inside the workspace and git ignores it. A default checkout runs `git clean -ffdx` and deletes the cache the snapshot just carried in. For the same reason, scope the pre-snapshot cleanup to credentials rather than running a blanket clean. Snapshots are deleted after 15 days, and `/tmp` does not survive a snapshot boot because that directory is cleaned on reboot. Booting from a snapshot takes 45 to 60 seconds and can be slower than starting a default runner. Subtract that from whatever the snapshot saves before comparing it against the cache action. ## [Sizing](#sizing)Copy Anchor Link WarpBuild Linux x64 runners hold 4GB of memory per core across the range. These are the sizes a moon shard uses: | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 | Shard count and runner size are one decision, because `moon ci --jobTotal N` gives you N full runners billed in parallel. Four shards on `warp-ubuntu-latest-x64-8x` and two shards on `warp-ubuntu-latest-x64-16x` buy the same 32 vCPU at the same $0.064 per minute of wall clock. The tiebreaker is the fixed cost each shard pays before moon runs a task: a checkout with full history, a toolchain restore, and a `.moon/cache` restore. Doubling the shard count doubles that overhead. Wide sharding stops paying once the per-shard fixed cost approaches the per-shard task time. Which size a given shard wants comes from what its tasks do. **Shards dominated by single-threaded tasks.** `tsc --build`, `eslint`, and most codegen occupy one core each. moon derives its parallelism bound from the cores it sees, and the global `--concurrency` option overrides that bound. Cores are the constraint here, so 8 or 16 vCPU per shard converts directly into throughput. **Shards dominated by test runners.** Vitest and Jest fan out into their own worker pools. Several of those in flight on an 8 vCPU shard produce dozens of workers contending for 8 cores and 32GB. Lower moon's concurrency and cap the inner tool with `vitest run --maxWorkers=2`, so the total worker count stays near the core count. **Shards with deep `tsc --build` chains.** Memory decides these more often than cores do. A large TypeScript project graph can hold several gigabytes per process, and 64GB on `warp-ubuntu-latest-x64-16x` is what keeps a wide fan-out alive after `warp-ubuntu-latest-x64-8x` starts throwing heap errors. Linux ARM64 runners carry the same shapes at lower rates, so `warp-ubuntu-latest-arm64-8x` costs $0.012 per minute against $0.016 for `warp-ubuntu-latest-x64-8x`. That swap works when every pinned toolchain and native dependency in `.moon/toolchain.yml` has an arm64 build. ### [What the warm directories cost](#what-the-warm-directories-cost)Copy Anchor Link Cache and snapshot usage are metered separately from runner minutes: | Metric | Rate | | ----------------------------- | ------------------------ | | Cache storage | $0.20 per GB-month | | Cache write, restore, or list | $0.0001 per operation | | Snapshot restore | $0.04 per job | | Snapshot storage | $0.025 per snapshot-hour | ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners in the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, those prices against the matching WarpBuild sizes are: | Shape | WarpBuild label | WarpBuild rate | GitHub-hosted rate | Lower by | | -------------- | -------------------------- | -------------- | -------------------------------------------- | ---------- | | 2 vCPU, 8GB | warp-ubuntu-latest-x64-2x | $0.004 | $0.006 (ubuntu-latest, private repositories) | 33 percent | | 4 vCPU, 16GB | warp-ubuntu-latest-x64-4x | $0.008 | $0.012 (4-core larger runner) | 33 percent | | 8 vCPU, 32GB | warp-ubuntu-latest-x64-8x | $0.016 | $0.022 (8-core larger runner) | 27 percent | | 16 vCPU, 64GB | warp-ubuntu-latest-x64-16x | $0.032 | $0.042 (16-core larger runner) | 24 percent | | 32 vCPU, 128GB | warp-ubuntu-latest-x64-32x | $0.064 | $0.082 (32-core larger runner) | 22 percent | Take a moon repository with 60 projects running 900 pull request and push workflows a month, sharded four ways on 8 vCPU machines. With no warm directories, each shard installs the toolchain and rebuilds every affected hash in 12 minutes, so a run costs 48 job-minutes. With `~/.proto` and `.moon/cache` restored, `moon ci` replays most of the graph and each shard finishes in 5 minutes, so a run costs 20 job-minutes. | Scenario | Rate | Monthly minutes | Monthly cost | | ------------------------------- | ---------- | --------------- | ------------ | | GitHub-hosted 8 vCPU, cold | $0.022/min | 43,200 | $950.40 | | GitHub-hosted 8 vCPU, warm | $0.022/min | 18,000 | $396.00 | | warp-ubuntu-latest-x64-8x, cold | $0.016/min | 43,200 | $691.20 | | warp-ubuntu-latest-x64-8x, warm | $0.016/min | 18,000 | $288.00 | Add the cache layer. A 6GB combined toolchain and moon cache costs 6 x $0.20 = $1.20 per month in storage. The workflow runs 3,600 shard jobs a month. The toolchain entry restores and saves on each of them, 7,200 operations. The moon entry restores on each of them and saves only from shard 0, another 3,600 plus 900, or 4,500 operations. Together that is 11,700 operations at $0.0001, or $1.17\. The cache layer for this workload comes to $2.37 a month against $288.00 of runner time. Now price the snapshot alternative for the same 3,600 shard jobs: 3,600 restores at $0.04 is $144.00, plus one live snapshot held for a 730-hour month at $0.025 per snapshot-hour, or $18.25\. Every shard pays its own restore, so sharding multiplies that fee while the cache operations stay nearly flat. The snapshot earns its place when the toolchain install is the dominant cost. At $0.016 per minute, the $0.04 restore buys 2.5 minutes of runner time, so a 4-minute install replaced by a 1-minute snapshot boot saves 3 minutes worth $0.048 and clears the fee by a small margin. On `warp-ubuntu-latest-x64-16x` at $0.032 per minute the same 3 minutes is worth $0.096 and the margin is comfortable. Every cost and performance number on this page carries its source and a checked-on date. Every rate for every size and platform is listed on the [WarpBuild pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Cold toolchain installs.** proto downloads and installs every version pinned in `.moon/toolchain.yml` on a machine that has never seen the repository. A repository pinning Node, a package manager, and a second language pays that install on all four shards, in parallel, on every run. This is the cost the `~/.proto` cache step and the snapshot runner both target, and it is the one that grows when a team adds a language rather than when it adds code. **Hash misses on shared project inputs.** moon adds a set of implicit inputs to every task in the workspace: the configuration under `.moon/`, the root manifest, and the lockfile. Touch any of them and every hash in the repository moves, so a lockfile bump turns an affected-only `moon ci` run into a full rebuild. The related trap is per-project: a task with no `inputs` list inherits everything git tracks in the project, so editing a README invalidates its `build`. Declaring `inputs` explicitly, as in the `moon.yml` above, keeps documentation and configuration churn out of the hash. The opposite mistake is worse. An `inputs` list that omits a file the task actually reads produces a hit that replays stale output. **Serialized dependency chains.** `deps` edges are ordering constraints, and moon respects them regardless of how many cores a shard has. A chain of five projects each depending on the previous one runs five tasks back to back on a 16 vCPU machine with 15 cores idle. Depth in the dependency graph, rather than task count, is what caps a shard's parallelism. The fixes are structural: flatten `dependsOn` where a project imports from a grandparent instead of a parent, split large aggregator projects that everything depends on, and check the task options that control whether dependencies run in parallel before assuming the graph is the limit. A shard whose runtime stays flat as you move from 8 to 16 vCPU is telling you it is chain-bound rather than core-bound. **Checkout wiping the warm state.** `actions/checkout` defaults `clean` to true, which runs `git clean -ffdx` and removes every ignored file in the workspace. `.moon/cache` and `node_modules` are both ignored, so the default checkout deletes exactly the state a snapshot runner booted with. Set `clean: false` on snapshot jobs. **Shallow clones breaking task selection.** Without full history, `moon ci` cannot resolve `--base` to a commit it can diff against. Depending on the version and the ref, the run either fails outright or falls back to treating everything as affected, which reads as a mysteriously slow pipeline rather than an error. For the runs where none of this is obvious, WarpBuild ships CI observability, which correlates OpenTelemetry-based system metrics from the runner agent with GitHub Actions job logs, so a shard starved of memory at high concurrency looks different from one stalled on a dependency chain. The Action Debugger pauses a workflow and opens an SSH session on the runner, which is the fastest way to inspect `.moon/cache` and the restored `~/.proto` on the machine that produced them. ## [Proof](#proof)Copy Anchor Link Moving a moon pipeline onto WarpBuild changes the `runs-on` label and the two cache steps. Nothing in `.moon/` or in any project `moon.yml` moves, because moon's hashing and task selection are unaware of which runner they land on. If the monorepo has to build inside your own cloud account for data residency or policy reasons, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. The tradeoff for this page specifically: snapshot runners are unavailable on BYOC, so a BYOC moon repository keeps the two cache steps and skips the snapshot layer. SSO is available for a flat $250 per month, whatever the user count. Three neighboring pages cover adjacent decisions. [Monorepo pipelines on GitHub Actions](/solutions/monorepo-github-actions) covers the sharding and caching pattern independent of build tool, [Turborepo cache configuration on GitHub Actions](/solutions/turborepo-github-actions) walks the same two-layer caching for a Turborepo graph, and [Pants builds on GitHub Actions](/solutions/pants-github-actions) covers the equivalent for a Pants workspace. The runner-side detail behind the snapshot workflow above lives on the [snapshot runners](/runners/snapshot) page. ## [FAQ](#faq)Copy Anchor Link **Does moon ci need the full git history?** Yes. `moon ci` resolves `--base` to a real commit and diffs the working tree against it to find touched files. Set `fetch-depth: 0` on `actions/checkout`. A shallow clone leaves the base commit missing, so the diff either fails or widens the affected set to the whole repository. **Which directories does a moon job need to keep warm?** Two. The proto toolchain at `~/.proto`, which holds the language versions pinned in `.moon/toolchain.yml`, and `.moon/cache`, which holds task hashes, states, and cached outputs. The package manager store is a third if installs are slow. **How should the .moon/cache key work across sharded jobs?** Key on the commit SHA with a `restore-keys` prefix, restore on every shard, and save from one shard only. Four shards writing the same key produce collisions and wasted uploads, and each shard holds a different slice of the outputs. **Do snapshot runners work for moon jobs on macOS or Windows?** No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. Snapshot labels on macOS, Windows, or BYOC runners are silently ignored and the job runs normally without snapshot behavior. --- # Next.js Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/nextjs-github-actions Description: Restore .next/cache beside the package manager store, key it on the lockfile plus a source hash, and size warp- runners from 4 to 16 vCPU for next build. # Next.js Builds on GitHub Actions Restore .next/cache beside the package manager store, key it on the lockfile plus a source hash, and size warp- runners from 4 to 16 vCPU for next build. Last verified: 2026-08-13 To make `next build` fast on GitHub Actions, restore `.next/cache` and the package manager store at the start of the job, and put the build on a runner with enough cores to keep the compiler and the prerender workers busy. On WarpBuild runners both halves are one-line changes: [WarpBuilds/cache](https://github.com/WarpBuilds/cache) is a drop-in replacement for `actions/cache@v4`, and `runs-on` picks a warp- label from 2 to 32 vCPU. This page covers the workflow configuration for the two caches a Next.js build depends on, the cache key design that survives dependency churn, the sizing call between 4, 8, and 16 vCPU runners, the three bottlenecks that dominate Next.js pipelines, and a worked cost model for a build-and-preview pipeline against GitHub-hosted runner list prices. ## [Overview](#overview)Copy Anchor Link Every GitHub Actions job starts on a fresh virtual machine, so `.next/cache` is empty unless a step puts it there. An empty `.next/cache` means `next build` recompiles every module in the app, even when the pull request touched one route file. Three things live under `.next/cache` and all three matter. The compiler cache holds the transformed output and module graph metadata from the previous build, which is what lets an incremental build skip modules whose inputs did not move. The lint result cache sits there when ESLint runs as part of the build. Optimized image variants land in `.next/cache/images` when the server renders them. A Next.js build has three phases with different resource shapes, and confusing them is how teams buy the wrong runner. **Compile** transforms and bundles every module that the routes reach. This phase parallelizes across cores, and it is the phase the restored `.next/cache` shortens. **Type check** runs TypeScript over the whole program in one single threaded pass. Cores do nothing for it. Heap size does. **Prerender** renders every static route and every statically generated dynamic route. Next.js runs these in a pool of worker processes whose size follows the machine CPU count, and you can pin it with `experimental.cpus`. Each worker executes the route's own data fetching, so this phase is bounded by cores when rendering is CPU heavy and by network latency when routes fan out to an upstream API. Next.js builds belong on the Linux runners, both because the sizes run up to 32 vCPU and because [WarpBuild Cache](/docs/ci/features/caching) is enabled by default on Linux runners and is not supported on Windows runners. One boundary before the configuration. This page covers a single Next.js application with one lockfile and one build command. If the app is one package inside a task graph, output caching across packages is a different problem, covered on the [Turborepo on GitHub Actions](/solutions/turborepo-github-actions) page. ## [Configuration](#configuration)Copy Anchor Link The workflow below installs with pnpm, restores `.next/cache`, and builds on an 8 vCPU WarpBuild runner. The package manager store is handled by [WarpBuilds/setup-node](https://github.com/WarpBuilds/setup-node), a drop-in replacement for `actions/setup-node` that routes dependency caching through WarpBuild Cache. Copy Text ``` name: nextjs-build on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Restore Next.js build cache uses: WarpBuilds/cache@v1 with: path: .next/cache key: next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('app/**', 'src/**', 'next.config.*') }} restore-keys: | next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- next-${{ runner.os }}- - run: pnpm exec next build ``` The key has two hashed segments and the restore ladder has two rungs, and that shape is what keeps the build cache useful through dependency churn. The exact key hits only when the lockfile and the source tree are both byte identical to a previous run, which happens on a rerun or a docs-only change. The first rung drops the source hash and keeps the lockfile hash, so an ordinary code change restores the compiler cache from the last build on the same dependency set and recompiles only what moved. The second rung drops both, so the run after a dependency bump still starts from a warm compiler cache instead of a cold one. Without that second rung, every automated dependency update pull request pays a full recompile, which is exactly the run where the cache is worth the most. Three behaviors from the [caching documentation](/docs/ci/features/caching) shape how this performs in practice. The cache is scoped to key, version, and branch. A cache saved on a feature branch is separate from the one on `main`, so seed the cache by running the build on `main` and let branch builds reach it through `restore-keys`. The version is a hash over the compression tool and the list of cached paths. Two consequences follow: a cache saved on a macOS runner cannot restore on a Linux runner, and editing the `path` list produces a new version that ignores every existing entry. Change the paths deliberately and expect one cold build after you do. Entries expire after 7 days of last use. An active repository stays warm on its own, and a branch nobody has touched for a week stops costing storage without any cleanup step. Cache usage is metered on hosted runners at $0.20 per GB-month of storage and $0.0001 per write or restore operation, and it is included at no charge on BYOC runners. A Next.js repository holding a 2.5GB combined store and build cache with 4,000 operations a month adds about $0.90 to the bill. The second piece of configuration is splitting type checking out of the build. Set `typescript.ignoreBuildErrors` and `eslint.ignoreDuringBuilds` in `next.config`, then run both checks as their own job on a smaller runner in parallel with the build: Copy Text ``` typecheck: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm exec tsc --noEmit - run: pnpm exec eslint . ``` The pull request still fails when types break, because the job still fails. What changes is that a single threaded `tsc` pass no longer sits inside the wall time of a build you are paying 8 cores for. ## [Sizing](#sizing)Copy Anchor Link WarpBuild Linux x64 runners scale from 2 to 32 vCPU with 4GB of memory per core. Three sizes cover almost every Next.js application: | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | Two inputs decide the size: how many routes the build prerenders, and whether type checking runs inside `next build`. **warp-ubuntu-latest-x64-4x** fits an app under roughly 100 routes with type checking split into its own job. The compile phase saturates 4 cores on an app this size, the prerender pool has few enough routes that more workers finish sooner than the pool can start them, and 16GB carries the Node heap through the module graph. **warp-ubuntu-latest-x64-8x** is the default when either input grows. An app prerendering a few hundred routes keeps 8 prerender workers busy, and an app that still runs type checking inside the build needs the memory headroom more than the cores, because the type graph of a large program is what pushes the process past the default heap. If `next build` is dying with an out-of-memory error on 4x, move to 8x for the 32GB before reaching for `NODE_OPTIONS=--max-old-space-size` on a machine that cannot back the number you write. **warp-ubuntu-latest-x64-16x** earns its rate above roughly 1,000 prerendered routes, or when the app runs `output: 'export'` and every route is rendered at build time. Watch for the ceiling: if the prerender phase is waiting on an upstream API rather than on CPU, 16 workers make 16 concurrent callers of that API and the wall time stops improving. Check the utilization before buying the cores. A calibration step worth one run: build once on 8x and watch per-step utilization. If only the compile and prerender phases hold all 8 cores, the pipeline above is already shaped correctly, with the 4x job carrying the checks. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners on the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), and the runner shapes on the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Checked on 2026-08-13, the Linux larger runners are $0.012 per minute for 4 vCPU, $0.022 for 8 vCPU, and $0.042 for 16 vCPU. | Runner label | vCPU | WarpBuild rate | GitHub larger runner rate | Lower by | | -------------------------- | ---- | -------------- | ------------------------- | ---------- | | warp-ubuntu-latest-x64-4x | 4 | $0.008/min | $0.012/min | 33 percent | | warp-ubuntu-latest-x64-8x | 8 | $0.016/min | $0.022/min | 27 percent | | warp-ubuntu-latest-x64-16x | 16 | $0.032/min | $0.042/min | 24 percent | Take the pipeline on this page at a stated volume: 1,000 build-and-preview runs a month, where each run is a 6 minute build job on 8 vCPU and a 3 minute preview deploy job on 4 vCPU. That is 6,000 minutes at the 8 vCPU rate and 3,000 minutes at the 4 vCPU rate. | Line item | Rate | Monthly minutes | Monthly cost | | ----------------------------------------------- | ---------- | --------------- | ------------ | | Build job, GitHub-hosted 8 vCPU | $0.022/min | 6,000 | $132.00 | | Preview job, GitHub-hosted 4 vCPU | $0.012/min | 3,000 | $36.00 | | GitHub-hosted total | 9,000 | $168.00 | | | Build job, warp-ubuntu-latest-x64-8x | $0.016/min | 6,000 | $96.00 | | Preview job, warp-ubuntu-latest-x64-4x | $0.008/min | 3,000 | $24.00 | | Cache storage and operations (2.5GB, 4,000 ops) | see above | n/a | $0.90 | | WarpBuild total | 9,000 | $120.90 | | The difference on this pipeline is $47.10 a month, or 28 percent below the GitHub-hosted list price for the same two shapes. Stated per size: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), which is 27 percent lower list price. GitHub list price checked on 2026-08-13. Full rates for every size and platform are on the [pricing page](/pricing). If the preview environments need to sit inside your own cloud account, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. ## [Bottlenecks](#bottlenecks)Copy Anchor Link Three bottlenecks account for most slow Next.js pipelines on GitHub Actions. **A cold `.next/cache` forcing a full recompile.** Without a restored cache, every build transforms every module the routes reach, and the wall time tracks the size of the app rather than the size of the change. This is the single largest lever on a Next.js build and the reason the restore step comes before `next build` in the workflow above. Verify it from the build log: a warm build reports far fewer compiled modules than a cold one, and the compile phase finishes in a fraction of the cold time. If the cache restores but the build still recompiles everything, the usual cause is a key whose source hash pattern covers generated files that change on every run, which turns every key into a fresh one. **Type checking serialized into the build.** `next build` type checks the program by default, in one single threaded pass, and that pass sits inside the build's wall time. Adding cores does nothing to it, so an app that spends four minutes compiling and three minutes type checking gets far less from a 16x runner than the utilization graph suggests. Move it to the parallel job shown in the configuration section. The check still gates the merge, and the build job stops paying for it. **Image work at build time.** Statically imported images with `placeholder="blur"` make the build generate a blur placeholder for each one through sharp, and that work repeats on every cold build. Optimized variants written by the image optimizer land in `.next/cache/images`, which the cache step above already carries, so a warm build skips both. When the app has hundreds of static image imports, this phase becomes visible in the build log and the fix is the same restored cache rather than a larger runner. Apps that build with `output: 'export'` pay the most here, because every route and every image variant is produced at build time instead of on demand. When a build is slow and the phase responsible is unclear, WarpBuild's CI observability shows OpenTelemetry-based system metrics from the runner agent correlated with GitHub Actions job logs, which separates a CPU-bound compile from a prerender phase waiting on an upstream API. For interactive work, the Action Debugger pauses the workflow and opens an SSH session on the runner, so you can inspect the restored `.next/cache` directory on the machine itself. Workflow-level tactics beyond the build itself are collected in the guide on [speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Proof](#proof)Copy Anchor Link Every cost number on this page is list-price arithmetic against GitHub's published rates, with the source linked and the date stated, so you can re-run it yourself. Public repositories running warp- labels are citable evidence, and reading a `runs-on` line takes a few seconds. The Trigger.dev TypeScript monorepo runs its end-to-end matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x`, which you can read in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml) (checked on 2026-08-13). The full runner matrix, including the Linux ARM64 sizes that run the same builds at lower per-minute rates, is documented under [cloud runners](/docs/ci/cloud-runners). If the same repository also installs dependencies for other jobs, the store configuration is covered on the [Node.js on GitHub Actions](/solutions/nodejs-github-actions) and [pnpm on GitHub Actions](/solutions/pnpm-github-actions) pages. ## [FAQ](#faq)Copy Anchor Link **Should I cache .next/cache or node\_modules?** Cache `.next/cache` and the package manager store. `node_modules` is rebuilt by the install command and is welded to the Node version that produced it, while `.next/cache` holds the compiler output that decides whether `next build` recompiles one route or the whole app. **What should the .next/cache key hash?** The lockfile plus a hash of the source directories, with a `restore-keys` ladder underneath. The exact key gives a byte-identical cache when nothing changed, and the lockfile-only rung keeps a source edit from starting the build cold. **What runner size does next build need?** `warp-ubuntu-latest-x64-4x` for apps under about 100 routes with type checking split into its own job, 8x when type checking runs inside `next build` or the app prerenders a few hundred routes, and 16x above roughly 1,000 routes. **What changes when a Next.js workflow moves to WarpBuild?** The `runs-on` label on each job, and `actions/cache` becomes `WarpBuilds/cache`. --- # Nix Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/nix-builds-github-actions Description: A Nix build on GitHub Actions is slow when the store starts empty. Add your own substituter, size the runner for eval, and boot warm stores from snapshots. # Nix Builds on GitHub Actions A Nix build on GitHub Actions is slow when the store starts empty. Add your own substituter, size the runner for eval, and boot warm stores from snapshots. Last verified: 2026-08-13 A Nix build on GitHub Actions is fast or slow for one reason: how much of the closure the runner has to build instead of download. Give the job a substituter that holds your own derivations, run it on `warp-ubuntu-latest-x64-8x`, and when the store grows past the point where fetching it is worth the wait, boot the job from a snapshot runner that already carries `/nix/store` on disk. The sections below cover the store cache strategies and what each one removes, a working workflow with the installer, the `nix.conf` settings and a `nix flake check` step, the sizing arithmetic for evaluation and parallel builds, and the prepared-machine option for repositories whose store is too large to restore on every run. ## [Overview](#overview)Copy Anchor Link Nix addresses every build output by a hash of its inputs and stores it under `/nix/store`. A derivation whose hash is already present is never rebuilt. A GitHub Actions runner is an ephemeral VM, so every job starts with a store containing nothing beyond what the installer puts there, and every path your flake needs has to arrive from somewhere. A substituter is where it arrives from. It is a binary cache that Nix queries before building: for each store path, Nix requests a `.narinfo` file, and on a hit it downloads the archive and unpacks it rather than running the derivation ([Nix configuration reference](https://nixos.org/manual/nix/stable/command-ref/conf-file.html)). The public cache at `cache.nixos.org` holds prebuilt paths for nixpkgs. It holds nothing your repository defines, which is why a flake with a handful of local packages still compiles from source on every runner until you add a cache of your own. That leaves four strategies, and the difference between them is which part of the work disappears. | Strategy | What the job stops doing | What it costs | Where it stops helping | | --------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------- | | cache.nixos.org only | Building nixpkgs paths from source | Nothing | Every derivation your repository defines still builds | | Your own binary cache as a second substituter | Rebuilding your own derivations after the first build anywhere | Object storage plus egress from your own bucket | A cold closure is still one narinfo request and one archive download per path | | Cache archive of \~/.cache/nix | Refetching flake inputs and re-evaluating unchanged attributes | $0.20 per GB-month storage, $0.0001 per cache operation | It carries no store paths, only evaluation and fetcher state | | Snapshot runner with a warm /nix/store | Downloading the closure at all | $0.04 per snapshot restore, $0.025 per snapshot-hour | Ubuntu Cloud runners only, and a snapshot is deleted after 15 days | Cache and snapshot rates are from the [pricing page](/pricing) and the [caching documentation](/docs/ci/features/caching). Most repositories want the second and third rows together, and reach for the fourth when the closure gets big. Nix work belongs on the Linux lines, where [the runner catalog](/runners/linux-x64) lists 2 to 32 vCPU sizes, each with a 150GB SSD, on Ubuntu images that carry the same tooling as GitHub-hosted runners. Snapshot runners are one part of a product surface that also includes remote Docker builders, CI observability, an MCP server, and the Action Debugger. ## [Configuration](#configuration)Copy Anchor Link ### [The workflow](#the-workflow)Copy Anchor Link Copy Text ``` name: nix on: push: branches: [main] pull_request: jobs: check: runs-on: warp-ubuntu-latest-x64-8x timeout-minutes: 60 steps: - uses: actions/checkout@v5 - name: Install Nix run: | sh <(curl -L https://nixos.org/nix/install) --daemon --yes echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" - name: Configure substituters run: | sudo mkdir -p /etc/nix sudo tee /etc/nix/nix.conf > /dev/null <<'EOF' experimental-features = nix-command flakes accept-flake-config = false substituters = https://cache.nixos.org https://acme-nix-cache.s3.us-east-1.amazonaws.com trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= acme-nix-cache-1:REPLACE_WITH_YOUR_PUBLIC_KEY max-jobs = 4 cores = 2 http-connections = 50 narinfo-cache-negative-ttl = 0 EOF sudo systemctl restart nix-daemon - name: Restore evaluation and fetcher cache uses: WarpBuilds/cache@v1 with: path: ~/.cache/nix key: nix-eval-${{ runner.os }}-${{ hashFiles('flake.lock') }} restore-keys: nix-eval-${{ runner.os }}- - name: Flake check run: nix flake check --print-build-logs --keep-going - name: Root the outputs so garbage collection keeps them if: github.ref == 'refs/heads/main' run: | mkdir -p /home/runner/nix-roots nix build .#packages.x86_64-linux.default \ --out-link /home/runner/nix-roots/package nix build .#devShells.x86_64-linux.default \ --out-link /home/runner/nix-roots/devshell - name: Push new store paths to the binary cache if: github.ref == 'refs/heads/main' env: AWS_ACCESS_KEY_ID: ${{ secrets.NIX_CACHE_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NIX_CACHE_SECRET_ACCESS_KEY }} NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }} run: | printf '%s' "$NIX_CACHE_SIGNING_KEY" > "$RUNNER_TEMP/cache.sec" chmod 600 "$RUNNER_TEMP/cache.sec" nix copy \ --to "s3://acme-nix-cache?region=us-east-1&secret-key=$RUNNER_TEMP/cache.sec" \ /home/runner/nix-roots/package /home/runner/nix-roots/devshell ``` Five details carry this workflow. **The daemon reads `/etc/nix/nix.conf`, so it needs a restart.** `substituters`, `max-jobs`, and `cores` are daemon-side settings in a multi-user install. Writing the file after the installer has already started the daemon changes nothing until `systemctl restart nix-daemon` picks it up. **A substituter is inert without its public key.** Nix ignores paths from a cache whose signing key is absent from `trusted-public-keys`, and the job then rebuilds everything while the cache sits there answering requests. Generate the key pair once with `nix key generate-secret`, keep the secret half in Actions secrets, and put the public half in `nix.conf`. **`accept-flake-config = false` keeps a flake from adding its own substituters.** A `nixConfig` block in an input flake can otherwise push extra caches and keys into the job, which is a supply-chain path you do not want open on a runner that holds your cache credentials. **Pull requests read the cache and main writes it.** The push step is gated on `github.ref`, so an unreviewed branch cannot place a store path under a hash that a later trusted build would download. **`--out-link` makes the outputs garbage collection roots.** `nix build --no-link` leaves the result unrooted, and the next `nix-collect-garbage` deletes exactly the paths you wanted to keep warm. Pointing the link at a stable path outside the workspace keeps the root alive after the next checkout wipes the working tree. ### [The prepared machine](#the-prepared-machine)Copy Anchor Link When the closure is large enough that the download dominates, stop moving the store over the network and keep it on the machine. Snapshot runners capture the runner disk mid-workflow and boot later jobs from that image, with the label form documented on the [snapshot runners page](/docs/ci/features/snapshot-runners): Copy Text ``` jobs: check: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=nix-store-main' }} steps: # install, configure, and check steps as above - name: Collect garbage before the snapshot if: github.ref == 'refs/heads/main' run: | nix-collect-garbage --delete-older-than 7d rm -rf "$HOME/.ssh" "$HOME/.aws" df -h / - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: nix-store-main fail-on-error: true wait-timeout-minutes: 45 ``` `snapshot.enabled=true` always boots from the base image, which is what the main-branch job wants so the saved store is built from a known starting point. `snapshot.key=nix-store-main` boots from the existing snapshot when one exists and falls back to the base image when it does not, so a stale alias costs a cold run rather than a failure. Snapshots are supported on WarpBuild Cloud Ubuntu runners; the labels are silently ignored on BYOC, Windows, and macOS runners. Keep the store out of `/tmp`, which is cleared on reboot, and a snapshot boot is a reboot. The default store path is `/nix/store`, so this matters mainly for anything you redirect by hand. ## [Sizing](#sizing)Copy Anchor Link Linux x64 rates from the [pricing page](/pricing): | Runner label | vCPU | Memory | Storage | Per minute | Fits | | -------------------------- | ---- | ------ | --------- | ---------- | --------------------------------------------------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | lock file checks, formatting, evaluation-only jobs | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | small flakes with a warm substituter | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | nix flake check on a repository with several packages | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | wide build graphs, overlays that rebuild parts of nixpkgs | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | full closure rebuilds after a toolchain or nixpkgs bump | For the default size, the list-price arithmetic runs like this: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13 in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). **Set `max-jobs` and `cores` together.** `max-jobs` is how many derivations build at once and `cores` is what each build sees as `NIX_BUILD_CORES`, so their product is the parallelism you are asking the machine for. Leaving both at their defaults either serializes the graph or oversubscribes it. | Runner label | vCPU | max-jobs | cores | Peak build threads | | -------------------------- | ---- | -------- | ----- | ------------------ | | warp-ubuntu-latest-x64-4x | 4 | 2 | 2 | 4 | | warp-ubuntu-latest-x64-8x | 8 | 4 | 2 | 8 | | warp-ubuntu-latest-x64-16x | 16 | 4 | 4 | 16 | | warp-ubuntu-latest-x64-32x | 32 | 8 | 4 | 32 | Favor more `cores` when a few large derivations dominate, such as a compiler or a kernel, and more `max-jobs` when the graph is many small independent packages. ### [Worked cost model](#worked-cost-model)Copy Anchor Link Take a repository running 400 pull request jobs a month. Assume `nix flake check` takes 12 minutes on 8 vCPU with a warm substituter and 4 minutes when the store is already on the machine. Substitute your own measurements, because the gap depends entirely on closure size. | Line item | Rate | Volume | Monthly cost | | ------------------------------------------------------------ | ------------------------ | ------------- | ------------ | | GitHub-hosted 8-core larger runner, 12 minutes per job | $0.022 per minute | 4,800 minutes | $105.60 | | warp-ubuntu-latest-x64-8x, 12 minutes per job | $0.016 per minute | 4,800 minutes | $76.80 | | warp-ubuntu-latest-x64-8x from a snapshot, 4 minutes per job | $0.016 per minute | 1,600 minutes | $25.60 | | Snapshot restore | $0.04 per job | 400 jobs | $16.00 | | Snapshot storage, one alias | $0.025 per snapshot-hour | 720 hours | $18.00 | The snapshot path totals $59.60 against $76.80 for the same runner without snapshots and $105.60 for the same minutes on GitHub-hosted larger runners. The main-branch job that refreshes the alias pays the cold path and is not in the 400. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Evaluation runs in one thread.** Before a single derivation builds, Nix evaluates the flake, and that phase uses one core and a heap that reaches several gigabytes on a flake that imports all of nixpkgs. Neither `max-jobs` nor `cores` touches it. Two things help: keep `~/.cache/nix` between runs so the evaluation cache survives, and avoid import-from-derivation, which suspends evaluation to run a build and turns the widest part of your graph into a queue. `NIX_SHOW_STATS=1 nix eval .#packages.x86_64-linux.default` prints the CPU time and allocation counts that tell you which of the two you are looking at. **Cold substituter fetches are round trips, not bandwidth.** A closure of a few thousand paths is a few thousand narinfo requests before any archive moves. Raising `http-connections` above the default of 25 shortens that phase, and `narinfo-cache-negative-ttl = 0` stops Nix from remembering a miss for the default hour, which matters when one job pushes a path that a later job in the same workflow needs. The push side has the same shape: `nix copy` uploads each new path individually, so a main-branch job after a nixpkgs bump spends real minutes writing to the cache. **The store outgrows the disk.** Each Linux size carries a 150GB SSD, and a repository that builds several variants of a large closure fills it faster than expected once snapshots keep the store alive between runs. Run `nix-collect-garbage --delete-older-than 7d` before the snapshot save, keep the paths you want warm rooted through `--out-link`, and print `df -h /` in the same step so the log says how close the last run came. Telling these apart is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so an evaluation stall reads as one busy core, a cold substituter reads as network activity with idle cores, and a real rebuild reads as sustained load across every core. For a derivation that fails only in GitHub Actions, the Action Debugger pauses the workflow and opens an SSH session on the live runner, which is where `nix log`, `nix why-depends`, and `nix path-info --closure-size` answer in seconds what a rerun answers in minutes. ## [Proof](#proof)Copy Anchor Link Public repositories running warp- labels are the citable evidence. The [bitcoin/bitcoin GitHub Actions workflow](https://github.com/bitcoin/bitcoin/blob/master/.github/workflows/ci.yml) routes its Linux jobs across warp- labels sized per job, from `warp-ubuntu-latest-x64-2x` for lint up to `warp-ubuntu-latest-x64-16x` for the fuzz and MSan matrices, with compiler caches restored and saved between runs (checked on 2026-08-13). That is a native compile matrix rather than a Nix build, and the pattern this page applies to `/nix/store` is the same one: size the runner to the shape of the work, then keep the expensive state on the machine. Related reading: the [snapshot runner reference](/runners/snapshot) covers the label forms and lifecycle in full, the [incremental builds guide](/guides/incremental-builds-github-actions) generalizes the warm-state pattern beyond Nix, the [remote build cache definition](/glossary/remote-build-cache) explains the key-derivation ideas that a substituter shares with other build tools, and the [Linux x64 runner page](/runners/linux-x64) lists every label, image, and rate. ## [FAQ](#faq)Copy Anchor Link **What does a substituter change about a Nix job on GitHub Actions?** A substituter is a binary cache that Nix consults before it builds a store path. When the path hash already exists there, Nix downloads the archive instead of running the derivation, so job time moves from compile time to download time. `cache.nixos.org` covers nixpkgs paths only. Your own derivations are substitutable only after a job pushes them to a cache you control. **Which runner size should `nix flake check` use?** Start on `warp-ubuntu-latest-x64-8x` at $0.016 per minute with `max-jobs = 4` and `cores = 2`. Evaluation runs in one thread and can hold a multi-gigabyte heap, so the 32 GB of memory matters as much as the core count. Move to 16 or 32 vCPU only when the build graph is wide enough to keep that many derivations running at once. **When is a snapshot runner better than a binary cache for `/nix/store`?** When the closure is large enough that fetching it dominates the job. A substituter still pays a narinfo request and an archive download per store path. A snapshot boots a WarpBuild Ubuntu runner from a saved disk where `/nix/store` is already populated, so the fetch does not happen. Snapshots are deleted after 15 days and are supported on WarpBuild Cloud Ubuntu runners only. --- # Node.js Builds on GitHub Actions Without Cold Caches | WarpBuild URL: https://www.warpbuild.com/solutions/nodejs-github-actions Description: Cache the npm, pnpm, or yarn store with WarpBuilds/cache keyed on the lockfile hash, then size warp- runners from 4 to 16 vCPU for installs and tests. # Node.js Builds on GitHub Actions Without Cold Caches Cache the npm, pnpm, or yarn store with WarpBuilds/cache keyed on the lockfile hash, then size warp- runners from 4 to 16 vCPU for installs and tests. Last verified: 2026-08-13 To cache node modules on GitHub Actions, save the package manager's download store between jobs, key it on the hash of the lockfile, and let `npm ci`, `pnpm install`, or `yarn install` rebuild `node_modules` from that warm store. On WarpBuild runners, the [WarpBuilds/cache](https://github.com/WarpBuilds/cache) action is a drop-in replacement for `actions/cache@v4` and stores the entries in WarpBuild's cache backend, which is enabled by default on all Linux runners. Caching fixes the install phase. The rest of a Node.js pipeline, typecheck, bundle, and tests, is bounded by CPU and memory, which makes it a runner sizing decision. This page covers the workflow configuration for npm, pnpm, and yarn, the sizing call between 4, 8, and 16 vCPU runners for each phase, the four bottlenecks that dominate Node.js pipelines, and the cost math against GitHub-hosted runner list prices. ## [Overview](#overview)Copy Anchor Link Every GitHub Actions job starts on a fresh virtual machine, and nothing from the previous run survives. For a Node.js repository that means `node_modules` is empty at the start of every job, and the install step downloads every package in the lockfile from the registry before any test or build can begin. Two different directories can be cached, and the right one is the store, the package manager's own download cache. npm keeps compressed tarballs in `~/.npm`, pnpm keeps a content-addressed store you can locate with `pnpm store path`, and yarn keeps its cache where `yarn cache dir` points. With a warm store, `npm ci` still rebuilds `node_modules` from scratch, but it reads tarballs from local disk instead of the registry, and `pnpm install` links files out of the store without copying them at all. Caching `node_modules` itself is the tempting shortcut, and it fails in practice. `npm ci` deletes the directory before installing, so the restored bytes are thrown away. A restored `node_modules` is also welded to the Node version and platform that produced it, so a toolchain bump silently breaks native modules. The store has neither problem: it is content addressed, and the install command decides what to link out of it on each run. For Node.js work the Linux x64 sizes from 4 to 16 vCPU cover almost every repository, and moving a job onto them is a one-line change to `runs-on`. One caveat from the [caching documentation](/docs/ci/features/caching): WarpBuild Cache is not supported on Windows runners, so keep Node.js cache steps on Linux jobs. One boundary before the configuration. This page covers single-package Node.js repositories: one lockfile, one install, one test suite. If your repository is a monorepo driven by a task graph, remote caching of task outputs is a different problem with different tools, covered on the [Turborepo on GitHub Actions](/solutions/turborepo-github-actions) and [Nx on GitHub Actions](/solutions/nx-github-actions) pages. ## [Configuration](#configuration)Copy Anchor Link The explicit form uses `WarpBuilds/cache` to save and restore the store directly. This workflow installs, typechecks, builds, and tests a pnpm project on an 8 vCPU WarpBuild runner: Copy Text ``` name: node-ci on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 22 - name: Resolve pnpm store path id: pnpm-store run: echo "path=$(pnpm store path)" >> "$GITHUB_OUTPUT" - name: Restore pnpm store uses: WarpBuilds/cache@v1 with: path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm- - run: pnpm install --frozen-lockfile - run: pnpm run typecheck - run: pnpm run build - run: pnpm run test ``` The key hashes `pnpm-lock.yaml`, so the cache rolls exactly when the dependency set changes. The `restore-keys` prefix is what keeps a dependency bump cheap: when the exact key misses, the action restores the most recent cache with the same prefix, and the install only fetches the packages that actually changed. `--frozen-lockfile` makes the install fail instead of silently rewriting the lockfile, which keeps the key honest. For npm the shorter form is [WarpBuilds/setup-node](https://github.com/WarpBuilds/setup-node), a drop-in replacement for `actions/setup-node` that routes its dependency caching through WarpBuild Cache. From v6, npm caching turns on automatically when the `packageManager` field in `package.json` is set to `npm`: Copy Text ``` - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm ``` The `cache` input accepts `npm`, `yarn`, or `pnpm`, and `cache-dependency-path` points the key at a lockfile outside the repository root. The full input list is in the [setup actions documentation](/docs/ci/features/caching/setup-actions). Two behaviors from the [caching documentation](/docs/ci/features/caching) matter before you rely on either form. The cache is scoped to key, version, and branch, so a cache saved on a feature branch is separate from the one on `main`; seed the cache from `main` and let branch builds restore it through `restore-keys`. Entries expire 7 days after last use, so an active repository stays warm and an abandoned branch stops costing storage on its own. Cache usage is metered, and the rates are small enough to state exactly: storage is $0.20 per GB-month and each write or restore operation is $0.0001\. A Node.js repository with a 1.5GB store and 4,000 cache operations a month adds about $0.70 to the bill. ## [Sizing](#sizing)Copy Anchor Link WarpBuild Linux x64 runners scale from 2 to 32 vCPU with 4GB of memory per core. The three sizes that matter for Node.js are 4x, 8x, and 16x: | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | Size each phase for what it can actually use: **Install** is network and disk bound. With a warm store it is mostly disk work, and extra cores change little beyond 4 vCPU. If install dominates your pipeline, the fix is the cache configuration above, and a bigger runner is the wrong lever. **Typecheck** runs `tsc` on a single core, so more vCPUs do nothing for it. Memory is the constraint that bites: the type graph of a large program can push the process past the default heap. The 16GB on 4x covers most projects; move to 8x for 32GB when `tsc` dies with heap errors rather than reaching for `NODE_OPTIONS=--max-old-space-size` on a machine that cannot back it. **Bundle** depends on the tool. esbuild and swc parallelize across every core they see, so they reward 8x and 16x on large inputs. webpack and Rollup run the module graph on one thread and only parallelize minification, so their ceiling is closer to 4 or 8 vCPU. If the bundle step is the long pole, check whether minification is the slow part before buying cores for a single-threaded graph walk. **Jest and Vitest** scale with cores. Jest defaults `--maxWorkers` to the core count minus one, and Vitest sizes its worker pool from available cores, so a suite with many independent test files keeps 8 or 16 cores busy. This is the phase where 16x earns its rate on a single job, and where sharding across several 4x runners is the alternative, covered under bottlenecks below. A useful calibration step: run the workflow once on 8x and watch utilization per step. If only the test phase holds all 8 cores, split the pipeline so install and typecheck run on 4x at $0.008 per minute and only the test job pays for 8x. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners at [github.com/pricing](https://github.com/pricing) and on the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the standard `ubuntu-latest` runner for private repositories (2 vCPU) is $0.006 per minute, and larger Linux runners are $0.012 for 4 vCPU, $0.022 for 8 vCPU, and $0.042 for 16 vCPU. | Machine | vCPU | Per-minute rate | Source | | --------------------------------- | ---- | --------------- | ------------------------------------- | | GitHub-hosted standard Linux | 2 | $0.006 | GitHub list price, checked 2026-08-13 | | GitHub-hosted Linux larger runner | 4 | $0.012 | GitHub list price, checked 2026-08-13 | | warp-ubuntu-latest-x64-2x | 2 | $0.004 | WarpBuild pricing | | warp-ubuntu-latest-x64-4x | 4 | $0.008 | WarpBuild pricing | Take a concrete team: a TypeScript service running 2,000 jobs a month, averaging 5 minutes per job on a 4 vCPU machine with a warm store. That is 10,000 runner minutes a month. | Scenario | Rate | Monthly minutes | Monthly cost | | ----------------------------------------------- | ---------- | --------------- | ------------ | | GitHub-hosted Linux larger runner, 4 vCPU | $0.012/min | 10,000 | $120.00 | | warp-ubuntu-latest-x64-4x, 4 vCPU | $0.008/min | 10,000 | $80.00 | | Cache storage and operations (1.5GB, 4,000 ops) | see above | n/a | $0.70 | Stated as list-price arithmetic: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13. Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four bottlenecks account for most slow Node.js pipelines on GitHub Actions. **Cold node\_modules.** Without a restored store, every job downloads the full dependency tree from the registry before the first test runs, and a lockfile with a few thousand entries turns that into minutes of network time per job. The fix is the cache configuration above. Verify it worked from the install log: a warm pnpm install reports most packages as reused from the store, and a warm `npm ci` finishes without a long download phase. **Lockfile-hash key churn.** Keying on `hashFiles('**/pnpm-lock.yaml')` means every dependency bump produces a key that has never been seen. Without `restore-keys`, each automated dependency update pull request starts from a completely cold store, which is precisely the moment the cache matters most. The prefix fallback restores the previous store so the install fetches only the changed packages. Watch for the same churn from keys that hash `package.json` instead of the lockfile, since scripts and metadata edits then invalidate a perfectly good cache. If cache entries keep disappearing on GitHub-hosted infrastructure instead, the repository is likely hitting eviction, which the [GitHub Actions cache size limit guide](/guides/github-actions-cache-size-limit) covers. **Native module rebuilds.** Packages such as sharp, better-sqlite3, and node-gyp based addons ship prebuilt binaries per platform and Node ABI. When a prebuilt binary exists, install just downloads it; when it does not, node-gyp compiles C++ at install time, and that compile repeats on every cold install. Pin the Node version in the workflow so the ABI stays stable and the prebuilt path keeps hitting. When a compile is unavoidable, it parallelizes well, which is one more reason install-heavy jobs with native dependencies belong on 8x rather than the smallest runner. **Single-shard test suites.** A suite that runs as one serial job has a wall time equal to its total test time, no matter how large the runner. Jest and Vitest both accept a `--shard` flag, so the suite splits across a matrix and each shard restores the same store cache: Copy Text ``` test: runs-on: warp-ubuntu-latest-x64-4x strategy: matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci - run: npx vitest run --shard=${{ matrix.shard }}/4 ``` Four shards on 4x spend roughly the same runner minutes as one job doing all the work, while the wall time drops to the slowest shard. Total minutes rise slightly because each shard repeats checkout and install, which is another reason the warm store matters. When a job is slow and the cause is unclear, WarpBuild's CI observability shows OpenTelemetry-based system metrics from the runner agent correlated with GitHub Actions job logs, which separates a CPU-bound test phase from an install stuck on the network. For interactive debugging, the Action Debugger pauses a workflow and opens an SSH session on the runner, so you can run `pnpm store path` and inspect the restored directories on the machine itself. Broader workflow-level tactics are collected in the guide on [speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Proof](#proof)Copy Anchor Link Public OSS repositories running warp- labels are citable evidence: open a repository's workflow file and read the `runs-on` line yourself. The Trigger.dev CLI end-to-end suite runs its matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x`, which you can read in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml) (checked on 2026-08-13). The same store-plus-sizing approach extends to monorepos, where task-graph output caching takes over; the [Turborepo on GitHub Actions](/solutions/turborepo-github-actions) page walks those decisions. ## [FAQ](#faq)Copy Anchor Link **Should I cache node\_modules or the package manager store?** Cache the store. `npm ci` deletes `node_modules` before every install, so caching that directory buys nothing, and a cached `node_modules` breaks when the Node version or platform changes. The store is content addressed and survives both. **What should the cache key hash for npm, pnpm, and yarn?** The lockfile: `package-lock.json` for npm, `pnpm-lock.yaml` for pnpm, `yarn.lock` for yarn. Add a `restore-keys` prefix so a dependency bump restores the previous store instead of starting cold. **What runner size do Jest or Vitest shards need?** One warp-ubuntu-latest-x64-4x per shard covers most suites, since both runners scale workers with the vCPU count. Move a shard to 8x when tests spawn browsers or databases that need the extra memory. **What changes when a Node.js workflow moves to WarpBuild?** One line per job: the `runs-on` label. --- # Nx Affected Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/nx-github-actions Description: Run nx affected on GitHub Actions with correct base and head SHAs, restore .nx/cache with WarpBuilds/cache, and size warp- runners to the affected graph. # Nx Affected Builds on GitHub Actions Run nx affected on GitHub Actions with correct base and head SHAs, restore .nx/cache with WarpBuilds/cache, and size warp- runners to the affected graph. Last verified: 2026-08-13 To run `nx affected` correctly on GitHub Actions, give Nx a base SHA that points at the last commit you actually verified, check out enough git history for that diff to resolve, and restore the `.nx/cache` directory so cached task results survive the ephemeral runner. On WarpBuild runners the cache step is the [WarpBuilds/cache](https://github.com/WarpBuilds/cache) action, a drop-in replacement for `actions/cache@v4`, and snapshot runners can hold `node_modules` and `.nx/cache` warm across runs. Those three decisions determine whether affected mode saves anything. This page covers the workflow YAML for base and head SHA selection, how the Nx computation cache behaves on a machine that is destroyed after every job, when a snapshot runner beats a cache archive, and how to pick `--parallel` and a runner size for the 8 and 16 vCPU tiers. ## [Overview](#overview)Copy Anchor Link Nx builds a project graph from your workspace and computes which projects a commit range touches, directly or through a dependency edge. `nx affected -t build` then runs the `build` target for that subset instead of the whole workspace. Two inputs decide the subset: the base SHA and the head SHA. On top of selection sits the computation cache. Nx hashes every input to a task, which includes the project's source files, the hashes of its dependencies, the target configuration in `nx.json` and `project.json`, the installed package versions, and any runtime or environment inputs you declared. If a task with that hash has run before and its result is in `.nx/cache`, Nx replays the recorded terminal output and restores the declared outputs instead of executing the task. Selection and caching solve different halves of the problem. Selection decides which tasks are candidates. The cache decides which of those candidates actually execute. A workflow that gets selection right and starts with an empty cache still runs every affected task from scratch. That empty cache is the default state on GitHub Actions. Every job starts on a fresh virtual machine with nothing from the previous run, so `.nx/cache` does not exist, every task hash misses, and Nx runs the full affected set. Carrying the cache forward is a workflow decision, and there are two mechanisms for it: restore a cache archive at the start of the job, or boot the job on a runner image that already contains the warm directory. Nx workspaces run on the Linux sizes, and moving a job onto them is a one-line change to `runs-on`. Pick one architecture for the whole pipeline and stay on it: cache entries are scoped by a version derived from the operating system and compression tool, so an entry written on a Linux ARM64 runner does not restore on a Linux x64 runner, and native modules under `node_modules` are architecture specific anyway. One boundary before the configuration. This page covers `nx affected` and the Nx computation cache on runner-local storage. Hosted remote cache services for Nx are a separate topic, and Turborepo's task pipeline has its own configuration on the [Turborepo builds on GitHub Actions](/solutions/turborepo-github-actions) page. Dependency store caching for npm, pnpm, and yarn is covered on the [Node.js dependency caching](/solutions/nodejs-github-actions) page. ## [Configuration](#configuration)Copy Anchor Link This workflow runs lint, test, and build for the affected projects on an 8 vCPU WarpBuild runner. It restores the Nx computation cache on every run and writes a new entry only from `main`. Copy Text ``` name: nx-affected on: push: branches: [main] pull_request: permissions: contents: read actions: read jobs: affected: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: nrwl/nx-set-shas@v4 with: main-branch-name: main - uses: pnpm/action-setup@v4 with: version: 9 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: pnpm - name: Restore Nx computation cache uses: WarpBuilds/cache/restore@v1 with: path: .nx/cache key: ${{ runner.os }}-nx-${{ github.run_id }} restore-keys: | ${{ runner.os }}-nx- - run: pnpm install --frozen-lockfile - name: Run affected targets run: | npx nx affected -t lint test build \ --base=$NX_BASE \ --head=$NX_HEAD \ --parallel=6 - name: Save Nx computation cache if: github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: .nx/cache key: ${{ runner.os }}-nx-${{ github.run_id }} ``` Four lines carry most of the behavior. `fetch-depth: 0` gives the runner the full git history. The default shallow checkout fetches one commit, and a diff against any base SHA outside that single commit cannot resolve. `nrwl/nx-set-shas@v4` exports `NX_BASE` and `NX_HEAD` into the job environment. On a `pull_request` event it sets the base to the merge base with the target branch and the head to the pull request head. On a push to `main` it queries the GitHub API for the most recent successful run of the same workflow on that branch and uses that SHA as the base, which is why the job needs the `actions: read` permission. Nx reads both variables on its own, so the explicit `--base` and `--head` flags in the run step are documentation for the next reader rather than a requirement. The restore step names `.nx/cache`, the workspace-local cache directory used by current Nx versions. Older workspaces put it under `node_modules/.cache/nx`, and a workspace that overrides `cacheDirectory` in `nx.json` needs the overridden path here. Restoring the wrong directory produces a workflow that looks correct and never gets a cache hit. The save step runs only on `main`. Every push writes a new entry keyed by run ID, and pull request jobs restore the most recent one through the `restore-keys` prefix. Restricting writes to `main` keeps the number of stored entries proportional to merges rather than to every push on every branch, which matters for the storage bill discussed under Sizing. ### [Keeping node\_modules and the Nx cache warm with a snapshot runner](#keeping-node%5Fmodules-and-the-nx-cache-warm-with-a-snapshot-runner)Copy Anchor Link A cache archive has to be uploaded and downloaded. When the install step plus the cache download is the largest block of time in the job, a [snapshot runner](/runners/snapshot) removes both by booting the job on a machine image captured after a previous run. Snapshot runners are available on WarpBuild Cloud Ubuntu runners, and the labels attach to `runs-on`: Copy Text ``` jobs: affected: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=nx-monorepo-warm' }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: nrwl/nx-set-shas@v4 - run: pnpm install --frozen-lockfile - run: npx nx affected -t lint test build --base=$NX_BASE --head=$NX_HEAD --parallel=6 - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx -e node_modules -e .nx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: nx-monorepo-warm fail-on-error: true wait-timeout-minutes: 60 ``` `snapshot.enabled=true` boots from the base image and lets the `snapshot-save` action capture the machine at the end. `snapshot.key=nx-monorepo-warm` boots from the snapshot stored under that alias when one exists, so pull request jobs start with `node_modules` already installed and `.nx/cache` already populated. Jobs booted from a snapshot get `WARPBUILD_SNAPSHOT_KEY` in the environment, which is a cheap way to assert in a step that the warm path is actually being taken. The `-e node_modules -e .nx` exclusions on `git clean` are the part people get wrong. Both directories are gitignored, so a plain `git clean -ffdx` deletes exactly the state the snapshot exists to preserve. Keep the credential removal, drop the two directories from the sweep. Three constraints from the [snapshot runner documentation](/docs/ci/features/snapshot-runners) shape how you use this. Snapshots are deleted 15 days after creation, so an alias that stops being refreshed silently falls back to the base image. The `/tmp` directory is cleared on reboot and does not survive. Booting from a snapshot takes 45 to 60 seconds, which is slower than a default runner boot, so the technique pays only when the warm state saves more than that. ## [Sizing](#sizing)Copy Anchor Link WarpBuild Linux x64 runners scale from 2 to 32 vCPU with 4GB of memory per core and 150GB of SSD storage on every size. The tiers that matter for an Nx workspace: | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 | The ARM64 line carries the same sizes at a lower rate, which suits a workspace whose native dependencies all publish ARM64 binaries: | Runner label | vCPU | Memory | Price per minute | | ---------------------------- | ---- | ------ | ---------------- | | warp-ubuntu-latest-arm64-8x | 8 | 32GB | $0.012 | | warp-ubuntu-latest-arm64-16x | 16 | 64GB | $0.024 | Nx runs at most `--parallel` tasks at once, and the default is 3\. On an 8 vCPU runner that leaves five cores idle for most of the run. Two numbers set the right value. **Cores.** On `warp-ubuntu-latest-x64-8x`, start at `--parallel=6`. The Nx process itself, the runner agent, and the log streaming all need CPU, and pinning all 8 cores to build tasks makes the job less predictable rather than faster. On `warp-ubuntu-latest-x64-16x`, start at `--parallel=12` for the same reason. **Memory.** A single Node build task holds 1GB to 3GB depending on the bundler and the size of the project. Twelve concurrent tasks at 3GB is 36GB, which fits inside the 64GB on 16x and would not fit inside the 32GB on 8x. When a run dies with a heap error at high parallelism, the fix is a larger tier or a lower `--parallel` value, in that order of preference if wall time matters. The ceiling that actually binds is neither of those. `nx affected` on a typical pull request selects a handful of projects, and their tasks respect the dependency graph, so a chain of five projects that each depend on the previous one executes serially no matter how many cores are available. Before moving from 8x to 16x, print the selection and its shape: Copy Text ``` npx nx affected -t build --base=$NX_BASE --head=$NX_HEAD --graph=affected.html npx nx show projects --affected --base=$NX_BASE --head=$NX_HEAD ``` If the widest layer of that graph is four tasks, 16 cores buy nothing. Move the money into fanning out across runners instead, covered under Bottlenecks. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners at [github.com/pricing](https://github.com/pricing) and on the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the standard `ubuntu-latest` runner for private repositories (2 vCPU) is $0.006 per minute, and the larger Linux runners are $0.012 for 4 vCPU, $0.022 for 8 vCPU, and $0.042 for 16 vCPU. | Machine | vCPU | Per-minute rate | Source | | --------------------------------- | ---- | --------------- | ------------------------------------- | | GitHub-hosted Linux larger runner | 4 | $0.012 | GitHub list price, checked 2026-08-13 | | GitHub-hosted Linux larger runner | 8 | $0.022 | GitHub list price, checked 2026-08-13 | | GitHub-hosted Linux larger runner | 16 | $0.042 | GitHub list price, checked 2026-08-13 | | warp-ubuntu-latest-x64-8x | 8 | $0.016 | WarpBuild pricing | | warp-ubuntu-latest-x64-16x | 16 | $0.032 | WarpBuild pricing | Take a monorepo team pushing 1,500 workflow runs a month. Running the whole workspace takes 14 minutes on an 8 vCPU machine. Running only the affected projects with a warm computation cache takes 6 minutes. | Scenario | Rate | Monthly minutes | Monthly cost | | ------------------------------------------ | ---------- | --------------- | ------------ | | Whole workspace, GitHub-hosted 8 vCPU | $0.022/min | 21,000 | $462.00 | | Whole workspace, warp-ubuntu-latest-x64-8x | $0.016/min | 21,000 | $336.00 | | Affected only, warp-ubuntu-latest-x64-8x | $0.016/min | 9,000 | $144.00 | Stated as list-price arithmetic: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13. Now price the warm state, because both mechanisms are metered. Cache storage is $0.20 per GB-month and each cache write or restore operation is $0.0001\. Entries expire 7 days after last use. With a 2GB `.nx/cache` archive saved on 20 merges per weekday, roughly 140 entries stay resident at any moment, which is about 280GB and $56.00 a month. Two levers bring that down: save less often on `main` by bucketing the key on the hour, or prune the cache directory before the save step so the archive is smaller. Snapshots are metered differently. Snapshot restore is $0.04 per job and snapshot storage is $0.025 per snapshot-hour. One continuously refreshed snapshot alive for 30 days is 720 snapshot-hours, or $18.00\. Across 1,500 jobs, that is $60.00 of restores plus $18.00 of storage, so $78.00 a month, and $0.052 per job. That per-job number is the decision rule. At $0.016 per minute on `warp-ubuntu-latest-x64-8x`, $0.052 buys 3.25 minutes of runner time. A snapshot that removes more than about 3.3 minutes of install and cache-download work per job costs less than the time it saves. Below that threshold, the cache archive is the cheaper mechanism. Every rate for every size and platform is on the [WarpBuild pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **The base SHA is wrong.** This is the failure that makes affected mode useless, and it fails in both directions. Too wide: a base pointing at the current tip of `main` rather than the merge base marks every project touched by commits that landed since you branched. A pull request changing one library then runs forty projects, and the run costs more than skipping affected mode entirely. A shallow checkout produces the same shape from a different cause, because git cannot walk back to the base commit and Nx has no diff to narrow the graph with. Too narrow: a base of `HEAD~1` on a branch where a previous run failed. The commits from that failed run sit between `HEAD~1` and the last verified state, so their projects are never selected and never tested. Broken code reaches `main` with a green check. Using the last successful workflow run as the base on `main` is what `nx-set-shas` exists to provide, and it is the reason the `actions: read` permission is in the workflow above. Fork pull requests deserve their own check. The base repository history and the workflow-run lookup both behave differently for a fork, so verify the selection on a fork pull request before trusting it. When a selection looks wrong, `npx nx show projects --affected` prints exactly what Nx picked, and comparing that list against `git diff --name-only $NX_BASE $NX_HEAD` shows whether the problem is the diff or the project graph. **The computation cache never hits, or hits when it should miss.** A restored `.nx/cache` produces a hit only when a task hash matches exactly. Anything that changes the hash on every run, such as a timestamp written into a source file or an undeclared environment variable that leaks into the build, guarantees a permanent miss while the cache costs storage. The inverse bug is worse: a task whose real inputs include something Nx does not hash returns a stale replay. The related trap is undeclared outputs. On a cache hit Nx restores the paths listed in the target's `outputs` and replays the terminal log. Nothing else comes back. A build that also writes `coverage/` or a generated `dist/types` directory without declaring it produces a job that passes cold and fails warm, because the downstream step finds an empty directory. Declare every path a later step reads. Cache scope matters too. Per the [WarpBuild caching documentation](/docs/ci/features/caching), entries are scoped to the key, the cache version, and the branch, so a cache written on a feature branch is not visible to a sibling branch, and an entry expires 7 days after its last use. Seeding from `main` and restoring by prefix is what makes the archive useful across the repository. One platform limit to plan around: WarpBuild Cache is not supported on Windows runners, so keep Nx cache steps on Linux jobs. **Install time swamps the affected run.** `nx affected` narrows the tasks. It does nothing to the install step, which still resolves the entire workspace dependency tree on every job. In a workspace with several hundred packages, install can exceed the time spent on the affected tasks. Restoring the package manager store helps, and the snapshot runner configuration above removes the step almost entirely by keeping `node_modules` on the machine image. One Nx behavior compounds this. Nx disables its daemon when the `CI` environment variable is set, so every `nx` invocation recomputes the project graph from scratch. Running `nx affected -t lint test build` as a single command computes the graph once. Running three separate `nx affected` commands computes it three times. **The affected graph is too narrow to use a big runner.** When the widest layer of the affected graph is small, a larger single runner sits idle. Fan the work out across machines instead, selecting projects in one job and building them in a matrix: Copy Text ``` jobs: select: runs-on: warp-ubuntu-latest-x64-2x outputs: projects: ${{ steps.affected.outputs.projects }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: nrwl/nx-set-shas@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - id: affected run: | echo "projects=$(npx nx show projects --affected --json)" >> "$GITHUB_OUTPUT" build: needs: select if: needs.select.outputs.projects != '[]' runs-on: warp-ubuntu-latest-x64-8x strategy: fail-fast: false matrix: project: ${{ fromJson(needs.select.outputs.projects) }} steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: npx nx run ${{ matrix.project }}:build ``` Two limits apply. GitHub caps a matrix at 256 jobs per workflow run, so a change touching a shared library in a very large workspace needs a chunking step that groups projects into a fixed number of shards. And each matrix job repeats checkout and install, so total runner minutes rise even as wall time falls, which is the strongest argument for keeping install cheap. On the capacity side, run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a wide matrix on a busy afternoon does not sit behind a queue that a plan limit created. When a job is slow and the cause is unclear, WarpBuild's CI observability shows OpenTelemetry-based system metrics from the runner agent correlated with GitHub Actions job logs, which separates a CPU-saturated task graph from an install stuck on the network. The Action Debugger pauses a workflow and opens an SSH session on the runner, so you can inspect `.nx/cache` on the machine and run `npx nx show projects --affected` by hand against the same SHAs the job used. Broader workflow-level tactics are collected in the guide on [speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Proof](#proof)Copy Anchor Link Test this against your own workspace instead of taking the sizing numbers on faith. Run the same pull request twice: once on your current runner with the workflow above, once on `warp-ubuntu-latest-x64-8x`, and compare the per-step timings in the GitHub Actions log. Three measurements make that test honest. Record the selection size from `npx nx show projects --affected` so both runs cover the same tasks. Record the cache hit rate from the Nx terminal output, since a replayed task and an executed task are not comparable. Record install time separately from task time, because that is the number a snapshot runner changes. For teams that need the runners inside their own cloud account, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. ## [FAQ](#faq)Copy Anchor Link **How do I set the base and head SHAs for nx affected on GitHub Actions?** Check out with `fetch-depth: 0`, then run `nrwl/nx-set-shas`, which exports `NX_BASE` and `NX_HEAD`. On pull requests `NX_BASE` is the merge base with the target branch. On pushes to `main` it is the SHA of the last successful run of the same workflow, which needs the `actions: read` permission. **Why does nx affected run every project on my pull requests?** Almost always the base SHA. A shallow checkout leaves git without the commits needed to compute the diff, and a base that points at the tip of `main` instead of the merge base marks every project touched by unrelated commits as affected. Print the selection with `npx nx show projects --affected` before trusting the run. **Should I cache .nx/cache or use a snapshot runner?** Cache `.nx/cache` when the Nx computation cache is the only warm state you need. Use a snapshot runner when install time dominates, since the snapshot holds `node_modules` and `.nx/cache` together. A snapshot costs $0.04 per restore plus $0.025 per snapshot-hour, so it pays for itself once it saves about 3.3 minutes on a `warp-ubuntu-latest-x64-8x` runner. **What --parallel value should I use on an 8 or 16 vCPU runner?** Start at 6 on `warp-ubuntu-latest-x64-8x` and 12 on `warp-ubuntu-latest-x64-16x`, leaving headroom for the Nx process itself and the runner agent. The real ceiling is the width of the affected task graph, so measure how many independent tasks a typical pull request selects before buying more cores. --- # Pants Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/pants-github-actions Description: Cache the Pants lmdb_store and named_caches with WarpBuilds/cache@v1 on a warp- Linux label, or boot a snapshot runner that already holds both warm on disk. # Pants Builds on GitHub Actions Cache the Pants lmdb\_store and named\_caches with WarpBuilds/cache@v1 on a warp- Linux label, or boot a snapshot runner that already holds both warm on disk. Last verified: 2026-08-13 Run Pants on GitHub Actions by pinning `local_store_dir` and `named_caches_dir` to stable paths under the runner home, then restoring both with `WarpBuilds/cache@v1` before the first `pants` invocation. On WarpBuild the same job runs on a label such as `warp-ubuntu-latest-x64-8x`, and a snapshot runner can carry the local store forward on disk so there is no archive to download at all. Both approaches restore the same directories. They differ in what the restore costs, and the crossover point depends on how large your store has grown. This page covers the config file, both workflows, how the Pants local store interacts with runner-local state, how to map process parallelism to a runner size, and what the whole setup bills per month. ## [Overview](#overview)Copy Anchor Link Pants keeps its incremental speed on local disk, spread across directories that behave differently under GitHub Actions. | Location | Option | What it holds | Shared across machines | | ----------------------------- | ---------------------- | ------------------------------------------------------------------------------------------ | ---------------------- | | \~/.cache/pants/lmdb\_store | local\_store\_dir | Content addressed process results, file contents, and directory digests | No, local disk only | | \~/.cache/pants/named\_caches | named\_caches\_dir | Tool caches handed into process sandboxes, such as the PEX root and the pip download cache | No, local disk only | | .pants.d | pants\_workdir | Per-run scratch: logs, materialized sandboxes, and the daemon state | No, local disk only | | A gRPC endpoint | remote\_store\_address | The same content addressed process results as the lmdb\_store | Yes | The lmdb\_store is where nearly all of the win lives. Pants computes a digest over each process it is about to run, covering the argv, the environment, the input file digests, and the execution platform, then looks that digest up in the store before executing anything. A hit means the process never runs. The remote cache serves the same content addressed data over the network, so the two tiers hold identical entries and a local hit means Pants never issues the remote lookup at all. The named caches work differently. Pants mounts those directories into process sandboxes so the underlying tools reuse their own downloads, and nothing in them is content addressed or reachable over the network. A cold `named_caches` means `pex` and `pip` refetch every wheel your requirements resolve to. The workdir at `.pants.d` is scratch. Delete it whenever you want; Pants rebuilds it. On a fresh GitHub Actions runner all three local directories start empty. The first `pants test ::` on that machine therefore executes every process in the graph, downloads every wheel, and writes tens of thousands of entries into an LMDB database that then evaporates when the runner terminates. Runner storage on WarpBuild is ephemeral and is deleted when the runner terminates, exactly as it is on GitHub-hosted runners, so the entire question is how you get that directory back. Pants runs on the Linux labels the same way it runs on a GitHub-hosted Ubuntu runner. [Snapshot runners](/runners/snapshot) are supported only on WarpBuild Cloud Ubuntu runners, and snapshot labels on any other runner type are silently ignored while the job runs normally. One layout decision matters before the configuration. A Pants remote cache is chatty, because a large graph issues tens of thousands of small lookups, so the distance between the runner and the cache endpoint shows up directly in wall clock time. BYOC runs on AWS, GCP, and Azure if you would rather put the runners in the same account and region as the cache service, and Terraform support exists for BYOC on AWS. ## [Configuration](#configuration)Copy Anchor Link Keep the workspace settings in `pants.toml` and put the GitHub Actions overrides in a second file that only the workflow selects. Pants reads the `PANTS_CONFIG_FILES` environment variable, so the split needs no flag plumbing in each step. pants.ci.toml Copy Text ``` [GLOBAL] pantsd = false dynamic_ui = false colors = true local_store_dir = "%(homedir)s/.cache/pants/lmdb_store" named_caches_dir = "%(homedir)s/.cache/pants/named_caches" local_store_files_max_size_bytes = 32000000000 local_store_directories_max_size_bytes = 4000000000 local_store_processes_max_size_bytes = 4000000000 [stats] log = true ``` Three settings in that file carry the weight. Both cache directories are pinned under the runner home rather than left at a platform default. Snapshot runners do not persist `/tmp`, which is cleaned on reboot, and booting from a snapshot is a reboot. Anything you want carried forward has to live outside `/tmp`, and the runner home satisfies that on hosted Linux labels. The three `local_store_*_max_size_bytes` values bound the LMDB database. Ubuntu labels carry a 150GB SSD, and an unbounded store on a long-lived snapshot alias grows until it competes with the checkout and the sandboxes for that disk. The 40 GB total above leaves room for a large monorepo checkout plus concurrent sandboxes on every size in the catalog. `[stats] log = true` prints cache counters at the end of every run. That output is the verification loop in the Proof section, and it costs nothing. ### [Caching the store](#caching-the-store)Copy Anchor Link The two directories want different cache keys, so they want separate entries. The lmdb\_store is content addressed, which means an entry written last Tuesday is still correct today and only incomplete. It wants a rolling key with a prefix fallback. The named caches track your lockfiles, so they want a lockfile hash. .github/workflows/pants.yml Copy Text ``` name: pants on: push: branches: [main] pull_request: env: PANTS_CONFIG_FILES: pants.ci.toml jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - uses: WarpBuilds/setup-python@v6 with: python-version: "3.12" - name: Compute the rolling store key id: day run: echo "day=$(date -u +%Y%m%d)" >> $GITHUB_OUTPUT - name: Restore the Pants local store id: lmdb uses: WarpBuilds/cache/restore@v1 with: path: ~/.cache/pants/lmdb_store key: pants-lmdb-${{ runner.os }}-${{ steps.day.outputs.day }} restore-keys: | pants-lmdb-${{ runner.os }}- - name: Restore the Pants named caches uses: WarpBuilds/cache@v1 with: path: ~/.cache/pants/named_caches key: pants-named-${{ runner.os }}-${{ hashFiles('**/*.lock') }} restore-keys: | pants-named-${{ runner.os }}- - name: Lint, check, test, package run: | pants lint check :: pants test :: pants package :: - name: Save the Pants local store if: github.ref == 'refs/heads/main' && steps.lmdb.outputs.cache-hit != 'true' uses: WarpBuilds/cache/save@v1 with: path: ~/.cache/pants/lmdb_store key: pants-lmdb-${{ runner.os }}-${{ steps.day.outputs.day }} ``` `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4`, so the inputs above are the ones you already know. The split `restore` and `save` form is what gates the write: the store is uploaded once per day from the default branch and every other run reads it, which keeps storage from growing by one full archive per job. Read the [caching documentation](/docs/ci/features/caching) for the full input list. Two properties of the cache decide whether a restore lands. Entries are scoped to the key, the version, and the branch, and the version is a hash over the compression tool and the list of cached paths. Change either path in the `path:` block and every previous entry becomes unreachable. That hash is also why a cache written on `warp-macos-15-arm64-6x` cannot restore on `warp-ubuntu-latest-x64-8x`. Entries expire after 7 days of last use, so an alias that only builds on release days will find nothing. Storage bills at $0.20 per GB-month and cache writes or restores at $0.0001 per operation on hosted runners, and both are free on BYOC. ### [Booting from a snapshot instead](#booting-from-a-snapshot-instead)Copy Anchor Link Past roughly 10 GB the archive round trip stops paying for itself. A snapshot carries the whole runner disk forward, so the lmdb\_store, the named caches, and the git checkout all arrive without a compress or decompress step. .github/workflows/pants-snapshot.yml Copy Text ``` jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=pants-main' }} env: PANTS_CONFIG_FILES: pants.ci.toml steps: - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Report snapshot state run: echo "booted from snapshot ${WARPBUILD_SNAPSHOT_KEY:-none}" - name: Lint, check, test, package run: | pants lint check :: pants test :: pants package :: - name: Report store size before capture if: github.ref == 'refs/heads/main' run: du -sh ~/.cache/pants/lmdb_store ~/.cache/pants/named_caches - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "pants-main" fail-on-error: true wait-timeout-minutes: 60 ``` `snapshot.enabled=true` always boots from the base image and lets the run capture a fresh snapshot; `snapshot.key=` boots from the existing snapshot for that alias and falls back to the base image when none exists. The [snapshot runner documentation](/docs/ci/features/snapshot-runners) covers both labels. The cleanup step is worth reading closely for a Pants repository. `git clean -ffdx` removes `.pants.d` and `dist/` because both are gitignored, which is the outcome you want: the workdir is scratch and the packaged artifacts are outputs. The lmdb\_store and the named caches live outside the build root, so they survive that command and land in the snapshot. Run the cleanup before capture in any case, because WarpBuild provisions runners at the organization level and GitHub may hand a snapshot-backed runner to a different job in the organization. Snapshots are deleted after 15 days, so the default branch job doubles as the refresher. If the default branch is quiet, add a scheduled trigger. The general shape of this tradeoff, across build systems, is written up in [incremental builds on GitHub Actions](/guides/incremental-builds-github-actions). ## [Sizing](#sizing)Copy Anchor Link Pants schedules a graph, so the runner size question is about how wide your graph gets rather than how large the repository is. `process_execution_local_parallelism` sets how many processes Pants runs at once and defaults to the core count of the machine. On a cold store every process slot is doing real work, so parallelism at the vCPU count is correct. On a warm store most slots resolve to a lookup instead of an execution, and the bottleneck moves to store reads, so raising parallelism above the core count buys nothing and costs memory. | Label | vCPU | RAM | Storage | Suggested parallelism | USD per minute | | ---------------------------- | ---- | ------ | --------- | --------------------- | -------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | 4 | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | 8 | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | 16 | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | 32 | $0.064 | | warp-ubuntu-latest-arm64-16x | 16 | 64 GB | 150GB SSD | 16 | $0.024 | Every Ubuntu size carries 4 GB of RAM per vCPU, which is the number that decides whether parallelism at the core count is safe. Pants materializes an input sandbox per process, so a test target that pulls a large model file or a wide dependency set holds that content on disk and in page cache for the life of the process. Thirty-two concurrent pytest processes each holding a gigabyte of fixtures will hit memory before they hit CPU, and the fix is a lower parallelism value rather than a larger label. The 150GB SSD is the other constraint, and it binds hardest on a snapshot alias. Three consumers share it: the checkout, the lmdb\_store, and the sandboxes materialized for in-flight processes. The size caps in `pants.ci.toml` hold the store at 40 GB; a monorepo checkout with full git history is often 5 GB to 20 GB; the sandboxes scale with parallelism. On a 32x label running 32 concurrent processes, budget the sandbox space explicitly before assuming the disk is free. Architecture matters for cache reuse. A `warp-ubuntu-latest-arm64-16x` job and a `warp-ubuntu-latest-x64-16x` job maintain separate key spaces in both tiers, because the execution platform is part of the Pants process digest and the compression tool plus paths hash is part of the cache version. Running the same targets on both architectures doubles your store rather than sharing it. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **A cold lmdb\_store.** This is the dominant cost on any runner that has never seen the workspace, and it is invisible in the logs unless you look for it. Pants reports counters, so read them rather than guessing. A run with `local_cache_requests_cached` near zero executed the entire graph. **Remote cache round trips.** A remote cache removes execution and adds latency. On a graph with tens of thousands of small processes, the per-lookup round trip dominates the transfer time, so endpoint distance matters more than bandwidth. Set `remote_cache_read = true` everywhere and keep `remote_cache_write` off for pull request jobs so an unreviewed branch cannot poison the shared store. Override it per job with `PANTS_REMOTE_CACHE_WRITE=false` rather than maintaining a third config file. **pantsd warmup.** The daemon holds the parsed BUILD graph and a file watcher in memory. On a laptop that state amortizes across dozens of invocations; inside a GitHub Actions job that terminates after one or two, the startup cost lands on the critical path and the memory competes with process sandboxes. Booting from a snapshot does not change this, because a snapshot boot is a reboot and no process survives it. Set `pantsd = false` and consolidate goals into a single invocation where the goal ordering allows it. **Shallow checkouts breaking `--changed-since`.** Selecting targets with `pants --changed-since=origin/main --changed-dependents=transitive test` needs the merge base in the local repository. The default `actions/checkout` depth of 1 does not have it, and Pants then either errors or selects the wrong target set. `fetch-depth: 0` in both workflows above is there for this reason. **Blind spots during the run.** CI observability reports OpenTelemetry system metrics from the runner agent correlated with the GitHub Actions job logs, which separates a CPU-bound run from one waiting on the network. When a cache miss only reproduces on the runner, the Action Debugger pauses the workflow and opens an SSH session on the machine so you can inspect the store and rerun `pants` by hand. ## [Proof](#proof)Copy Anchor Link Pants reports its own cache behavior, so verification is one config line and one look at the log tail. With `[stats] log = true` set, every run ends with a counters block: Copy Text ``` Counters: local_cache_requests: 4128 local_cache_requests_cached: 3901 local_cache_requests_uncached: 227 remote_cache_requests: 227 remote_cache_requests_cached: 154 remote_cache_requests_uncached: 73 ``` Track the ratio of `local_cache_requests_cached` to `local_cache_requests` over time. A drop after a toolchain bump means an input digest changed for every process, which surfaces here days before anyone notices the job got longer. Compare the same commit run twice: once on a cold label, once after a snapshot boot, and the difference between the two counter blocks is what the snapshot is worth on your graph. ### [A worked monthly cost model](#a-worked-monthly-cost-model)Copy Anchor Link Take a Pants monorepo running 900 GitHub Actions builds per month across pull requests and the default branch, at a median of 9 minutes of wall clock each on 16 vCPU. That is 8,100 runner minutes, with one snapshot alias kept warm for the month. | Line item | Rate | Quantity | Monthly USD | | ---------------------------------- | ------------------------ | --------------------- | ----------- | | warp-ubuntu-latest-x64-16x minutes | $0.032 per minute | 8,100 minutes | $259.20 | | Snapshot restore | $0.04 per job | 900 jobs | $36.00 | | Snapshot storage | $0.025 per snapshot-hour | 1 alias for 730 hours | $18.25 | | Cache storage | $0.20 per GB-month | 25 GB | $5.00 | | Cache write and restore operations | $0.0001 per operation | 1,800 operations | $0.18 | | **Total** | **$318.63** | | | The cache lines stay in the model even on a snapshot setup, because the alias expires after 15 days of no refresh and the daily store entry is what a cold boot falls back to. The same 8,100 minutes on a GitHub-hosted 16-core Linux x64 larger runner cost 8,100 multiplied by $0.042, which is $340.20, with no snapshot equivalent to fold in. GitHub list prices were checked on 2026-08-13 in the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). | vCPU | WarpBuild label | WarpBuild USD per minute | GitHub-hosted Linux x64 USD per minute | Lower list price | | ---- | -------------------------- | ------------------------ | -------------------------------------- | ---------------- | | 4 | warp-ubuntu-latest-x64-4x | $0.008 | $0.012 | 33 percent | | 8 | warp-ubuntu-latest-x64-8x | $0.016 | $0.022 | 27 percent | | 16 | warp-ubuntu-latest-x64-16x | $0.032 | $0.042 | 24 percent | | 32 | warp-ubuntu-latest-x64-32x | $0.064 | $0.082 | 22 percent | Runner shapes are matched on both sides: each row compares the same vCPU and RAM. GitHub list prices checked on 2026-08-13. The full per-minute rate list is on the [pricing page](/pricing). A workspace that also drives Bazel or moon targets has the same layered-state problem with different directory names; [running Bazel on GitHub Actions with remote cache](/solutions/bazel-github-actions) and [moon monorepo builds on GitHub Actions](/solutions/moon-github-actions) cover those. ## [FAQ](#faq)Copy Anchor Link ### [Should I cache the Pants lmdb\_store or boot a snapshot runner?](#should-i-cache-the-pants-lmdb%5Fstore-or-boot-a-snapshot-runner)Copy Anchor Link Cache the store while it is small enough that the archive round trip stays under about a minute. Once the store passes roughly 10 GB the compress, upload, download, and decompress cycle costs more than the process executions it saves, and a snapshot runner that carries the same directory forward on disk removes the archive step entirely. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. ### [Why cache lmdb\_store and named\_caches as separate entries?](#why-cache-lmdb%5Fstore-and-named%5Fcaches-as-separate-entries)Copy Anchor Link The cache version is a hash over the compression tool and the list of cached paths, so a single entry covering both directories is invalidated whenever you change either path. Separate entries also let you key them differently: the lmdb\_store is content addressed and stays valid forever, so it wants a rolling date key, while `named_caches` tracks your lockfiles and wants a lockfile hash. ### [Does pantsd help inside a GitHub Actions job?](#does-pantsd-help-inside-a-github-actions-job)Copy Anchor Link Only when the job invokes `pants` more than once. The daemon holds the parsed BUILD graph and the file watcher in memory, and that state dies with the runner, so a job that runs a single `pants` command pays the startup cost and collects none of the benefit. Set `pantsd = false` in `pants.ci.toml` and run lint, check, test, and package in one invocation where the goals allow it. ### [Can I use snapshot runners for a macOS Pants build?](#can-i-use-snapshot-runners-for-a-macos-pants-build)Copy Anchor Link No. Snapshot labels on macOS, Windows, and BYOC runners are silently ignored and the job runs normally with no snapshot behavior. WarpBuild cache is also unsupported on Windows runners. Keep the Pants remote cache and a pinned local store for those platforms. --- # Faster PHP Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/php-github-actions Description: PHP builds on GitHub Actions stall on cold Composer installs and 2 vCPU runners. Fix both with warp- runner labels, WarpBuilds/cache, and correct sizing. # Faster PHP Builds on GitHub Actions PHP builds on GitHub Actions stall on cold Composer installs and 2 vCPU runners. Fix both with warp- runner labels, WarpBuilds/cache, and correct sizing. Last verified: 2026-08-13 ## [Overview](#overview)Copy Anchor Link PHP builds on GitHub Actions spend most of their wall clock repeating work that never changes between runs: a cold vendor directory, PHP extensions compiled at job start, an autoloader dumped from scratch, and a test database migrated and seeded before the first assertion. Point `runs-on` at a WarpBuild `warp-` label, cache the Composer directories with `WarpBuilds/cache@v1` keyed on `composer.lock`, and size the runner to the number of PHPUnit or Pest processes you actually run. The default GitHub-hosted Linux runner on private repositories carries 2 vCPUs and 8GB of RAM, per GitHub's published runner specs (checked on 2026-08-13). A PHPUnit suite launched with four parallel processes oversubscribes that machine before MySQL and Redis start. Service containers make the squeeze tighter, because they run on the same virtual machine as the job and draw from the same CPU and memory budget as the test processes. WarpBuild's Linux images carry the same tooling as GitHub-hosted runners, so your PHP setup action, Composer, PHPUnit, Pest, and Paratest all run unchanged, and the only required edit to a working workflow is the `runs-on` line. Runners are ephemeral virtual machines, allocated per job and destroyed when the job ends. The full label list, shapes, and rates are on the [cloud runners documentation page](/docs/ci/cloud-runners). The rest of this page walks through a working Composer cache configuration, sizing for PHPUnit and Pest suites that carry MySQL and Redis service containers, the four bottlenecks that dominate PHP jobs, and how to check the numbers yourself. For the language-neutral version of the same exercise, read the [checklist for speeding up GitHub Actions](/guides/speed-up-github-actions). ## [Configuration](#configuration)Copy Anchor Link WarpBuild maintains drop-in replacements for several upstream `setup-*` actions, including `WarpBuilds/setup-node@v6`, `setup-python@v6`, `setup-go@v6`, `setup-java@v5`, `setup-dotnet@v4`, `setup-ruby@v1.295.0`, `rust-cache@v2`, `gradle-actions/setup-gradle@v5`, `mise-action@v2`, and `setup-zig@v2`. Those forks route toolchain and dependency caching through the WarpBuild cache with no extra configuration. PHP has no fork in that list, so a PHP workflow keeps whichever setup action it already uses and calls `WarpBuilds/cache@v1` directly for the Composer directories. The workflow below runs a PHPUnit suite on `warp-ubuntu-latest-x64-8x` (8 vCPUs, 32GB of RAM) and caches both Composer directories against the lockfile. Copy Text ``` name: php-tests on: push: branches: [main] pull_request: jobs: phpunit: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - name: Set up PHP uses: shivammathur/setup-php@v2 with: php-version: "8.3" extensions: mbstring, intl, pdo_mysql, redis, bcmath coverage: none - name: Resolve the Composer cache directory id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" - name: Restore Composer cache and vendor id: composer-restore uses: WarpBuilds/cache@v1 with: path: | ${{ steps.composer-cache.outputs.dir }} vendor key: ${{ runner.os }}-php83-composer-${{ hashFiles('composer.lock') }} restore-keys: | ${{ runner.os }}-php83-composer- - name: Install dependencies run: composer install --prefer-dist --no-interaction --no-progress - name: Run tests run: vendor/bin/paratest --processes=8 ``` `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4`, so `path`, `key`, `restore-keys`, `fail-on-cache-miss`, and the `cache-hit` output behave exactly as they did before. Three properties change how you write PHP cache keys. Entries are scoped to the key, the cache version, and the branch. The version is a hash over the compression tool and the exact list of cached paths, so a cache saved on a macOS runner cannot restore on a Linux runner, and adding a directory to `path` starts a fresh entry instead of reusing the old one. Keep the PHP version in the key, as `php83` does above, so an upgrade to 8.4 never restores an extension-linked vendor tree built for 8.3. Entries expire 7 days after last use. A long-lived branch that goes quiet for a week starts cold on its next push, which is the usual explanation for a single slow run on an old pull request. The `restore-keys` prefix carries most of the value in PHP. When `composer.lock` changes by one package, the exact key misses, the prefix restores the most recent entry on the branch, and `composer install` downloads and extracts only the delta. Without the prefix, a one-line dependency bump pays for a full cold install. Cache carrying cost is small and visible on the invoice: storage bills at $0.20 per GB-month and each cache read or write at $0.0001, so keeping a 400MB Composer cache warm runs about $0.08 a month. The split restore and save variants, the container requirements, and the full input list are in the [WarpBuild caching documentation](/docs/ci/features/caching). ## [Sizing](#sizing)Copy Anchor Link Runner size sets the ceiling on parallel test processes, and PHP suites usually pay for the database before they pay for the tests. These are the Linux x64 sizes worth considering, with WarpBuild rates from the [pricing page](/pricing) and the nearest GitHub-hosted shape alongside. | Runner label | vCPU | RAM | Price per minute | Comparable GitHub-hosted runner | GitHub list price | Difference | | -------------------------- | ---- | ---- | ---------------- | ----------------------------------------------------- | ----------------- | --------------------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | $0.004 | ubuntu-latest, 2 vCPU and 8GB on private repositories | $0.006 | 33 percent lower list price | | warp-ubuntu-latest-x64-4x | 4 | 16GB | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent lower list price | | warp-ubuntu-latest-x64-8x | 8 | 32GB | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent lower list price | | warp-ubuntu-latest-x64-16x | 16 | 64GB | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent lower list price | Every size in the table carries 150GB of SSD storage. GitHub list prices come from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and the shapes from the [GitHub-hosted runner specs](https://docs.github.com/en/actions/reference/runners/github-hosted-runners), both checked on 2026-08-13. **4x (4 vCPUs, 16GB).** Unit-heavy suites with an in-memory or SQLite database, and feature suites that run three parallel processes or fewer. If MySQL and Redis run as service containers on the same runner, cap Paratest or Pest at three processes and leave a core for the database. Migrations and seeding are single-threaded, so the fourth core sits idle during setup regardless. **8x (8 vCPUs, 32GB).** The default for a Laravel or Symfony suite with database-backed feature tests. Eight test processes each hold a PDO connection and a fixture set, MySQL gets a full core to itself during seeding, and 32GB leaves room for a per-process database. This is the size most PHP teams settle on. **16x (16 vCPUs, 64GB).** Worth it when you consolidate a matrix into one job, or when a monorepo runs several packages' suites in the same run. Time your migration and seeding phase first: it runs once, serially, and no runner size shortens it. Service containers share the runner with the job, so plan the budget together: Copy Text ``` jobs: feature: runs-on: warp-ubuntu-latest-x64-8x services: mysql: image: mysql:8.4 env: MYSQL_ROOT_PASSWORD: test MYSQL_DATABASE: app_test ports: - 3306:3306 options: >- --health-cmd "mysqladmin ping -h 127.0.0.1" --health-interval 5s --health-timeout 5s --health-retries 20 redis: image: redis:7 ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 5s --health-retries 10 ``` Health checks matter more than the image tags. Without them, workflows fall back to a fixed sleep, which is either too short and flaky or too long and wasteful. MySQL 8 starts with a 128MB InnoDB buffer pool by default and Redis holds very little, so the pair is cheap in RAM and expensive in CPU during migration and seeding. That is the argument for 8 vCPUs rather than 16GB more memory. To confirm a sizing choice instead of guessing, WarpBuild's CI observability reports CPU and memory metrics from the runner agent correlated with the GitHub Actions job logs, so you can see whether eight Paratest processes saturate the machine or whether six of them wait on MySQL. If you want the decision procedure on its own, the [runner size answer page](/answers/how-do-i-choose-a-github-actions-runner-size) walks through it. ### [A worked cost model](#a-worked-cost-model)Copy Anchor Link Assume a feature suite that takes 14 minutes on GitHub's standard 2 vCPU hosted runner and runs 300 times a month. Assume your own measurement puts it at 8 minutes on 4 vCPUs and 5 minutes on 8 vCPUs. Substitute your real numbers, because parallel efficiency depends on how much time the suite spends waiting on the database. | Setup | Wall clock | Rate per minute | Cost per run | Cost per month (300 runs) | | ---------------------------------- | ---------- | --------------- | ------------ | ------------------------- | | GitHub-hosted, 2 vCPU | 14 min | $0.006 | $0.084 | $25.20 | | GitHub-hosted 8-core larger runner | 5 min | $0.022 | $0.110 | $33.00 | | warp-ubuntu-latest-x64-8x | 5 min | $0.016 | $0.080 | $24.00 | | warp-ubuntu-latest-x64-4x | 8 min | $0.008 | $0.064 | $19.20 | Two results fall out of the arithmetic. Buying more cores from GitHub raises the monthly bill even when the suite finishes sooner, because the rate climbs faster than the wall clock drops. And for PHP specifically, the 4x row often wins on total cost while the 8x row wins on pull request feedback time, so the choice is a scheduling decision rather than a budget one. ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four things dominate slow PHP jobs on GitHub Actions. Each has a specific fix, and the first three are cache problems. ### [Cold vendor directory](#cold-vendor-directory)Copy Anchor Link A cold `composer install` resolves the lockfile, downloads every package archive, extracts each one, and writes thousands of small files. A mid-sized Laravel application pulls a few hundred packages, and the extraction step alone is disk-bound. The workflow above fixes this by caching the Composer cache directory and `vendor` together: the archives survive a lockfile change, and the extracted tree makes a warm install close to a no-op. Add `--prefer-dist` so Composer takes package archives instead of cloning repositories, and avoid `--no-cache`, which is occasionally copied from Docker build advice where it belongs. ### [Autoloader dumps on every run](#autoloader-dumps-on-every-run)Copy Anchor Link Composer regenerates the autoloader after every install, and on a large codebase the classmap scan is a real cost. Two settings shrink it. Set `optimize-autoloader` in the `config` block of `composer.json` so production-shaped classmaps are the default, and pass `--classmap-authoritative` in jobs that never load classes outside the map, which drops the filesystem fallback lookups entirely. If your `post-autoload-dump` scripts run package discovery or clear framework caches, run `composer install --no-scripts` and invoke the specific script you need afterwards, so a cached vendor tree stops paying for work it already did. ### [Extension installs through setup-php](#extension-installs-through-setup-php)Copy Anchor Link PHP setup actions install extensions in three different ways: some ship precompiled, some come from a package repository, and the rest build through PECL inside the job. Extensions that build from source, such as imagick and some Redis or gRPC builds, are the slowest step in many PHP workflows and can dwarf the test run itself. Three mitigations, in order of value. Ask for the smallest extension list your suite actually needs, since every entry is a potential compile. Set `coverage: none` unless the job publishes coverage, which skips the Xdebug install altogether, and use PCOV rather than Xdebug on the one job that does need line coverage. Cache the resolved extension directory with `WarpBuilds/cache@v1`, keyed on the PHP version and the exact extension list, so the compile happens once per key instead of once per job. ### [Database fixtures rebuilt per job](#database-fixtures-rebuilt-per-job)Copy Anchor Link A suite that migrates from zero and seeds fixtures on every job pays the same serial cost forever. Three fixes stack. Dump the migrated schema to a SQL file, cache it against a hash of the migrations directory, and load the dump when the hash matches, so migrations run only when they change. Give each parallel process its own database created from a template, which removes the deadlocks that appear when eight processes truncate the same tables. And wrap each test in a transaction that rolls back, so seeding runs once per process rather than once per test. When a failure reproduces only inside GitHub Actions, the WarpBuild Action Debugger pauses the workflow and opens an SSH session on the live runner, so you can inspect the database, the extension list, and the cache state on the machine that failed instead of pushing another commit with echo statements. ## [Proof](#proof)Copy Anchor Link Start with the numbers on this page. Every WarpBuild rate above comes from the [pricing page](/pricing) and the [cloud runners documentation](/docs/ci/cloud-runners), and every GitHub list price is linked to GitHub's own billing reference with the date it was checked. Cost and performance claims on WarpBuild pages carry a number, a source link, and a checked-on date, and the same number appears on every surface, so you can reproduce the arithmetic in the Sizing table with a calculator. Teams running a mixed stack can apply the same runner and cache pattern elsewhere: the [Ruby on GitHub Actions page](/solutions/ruby-github-actions) covers Bundler and RSpec, and the [Docker Compose integration test page](/solutions/docker-compose-integration-tests-github-actions) covers suites that bring up a full service graph instead of two service containers. ## [FAQ](#faq)Copy Anchor Link ### [Is there a WarpBuild setup-php action?](#is-there-a-warpbuild-setup-php-action)Copy Anchor Link No. WarpBuild maintains drop-in forks for setup-node, setup-python, setup-go, setup-java, setup-dotnet, setup-ruby, rust-cache, setup-gradle, setup-zig, and mise-action. PHP has no fork, so keep the PHP setup action you already use and add WarpBuilds/cache@v1 for the Composer directories. ### [Should I cache the Composer cache directory or the vendor directory?](#should-i-cache-the-composer-cache-directory-or-the-vendor-directory)Copy Anchor Link Cache both. The Composer cache directory holds downloaded package archives and survives a lockfile change, while vendor holds the extracted tree and makes a warm install close to a no-op. Key on composer.lock and add a restore-keys prefix so a one-package bump still restores. ### [What runner size fits a PHPUnit or Pest suite with MySQL and Redis?](#what-runner-size-fits-a-phpunit-or-pest-suite-with-mysql-and-redis)Copy Anchor Link Start at warp-ubuntu-latest-x64-8x, which gives 8 vCPUs and 32GB of RAM. Service containers run on the same virtual machine as the job, so the database and cache draw from the same budget as your test processes. A 4x runner works for suites that run three test processes or fewer. ### [What does it cost to run PHP builds on WarpBuild?](#what-does-it-cost-to-run-php-builds-on-warpbuild)Copy Anchor Link In the worked model above, a five-minute run on `warp-ubuntu-latest-x64-8x` costs $0.080, or $24.00 across 300 runs. Substitute your suite's measured wall clock and the current rate from the [pricing page](/pricing). --- # Playwright Test Suites on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/playwright-github-actions Description: Shard Playwright across warp- runners on GitHub Actions, cache the browser binaries, merge the blob reports, and size each shard from real per-minute rates. # Playwright Test Suites on GitHub Actions Shard Playwright across warp- runners on GitHub Actions, cache the browser binaries, merge the blob reports, and size each shard from real per-minute rates. Last verified: 2026-08-13 To run Playwright tests on GitHub Actions, split the suite across a shard matrix, cache the browser binaries under `~/.cache/ms-playwright` so every job stops downloading them, and merge the per-shard blob reports into one HTML report at the end. On WarpBuild runners the shape stays the same and two things change: the `runs-on` label, and the cache action, where [WarpBuilds/cache](https://github.com/WarpBuilds/cache) is a drop-in replacement for `actions/cache@v4` that is enabled by default on Linux runners. Sharding fixes wall clock, caching fixes the browser download, and worker count decides whether the vCPUs you pay for are doing anything. This page covers the workflow configuration for a sharded Playwright run, the sizing call between 4, 8, and 16 vCPU runners, the four bottlenecks that dominate Playwright pipelines, the cost math against GitHub-hosted list prices, and how to read shard duration and queue time out of the reports once it is running. ## [Overview](#overview)Copy Anchor Link Every GitHub Actions job starts on a fresh virtual machine. For Playwright that means two empty directories at the start of every job: `node_modules`, and the browser cache that `npx playwright install` writes to `~/.cache/ms-playwright` on Linux. The browser install pulls a full browser build for each configured project plus the system libraries those builds link against, and it repeats on every job in the matrix unless a cache restores it. The second default that shapes the run is worker count. The config that `npm init playwright` generates sets `workers: process.env.CI ? 1 : undefined`, so the suite that used half your laptop's cores locally runs one test at a time on GitHub Actions. Playwright's own default when `workers` is unset is half the logical CPU count. A 4 vCPU runner executing one worker is a machine paid for and idle. Sharding is the third piece. `--shard=/` splits the test list across jobs, each shard writes a blob report, and `merge-reports` reassembles them afterwards. Wall clock then tracks the slowest shard rather than the total suite time, which is what most teams actually want from a pull-request check. Playwright work belongs on Linux x64 for two reasons: the browser builds and system libraries are best supported there, and the [caching documentation](/docs/ci/features/caching) states that WarpBuild Cache is not supported on Windows runners, which would put the browser cache back on a cold path. One boundary before the configuration. This page covers Playwright. A Cypress suite has a different runner model, a different cache directory, and a different parallelization mechanism, and it is covered on the [Cypress on GitHub Actions](/solutions/cypress-github-actions) page. ## [Configuration](#configuration)Copy Anchor Link This workflow shards a Playwright suite four ways, restores the browser binaries by Playwright version, writes a blob report per shard, and merges the blobs in a job that runs after the matrix: Copy Text ``` name: playwright on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci - name: Read the Playwright version id: pw run: | echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" - name: Restore Playwright browsers id: browsers uses: WarpBuilds/cache@v1 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ steps.pw.outputs.version }} - name: Install browsers and system libraries if: steps.browsers.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium - name: Install system libraries only if: steps.browsers.outputs.cache-hit == 'true' run: npx playwright install-deps chromium - name: Run shard ${{ matrix.shard }} env: PLAYWRIGHT_BLOB_OUTPUT_FILE: blob-report/report-${{ matrix.shard }}.zip run: npx playwright test --shard=${{ matrix.shard }}/4 --workers=3 --reporter=blob - uses: actions/upload-artifact@v4 if: always() with: name: blob-report-${{ matrix.shard }} path: blob-report/report-${{ matrix.shard }}.zip retention-days: 3 report: if: always() needs: test runs-on: warp-ubuntu-latest-x64-2x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci - uses: actions/download-artifact@v4 with: path: all-blob-reports pattern: blob-report-* merge-multiple: true - run: npx playwright merge-reports --reporter=html ./all-blob-reports - uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report retention-days: 7 ``` Four details in that file carry the weight. **The cache key hashes the Playwright version.** Browser builds are pinned to the Playwright release, so `1.56.0` and `1.57.0` need different binaries while an unrelated dependency bump needs none. A key built from the lockfile hash would roll on every dependency change and throw away a perfectly good browser cache. Reading the version out of `@playwright/test/package.json` into a step output keeps the key rolling exactly when the browsers change. **A cache hit still needs `install-deps`.** The cache covers the browser binaries under `~/.cache/ms-playwright`. It does not cover the apt packages the browsers link against, which live in system directories outside that path. Running `npx playwright install-deps` on the hit branch keeps the restore correct without re-downloading browsers. **The blob reporter carries the shard index.** Each shard writes its own zip, the artifact name repeats the index so uploads never collide, and `merge-reports` reassembles a single HTML report in the `report` job. `fail-fast: false` keeps the other shards running when one fails, which is the only way the merged report describes the whole suite. **`WarpBuilds/setup-node` routes dependency caching through WarpBuild Cache.** It is a drop-in for `actions/setup-node`, and on WarpBuild runners the npm cache it manages is served by WarpBuild Cache with no extra configuration. Two behaviors from the [caching documentation](/docs/ci/features/caching) matter before you rely on the browser cache. Entries are scoped to key, version, and branch, so seed the cache on `main` and let branch builds restore from it. The version hash covers the compression tool and the cached paths, which means a cache written on a macOS runner cannot restore on a Linux runner even with an identical key. Entries expire 7 days after last use, so an active repository stays warm and an abandoned branch stops costing storage on its own. Cache usage is metered at $0.20 per GB-month of storage and $0.0001 per write or restore operation on hosted runners, and it is free on BYOC. ## [Sizing](#sizing)Copy Anchor Link Worker count is the number that connects Playwright to the runner you picked. Each worker is a separate process driving its own browser context, so workers scale with vCPU until memory or the application under test becomes the limit. These are the Linux x64 rows that matter for a sharded suite: | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | Pick the size from what the suite can keep busy: **4x for a small suite.** Four vCPU and 16GB run 3 workers with headroom for the application under test in the same job. This is the default for a few hundred tests split into three or four shards, and it is where most Playwright pipelines should start. The merge job needs almost nothing, so send it to 2x at $0.004 per minute. **8x when workers saturate.** If every worker on a 4x shard stays busy and the shard is still the long pole, 8 vCPU and 32GB carry 6 or 7 workers. This is the size for suites where each test drives a heavy single-page application, or where the shard also runs a dev server, a database, and a queue alongside the browsers. **16x for suites that saturate 8 workers.** Sixteen vCPU and 64GB earns its rate when a shard runs a full application stack next to a dozen browser processes and memory becomes the limit before CPU does. Check utilization before buying it: a shard that never passes 50 percent CPU on 8x gains nothing from 16x, and the fix there is more shards. Set `--workers` explicitly on every size. Leaving it at the scaffolded `1` wastes the machine, and leaving it unset gives you half the cores, which is conservative when the application under test lives outside the job. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners on the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the standard `ubuntu-latest` runner for private repositories (2 vCPU) is $0.006 per minute, and the larger Linux runners are $0.012 for 4 vCPU, $0.022 for 8 vCPU, and $0.042 for 16 vCPU. | Machine | vCPU | Per-minute rate | Source | | --------------------------------- | ---- | --------------- | ------------------------------------- | | GitHub-hosted standard Linux | 2 | $0.006 | GitHub list price, checked 2026-08-13 | | GitHub-hosted Linux larger runner | 4 | $0.012 | GitHub list price, checked 2026-08-13 | | GitHub-hosted Linux larger runner | 8 | $0.022 | GitHub list price, checked 2026-08-13 | | warp-ubuntu-latest-x64-4x | 4 | $0.008 | WarpBuild pricing | | warp-ubuntu-latest-x64-8x | 8 | $0.016 | WarpBuild pricing | Take a concrete team: a Playwright suite that runs 550 times a month across pull requests and pushes to `main`, sharded four ways, with each shard taking 9 minutes on a 4 vCPU machine. That is 19,800 shard minutes a month, plus a 2 minute merge job per run. | Line item | Rate | Monthly volume | Monthly cost | | ----------------------------------------- | ----------------------------------------- | -------------- | ------------ | | GitHub-hosted Linux larger runner, 4 vCPU | $0.012/min | 19,800 min | $237.60 | | warp-ubuntu-latest-x64-4x, 4 vCPU | $0.008/min | 19,800 min | $158.40 | | Merge job on warp-ubuntu-latest-x64-2x | $0.004/min | 1,100 min | $4.40 | | Browser cache storage and operations | $0.20 per GB-month, $0.0001 per operation | 2GB, 2,500 ops | $0.65 | The WarpBuild column totals $163.45 a month against $237.60 for the same minutes on GitHub-hosted 4-core runners, before any change to how long a shard takes. Stated as list-price arithmetic: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13\. The same arithmetic at the next size up: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13. Every size and platform is listed on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four things account for most slow Playwright pipelines on GitHub Actions. **Browser downloads on every run.** Without a restored `~/.cache/ms-playwright`, every shard downloads a browser build per configured project before the first test starts, and a three-browser project multiplies that by three across every job in the matrix. The cache step above removes it. Confirm the restore worked by reading `steps.browsers.outputs.cache-hit` in the job log rather than assuming it: a mistyped path or a version key that moved will silently fall through to a full download. **One worker per job.** The scaffolded `workers: process.env.CI ? 1 : undefined` is the single most common reason a Playwright suite runs slowly on GitHub Actions. The suite executes serially while the other cores sit idle, and adding shards papers over it while multiplying the runner minutes. Set the worker count per runner size first, then decide how many shards you need. **Video and trace artifacts.** `trace: 'on'` and `video: 'on'` write a file per test, and the upload step then compresses and ships gigabytes of them. The scaffolded `trace: 'on-first-retry'` is the setting worth keeping, with `video: 'retain-on-failure'` when a video actually helps triage. Setting a short `retention-days` on the artifact keeps the storage from accumulating across every pull request. **Flaky retries inflating wall clock.** With `retries: 2`, one flaky test costs up to three times its runtime, and it lands entirely on the shard that owns it, so the merged run waits for a single misbehaving spec. Retries hide the flake instead of fixing it, and the shard duration is where the cost shows up. The playbook for finding and removing them is in the guide on [fixing flaky GitHub Actions jobs](/guides/flaky-github-actions-jobs). ### [Reading shard duration and queue time](#reading-shard-duration-and-queue-time)Copy Anchor Link Guessing which shard is the long pole wastes more time than measuring it. The [reports documentation](/docs/ci/features/reports) covers three sections, and two of them answer this directly. The Jobs report aggregates every run of a unique repository, workflow, and job name combination, and gives Duration P75 and P90, Queue Time P75 and P90, plus CPU and Memory at the same two percentiles when observability is enabled. Because a sharded matrix produces one row per shard, the spread between the fastest and slowest shard row shows up immediately, and an uneven split shows as one row with a Duration P90 well above its siblings. A shard whose CPU P75 sits low is the signal that the worker count, rather than the runner size, is the constraint. The Queue Timings report breaks queue wait down per runner label and stack with Queue Time P75 and P90\. Read it before blaming a shard: time spent waiting for a runner is time the Jobs report attributes to queueing rather than execution, and the two together separate a slow suite from a slow start. Both reports support date ranges, filtering, and CSV export when you want the numbers next to your own history. The reasoning behind reading P75 and P90 instead of averages is covered in the guide on [GitHub Actions build duration percentiles](/guides/github-actions-build-duration-percentiles). When the cause is still unclear, WarpBuild's CI observability reports OpenTelemetry-based system metrics from the runner agent correlated with GitHub Actions job logs, which separates a CPU-bound worker pool from a shard stuck on a network call. The Action Debugger pauses a workflow and opens an SSH session on the runner, so you can list `~/.cache/ms-playwright` on the machine itself and see exactly what the restore produced. ## [Proof](#proof)Copy Anchor Link Public OSS repositories running `warp-` labels are citable evidence, and two of them run Playwright in the open: * [documenso/documenso](https://github.com/documenso/documenso/blob/main/.github/workflows/e2e-tests.yml) runs its Playwright Tests workflow on `warp-ubuntu-2204-x64-8x`, starting its services and database in the job before `npx playwright install --with-deps` and the suite itself, then uploading `test-results` with a 7 day retention (checked on 2026-08-13). * [appsmithorg/appsmith](https://github.com/appsmithorg/appsmith/blob/release/.github/workflows/ci-test-playwright.yml) runs its Playwright end-to-end job on `warp-ubuntu-latest-x64-4x`, caches `~/.cache/ms-playwright`, runs the suite with `--reporter=blob`, reruns only failures with `--last-failed`, and merges the blobs with `npx playwright merge-reports` (checked on 2026-08-13). The second one is worth reading in full if your suite has flaky specs, because the rerun-failures pattern it uses keeps the blob report intact across both attempts. The same sharding and caching shape applies to the rest of a JavaScript pipeline; the [Node.js on GitHub Actions](/solutions/nodejs-github-actions) page covers the install and build phases that run before the browsers start. ## [FAQ](#faq)Copy Anchor Link **How many shards should a Playwright suite use?** Enough that the slowest shard finishes inside the wall clock you want, and no more. Each extra shard repeats checkout, install, and browser restore, so a suite that already finishes in four minutes per shard gains little from doubling the matrix. **Why does Playwright run one worker on GitHub Actions?** The config generated by `npm init playwright` sets `workers` to 1 when `process.env.CI` is set. That serializes the suite on a machine with 4 or more cores, so set the worker count explicitly per runner size. **What should the Playwright browser cache key hash?** The installed Playwright version, since browser builds are pinned to it. Read it from `@playwright/test/package.json` into a step output and put that value in the cache key so a Playwright upgrade rolls the entry. **How do I get one report out of a sharded run?** Run each shard with `--reporter=blob`, upload the per-shard zip as an artifact, then run `npx playwright merge-reports --reporter=html` over the downloaded blobs in a job that needs the matrix. --- # pnpm Cache on GitHub Actions: Store and Workspaces | WarpBuild URL: https://www.warpbuild.com/solutions/pnpm-github-actions Description: Cache the pnpm store with WarpBuilds/setup-node keyed on pnpm-lock.yaml, then map pnpm --filter onto a matrix of warp- runner sizes for each workspace package. # pnpm Cache on GitHub Actions: Store and Workspaces Cache the pnpm store with WarpBuilds/setup-node keyed on pnpm-lock.yaml, then map pnpm --filter onto a matrix of warp- runner sizes for each workspace package. Last verified: 2026-08-13 To cache pnpm on GitHub Actions, save the content-addressable store that `pnpm store path` reports and key it on the hash of `pnpm-lock.yaml`, so that `pnpm install --frozen-lockfile` links packages out of local disk instead of fetching them from the registry. On WarpBuild runners, [WarpBuilds/setup-node](https://github.com/WarpBuilds/setup-node) with `cache: pnpm` performs that save and restore through WarpBuild Cache without a separate cache step. A workspace adds a second decision on top of the cache: which packages a given job installs and which runner size it deserves. This page covers the store configuration for `WarpBuilds/setup-node@v6`, how `pnpm --filter` maps onto a matrix of `warp-` labels, the three bottlenecks that dominate pnpm pipelines, and the per-minute arithmetic against GitHub-hosted list prices. ## [Overview](#overview)Copy Anchor Link Every GitHub Actions job starts on a fresh virtual machine with an empty disk. For a pnpm workspace that means the store is gone, `node_modules` is gone, and the install step resolves and downloads the entire dependency graph before the first test file loads. pnpm keeps three layers on disk, and only one of them is worth caching. The **store** is a global content-addressable directory holding each package version once, addressed by the hash of its contents. `pnpm store path` prints its location, which varies by platform and by whether a `store-dir` setting is in effect. This is the layer to cache. The **virtual store** at `node_modules/.pnpm` holds one directory per package version, with the files hardlinked out of the global store. Hardlinks are close to free, which is why a warm store makes an install fast even though every package still lands in the virtual store. Each package's own `node_modules` then holds symlinks into the virtual store, which is what gives pnpm its strict resolution: a package can only import what its own manifest declares. Caching `node_modules` instead of the store fails for a structural reason. A workspace with 30 packages produces 30 symlink farms plus the virtual store, all pointing at absolute paths that only exist on the machine that created them. Restoring those links onto a different runner, or into a different workspace root, leaves dangling entries that surface as module resolution errors halfway through a test run. The store carries no path assumptions, so it restores cleanly and lets the install rebuild the links. One detail decides whether a warm store is fast or merely warm. Hardlinks work within a single filesystem, so when the store and the workspace sit on different volumes pnpm falls back to copying every file. WarpBuild Linux runners expose a single 150GB SSD volume, so the default store location and the checkout land on the same filesystem and the link path holds. Moving a job onto one is a change to the `runs-on` line. WarpBuild Cache is available on Linux runners and is not supported on Windows runners, so keep the store cache steps on Linux jobs and let any Windows leg of a matrix install cold. Three boundaries before the configuration. A single-package repository with one lockfile is a simpler problem, covered on [Node.js dependency caching on GitHub Actions](/solutions/nodejs-github-actions). Caching the outputs of workspace tasks rather than the packages they consume is a different mechanism again, covered on [Turborepo remote caching on GitHub Actions](/solutions/turborepo-github-actions). A workspace that installs with Bun has its own store layout and its own lockfile name, covered on [Bun installs and tests on GitHub Actions](/solutions/bun-github-actions). ## [Configuration](#configuration)Copy Anchor Link The short form uses the WarpBuild fork of the Node setup action. From v6 that action turns npm caching on automatically when `package.json` declares npm as the package manager; pnpm always needs `cache: pnpm` set explicitly. Copy Text ``` name: pnpm-ci on: push: branches: [main] pull_request: jobs: install-and-test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: pnpm/action-setup@v4 with: version: 10 - name: Resolve pnpm store directory id: pnpm-store run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: pnpm cache-dependency-path: pnpm-lock.yaml - run: pnpm install --frozen-lockfile - run: pnpm -r run typecheck - run: pnpm -r run test ``` Order matters in that job. The `cache: pnpm` input resolves the store by asking pnpm itself, so `pnpm/action-setup` has to run before the setup action or the workflow fails on the cache step. The explicit store resolution step is useful even when the setup action handles the cache, because `steps.pnpm-store.outputs.path` gives you a value to print in logs and to reuse in a later prune or size check. `cache-dependency-path` accepts globs and multiple paths. A single root `pnpm-lock.yaml` is the normal case for a workspace, since pnpm resolves every package in `pnpm-workspace.yaml` into that one lockfile. Point it at the lockfile rather than at `package.json`, or every script or metadata edit invalidates a store that was perfectly good. The full input list for each fork is in the [setup actions documentation](/docs/ci/features/caching/setup-actions). When you want direct control over the key and its fallbacks, use [WarpBuilds/cache](https://github.com/WarpBuilds/cache), a drop-in replacement for `actions/cache@v4`: Copy Text ``` - name: Restore pnpm store uses: WarpBuilds/cache@v1 with: path: ${{ steps.pnpm-store.outputs.path }} key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | pnpm-${{ runner.os }}- ``` Four cache behaviors are worth knowing before you rely on either form. Entries are scoped to the key, the version, and the branch, so a store saved on a feature branch is separate from the one on the default branch; seed the cache on the default branch and let branch builds reach it through `restore-keys`. The version hash covers the compression tool and the cached paths, so a store saved on a macOS runner cannot restore on a Linux runner even under an identical key. Entries expire 7 days after last use, so active repositories stay warm and abandoned branches release their storage without a cleanup job. Cache metering is small enough to state exactly: $0.20 per GB-month of storage and $0.0001 per write or restore operation on hosted runners, and free on BYOC. ## [Sizing](#sizing)Copy Anchor Link WarpBuild Linux x64 runners carry 4GB of memory per vCPU across the range. These are the sizes a pnpm workspace usually picks from, taken from the [cloud runners catalog](/docs/ci/cloud-runners): | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | A workspace rarely wants one size for everything, and `--filter` is the lever that lets each package get its own. `pnpm --filter ` selects a package, the trailing ellipsis form `--filter ...` adds everything that package depends on, and the leading form `--filter ...` adds everything that depends on it. Install with the same filter you build with, and the install only fetches the subgraph that job needs. That maps directly onto a matrix where the runner label is a matrix value: Copy Text ``` package: strategy: fail-fast: false matrix: include: - name: "@acme/web" runner: warp-ubuntu-latest-x64-16x - name: "@acme/api" runner: warp-ubuntu-latest-x64-8x - name: "@acme/cli" runner: warp-ubuntu-latest-x64-4x runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 10 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: pnpm cache-dependency-path: pnpm-lock.yaml - run: pnpm install --frozen-lockfile --filter ${{ matrix.name }}... - run: pnpm --filter ${{ matrix.name }} run build - run: pnpm --filter ${{ matrix.name }} run test ``` Pick each row by what the package can actually use. **Install-dominated packages** stay on 4x. Install is network and disk work, pnpm defaults to 16 concurrent network requests, and a warm store turns most of the step into hardlink creation. Extra cores change little here, so a package whose job is mostly `pnpm install` plus a lint pass belongs on the smaller row. **Typecheck** runs `tsc` on one core, which makes memory the constraint rather than parallelism. The 16GB on 4x covers most packages, and a package whose project references pull in the whole graph moves to 8x for 32GB before anyone reaches for a larger heap flag on a machine that cannot back it. **Bundle and test** are where cores pay. esbuild and swc use every core they are given, and Vitest sizes its worker pool from the core count, so an application package with a large test suite is the one that earns 8x or 16x. Keep the leaf libraries on 4x in the same matrix. At the root, `pnpm -r` runs tasks across packages with a default workspace concurrency of 4\. Raise `--workspace-concurrency` toward the vCPU count when each package task is single threaded, and lower it when each task spawns its own worker pool, since two levels of parallelism on the same cores produce contention rather than throughput. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners in the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the 4-core Linux larger runner is $0.012 per minute and the 8-core Linux larger runner is $0.022 per minute. Take a workspace running 1,500 workflow runs a month with two filtered jobs per run: a lint and typecheck job averaging 4 minutes on 4 vCPU, and a build and test job averaging 7 minutes on 8 vCPU. That is 6,000 minutes at the smaller size and 10,500 minutes at the larger one. | Job | Minutes per month | GitHub-hosted rate | GitHub-hosted cost | WarpBuild rate | WarpBuild cost | | ------------------------------------- | ----------------- | ------------------ | ------------------ | -------------- | -------------- | | Lint and typecheck, 4 vCPU | 6,000 | $0.012 | $72.00 | $0.008 | $48.00 | | Build and test, 8 vCPU | 10,500 | $0.022 | $231.00 | $0.016 | $168.00 | | Store cache, 2GB and 4,500 operations | n/a | n/a | n/a | see above | $0.85 | | Total | 16,500 | n/a | $303.00 | n/a | $216.85 | Stated as list-price arithmetic: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB), a 33 percent lower list price, and warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), a 27 percent lower list price. GitHub list prices checked on 2026-08-13. Rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Three problems account for most slow pnpm pipelines on GitHub Actions. **A cold content-addressable store.** With no restored store, every job resolves the lockfile and downloads every tarball before any package can be linked, and a workspace lockfile with several thousand entries turns that into minutes of network time on every job in the matrix. Read the install log to confirm the fix landed: a warm run reports packages as reused rather than downloaded, and the resolution phase finishes in seconds. When only one job in a matrix runs cold, check whether it landed on a different branch scope or a different operating system, since both produce a separate cache entry. The same store-over-node\_modules reasoning applies to every JavaScript package manager, and the short version lives in the answer on [how to cache node\_modules in GitHub Actions](/answers/how-do-i-cache-node-modules-in-github-actions). **Lockfile churn invalidating the whole store key.** A key built from `hashFiles('pnpm-lock.yaml')` moves whenever any dependency in any workspace package moves. One bump to one leaf library therefore produces a key that has never been seen, and every job in the matrix starts from an empty store on exactly the pull request where the cache matters most. The `restore-keys` prefix in the explicit form above is the fix: the action falls back to the most recent store under the same prefix, and the install fetches only the versions that actually changed. Automated dependency update pull requests are the workload that makes this visible, because they change the lockfile every single run. **Postinstall scripts.** Packages with native components run build steps at install time, and those steps repeat on every cold install. pnpm 10 blocks dependency lifecycle scripts by default and requires each one to be approved, which makes the cost explicit: the approved list is the set of packages you are paying to build. pnpm can keep the results of those build steps in the store through its side effects cache, so a restored store carries the built artifacts as well as the sources. Two habits keep that working. Pin the Node version in the workflow so the ABI behind any compiled addon stays stable across runs, and keep the approved build list as short as the workspace actually needs. When a job is slow and the cause is unclear, WarpBuild CI observability correlates system metrics from the runner agent with GitHub Actions job logs, which separates a CPU-bound test phase from an install stuck on network resolution. The Action Debugger pauses a workflow and opens an SSH session on the runner, which is the fastest way to run `pnpm store path` and `pnpm store status` against the machine that produced the failure. Snapshot runners and remote Docker builders cover the neighboring cases where a workspace also builds images. ## [Proof](#proof)Copy Anchor Link Public repositories running `warp-` labels are citable evidence you can read yourself. The Trigger.dev end-to-end suite is a pnpm workspace whose matrix runs on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x`, and it installs with a workspace filter, `pnpm install --frozen-lockfile --filter trigger.dev...`, exactly as described above. The workflow is [triggerdotdev/trigger.dev e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml), checked on 2026-08-13. ## [FAQ](#faq)Copy Anchor Link **What should the pnpm cache key hash?** `pnpm-lock.yaml`. Point `cache-dependency-path` at it so the key rolls exactly when the resolved dependency set changes, and add a `restore-keys` prefix on the explicit cache action so a single dependency bump restores the previous store instead of starting cold. **Do I still need pnpm/action-setup when I use WarpBuilds/setup-node?** Yes, and it has to run first. The `cache: pnpm` input shells out to pnpm to resolve the store directory, so a workflow that runs setup-node before pnpm exists fails on the cache step rather than on the install. **How do I run only the workspace packages that changed?** Use a changed-since filter such as `pnpm --filter '...[origin/main]' run build`, and set `fetch-depth: 0` on `actions/checkout` so the merge base exists. Without the full history the filter sees no base commit and selects every package. **What changes when a pnpm workflow moves to WarpBuild runners?** The `runs-on` label per job, and the setup action reference if you want cache steps served by WarpBuild Cache. --- # Protobuf and gRPC Codegen on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/protobuf-grpc-builds-github-actions Description: Protobuf codegen on GitHub Actions: cache buf modules and protoc plugin binaries, fail pull requests on generated-code drift, and size warp- runner labels. # Protobuf and gRPC Codegen on GitHub Actions Protobuf codegen on GitHub Actions: cache buf modules and protoc plugin binaries, fail pull requests on generated-code drift, and size warp- runner labels. Last verified: 2026-08-13 Protobuf codegen on GitHub Actions splits into two jobs: one that regenerates the stubs and fails the pull request when the committed output has drifted, and a matrix that fans generation out across every language you publish. Cache the buf module cache and the compiled `protoc-gen-*` plugin binaries, then point `runs-on` at a `warp-` label sized for the compile step rather than for generation itself. ## [Overview](#overview)Copy Anchor Link A `.proto` file set is a dependency graph before it is code. `buf generate` resolves imports across modules, orders the inputs, and hands each file set to one plugin process per language target, which is the same shape described in the [task graph glossary entry](/glossary/task-graph). Generation is cheap. Everything around it is where GitHub Actions minutes go. Two models exist, and the pipeline has to pick one and enforce it. Committing generated code keeps consumers free of the toolchain: a Go service imports `gen/go/...` and never installs `protoc`. The cost is drift. A schema change merged without regenerating leaves the repository in a state where the `.proto` files and the checked-in stubs disagree, and the failure surfaces at runtime in whichever service deserializes the wrong field number. The fix is a job that regenerates into a clean directory and runs `git diff --exit-code`. Generating at build time removes drift by construction and moves the toolchain requirement into every downstream build, including local developer machines. Most repositories that publish stubs to several package registries end up committing the output and running the drift check, because the registries need artifacts regardless. Either way, four caches decide the job duration: | Path | What it holds | Key input | | -------------------------------------- | ---------------------------------------------------------------------------- | -------------------------- | | \~/.cache/buf | Resolved schema modules and the buf build cache, under $XDG\_CACHE\_HOME/buf | buf.lock | | \~/go/bin | Compiled protoc-gen-go, protoc-gen-go-grpc, and other plugin binaries | tools/go.sum | | \~/go/pkg/mod | Module source the plugins were compiled from | tools/go.sum | | \~/.gradle/caches or \~/.m2/repository | JVM dependencies for compiling the generated Java or Kotlin stubs | gradle.lockfile or pom.xml | All drop-in for GitHub-hosted labels, with shapes and per-minute rates in the [cloud runners documentation](/docs/ci/cloud-runners). ## [Configuration](#configuration)Copy Anchor Link The first job lints the schema, checks it for breaking changes against `main`, regenerates, and fails on any diff against the committed output. Copy Text ``` name: proto on: push: branches: [main] pull_request: jobs: check: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: bufbuild/buf-action@v1 with: setup_only: true - uses: actions/setup-go@v5 with: go-version: "1.24" cache: false - name: Restore buf and plugin caches uses: WarpBuilds/cache@v1 with: path: | ~/.cache/buf ~/go/bin ~/go/pkg/mod key: proto-${{ runner.os }}-${{ hashFiles('buf.lock', 'buf.gen.yaml', 'tools/go.sum') }} restore-keys: | proto-${{ runner.os }}- - name: Install codegen plugins working-directory: tools run: | go install \ google.golang.org/protobuf/cmd/protoc-gen-go \ google.golang.org/grpc/cmd/protoc-gen-go-grpc - run: buf lint - run: buf breaking --against '.git#branch=main' - name: Regenerate stubs run: | rm -rf gen buf generate - name: Fail when generated code is stale run: | git add --intent-to-add gen git diff --exit-code -- gen || { echo "gen/ is out of date. Run 'buf generate' and commit the result." exit 1 } ``` Four details in that file carry the weight. **Plugin versions live in a tools module.** `go install` inside `tools/` resolves the versions pinned in that module's `go.mod` instead of taking whatever is newest. Unpinned plugins are the most common cause of a drift check that fails on a pull request touching no schema, because a plugin release changed a generated comment or a build tag. **The regenerate step starts from an empty directory.** `rm -rf gen` before `buf generate` makes deletions visible. Without it, a message removed from the schema leaves its orphaned stub file in place and `git diff` stays clean. **`git add --intent-to-add` makes new files count.** `git diff --exit-code` ignores untracked files, so a newly added service would pass the check silently. Staging the intent first puts new paths in the diff. **The breaking-change check needs history.** `buf breaking --against '.git#branch=main'` reads the previous schema state out of the local clone, which requires `fetch-depth: 0` on the checkout. A shallow clone makes the step fail or, worse, compare against nothing. The second job fans generation out per language. Each target gets its own cache entry, because a Java plugin run shares nothing with a TypeScript one: Copy Text ``` generate: needs: check runs-on: warp-ubuntu-latest-x64-4x strategy: fail-fast: false matrix: language: [go, java, python, typescript, csharp] steps: - uses: actions/checkout@v4 - uses: bufbuild/buf-action@v1 with: setup_only: true - uses: WarpBuilds/cache@v1 with: path: ~/.cache/buf key: buf-${{ matrix.language }}-${{ hashFiles('buf.lock') }} restore-keys: | buf-${{ matrix.language }}- - run: buf generate --template buf.gen.${{ matrix.language }}.yaml - uses: actions/upload-artifact@v4 with: name: stubs-${{ matrix.language }} path: gen/${{ matrix.language }} ``` `WarpBuilds/cache@v1` takes the same `path`, `key`, and `restore-keys` inputs as `actions/cache@v4` and is enabled by default on Linux runners. Two behaviors from the [caching documentation](/docs/ci/features/caching) shape the keys above: entries are scoped to key, version, and branch, so seed the cache on `main` and let branch builds reach it through `restore-keys`; and an entry expires 7 days after its last use, so an abandoned language target stops costing storage on its own. `hashFiles` follows the [GitHub Actions expression semantics](https://docs.github.com/en/actions/learn-github-actions/expressions#hashfiles), hashing every match in glob order. ## [Sizing](#sizing)Copy Anchor Link Rates below come from the [pricing page](/pricing) and the shapes from the runner catalog. | Runner label | vCPU | RAM | Storage | Price per minute | | -------------------------- | ---- | ----- | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | Generation is a sequence of short plugin processes reading a resolved file set, so it consumes cores in bursts and finishes before a wide machine has warmed up. `warp-ubuntu-latest-x64-4x` is the right default for the check job and for every generate leg. The 2x label fits a lint-only job on a schema-only repository. The 8x label earns its rate when the same job compiles what it generated, which is where a Gradle build of `grpc-java` stubs or a TypeScript project reference build spends its minutes. Width comes from the matrix, and the [matrix sharding guide](/guides/matrix-sharding-github-actions) covers how to keep the fan-out from outgrowing its merge step. ### [Cost at five languages](#cost-at-five-languages)Copy Anchor Link Take a schema repository publishing stubs for five languages, running 600 pipelines a month: | Job | Jobs per pipeline | Minutes each | Minutes per pipeline | | ------------------------------------ | ----------------- | ------------ | -------------------- | | Lint and breaking-change check | 1 | 1 | 1 | | Drift check (generate plus git diff) | 1 | 2 | 2 | | Per-language generate and publish | 5 | 4 | 20 | | Total | 7 | 23 | | 23 minutes per pipeline across 600 pipelines is 13,800 runner minutes a month. | Machine | Rate per minute | Monthly minutes | Monthly cost | | ---------------------------------------- | --------------- | --------------- | ------------ | | warp-ubuntu-latest-x64-4x | $0.008 | 13,800 | $110.40 | | GitHub-hosted 4-core Linux larger runner | $0.012 | 13,800 | $165.60 | Stated as list-price arithmetic: `warp-ubuntu-latest-x64-4x` (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13, from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). At the same wall clock, the pipeline above comes to $55.20 a month less. Cache usage is metered separately at $0.20 per GB-month of storage and $0.0001 per write or restore operation. A schema repository with a 600MB combined buf and plugin cache and 4,000 operations a month adds about $0.52. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Recompiling plugins on every job.** `go install` builds each `protoc-gen-*` binary from source, which is the largest fixed cost in a cold codegen job. Caching `~/go/bin` alongside `~/go/pkg/mod` turns the install step into a no-op on a hit. The same module-cache reasoning applies to the service builds downstream, covered on the [Go builds page](/solutions/go-github-actions). **One cache key across all languages.** Five matrix legs writing a single key evict each other for the whole run and the next pipeline still starts cold. Put `matrix.language` in the key, as the generate job does. **Drift failures caused by tooling, not by schema.** A drift check that fails on unrelated pull requests trains the team to rerun it until it passes. Pin the buf version in `buf-action`, pin the plugins in the tools module, and pin the generator options in `buf.gen.yaml`, so the only input that can change the output is the schema. **Shallow clones under `buf breaking`.** The default checkout depth of 1 gives the breaking-change step nothing to compare against. Set `fetch-depth: 0` on the check job only, and leave the generate legs shallow, since they read the working tree. **Serialized publishing.** Pushing five language packages from one job makes the slowest registry set the pipeline duration, and one registry outage fails all five. Publish per language inside the matrix leg that produced the artifact. Separating a slow generate from a slow publish is a measurement problem. The observability Jobs report shows duration, queue time, CPU, and memory per job, which tells a cache miss apart from a registry that is slow to respond. When a drift check fails only inside GitHub Actions, the Action Debugger pauses the workflow and opens an SSH session on the live runner, so you can run `buf generate` and `git status` on the machine that produced the diff. Repository-wide fan-out strategy is on the [monorepo pipelines page](/solutions/monorepo-github-actions). ## [Proof](#proof)Copy Anchor Link The rest is checkable in your own repository. Point the check job at a `warp-` label, leave the generate matrix where it is, and compare job duration at P75 rather than on a single run, since one run hides queue time and cache-miss variance. Every cost number on this page comes from a published rate with a source link and a checked-on date, and no build-time number is claimed for your schema, because your language count and cache hit rate decide it. ## [FAQ](#faq)Copy Anchor Link ### [Should generated protobuf code be committed to the repository?](#should-generated-protobuf-code-be-committed-to-the-repository)Copy Anchor Link Either model works as long as the pipeline enforces it. If you commit the generated code, add a drift job that regenerates and runs `git diff --exit-code` so a stale stub fails the pull request. If you generate at build time, every consumer needs the toolchain and the plugin versions pinned, which moves the same problem into each downstream build. ### [Which directories should a protobuf codegen job cache?](#which-directories-should-a-protobuf-codegen-job-cache)Copy Anchor Link The buf module cache under `$XDG_CACHE_HOME/buf`, which defaults to `~/.cache/buf` on Linux, and the compiled plugin binaries in `~/go/bin` along with the `~/go/pkg/mod` source they were built from. Key the entry on `buf.lock`, `buf.gen.yaml`, and the `go.sum` of the tools module, so the cache rolls when the schema dependencies or the plugin versions change. ### [What runner size does protobuf codegen need?](#what-runner-size-does-protobuf-codegen-need)Copy Anchor Link Start on `warp-ubuntu-latest-x64-4x` at $0.008 per minute for 4 vCPU and 16 GB. Generation itself spawns one short plugin process per file set and rarely saturates more cores than that, so add width through matrix jobs rather than vCPU. Move to `warp-ubuntu-latest-x64-8x` only when the job also compiles the generated stubs, which is where JVM and TypeScript targets spend their time. --- # Pulumi Pipelines on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/pulumi-github-actions Description: Run pulumi preview on pull requests and pulumi up on merge with warp- runners: OIDC token exchange, cached provider plugins, serialized stack applies. # Pulumi Pipelines on GitHub Actions Run pulumi preview on pull requests and pulumi up on merge with warp- runners: OIDC token exchange, cached provider plugins, serialized stack applies. ## [Overview](#overview)Copy Anchor Link A Pulumi pipeline on GitHub Actions is two jobs against one stack: a preview job that runs `pulumi preview` on every pull request and posts the diff, and an apply job that runs `pulumi up` after merge behind a deployment gate. Moving that pipeline to WarpBuild is a one-line `runs-on` change plus a cache step for `~/.pulumi/plugins`, because a Pulumi job spends most of its wall time installing provider plugins and waiting on cloud control planes rather than computing anything. Three requirements shape the workflow file, and each one is a place where pipelines break. The state backend has to be reachable and authenticated before any Pulumi command runs. Pulumi Cloud is the default, and `pulumi login` picks it up from a `PULUMI_ACCESS_TOKEN` or from an OIDC exchange. A self-managed backend keeps the checkpoint in your own object storage, selected with `pulumi login s3://my-bucket/pulumi-state`, and it carries a second requirement: a secrets provider. The default passphrase provider needs `PULUMI_CONFIG_PASSPHRASE` in the job environment, and a KMS provider set at `pulumi stack init` time with `--secrets-provider awskms://alias/pulumi` records itself in `Pulumi..yaml` so the runner does not need a passphrase at all. Credentials for the cloud the stack manages are separate from credentials for the backend. A Pulumi job that provisions AWS resources and stores state in Pulumi Cloud performs two OIDC exchanges, one for each side. Applies against a single stack have to be serialized. Pulumi Cloud holds a per-stack lock and rejects a second concurrent update with a conflict error, so an overlapping run fails rather than waits. A self-managed backend keeps the checkpoint in a bucket, and object storage gives you no interlock between two runs by itself. Either way the durable fix is a `concurrency` group at the GitHub Actions layer, covered in [concurrency group](/glossary/concurrency-group). Pulumi pipelines live on the Linux labels, and the rest of this page uses them. Per-size rates for every label are on the [pricing page](/pricing). ## [Configuration](#configuration)Copy Anchor Link This workflow previews on pull requests and applies on merge, with both OIDC exchanges, the plugin cache on [WarpBuilds/cache](https://github.com/WarpBuilds/cache), and a job-level concurrency group on each side. Copy Text ``` name: pulumi on: pull_request: paths: - "infra/**" push: branches: [main] paths: - "infra/**" env: PULUMI_SKIP_UPDATE_CHECK: "true" jobs: preview: runs-on: warp-ubuntu-latest-x64-2x concurrency: group: pulumi-preview-${{ github.ref }} cancel-in-progress: true permissions: contents: read id-token: write pull-requests: write defaults: run: working-directory: infra steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm cache-dependency-path: infra/package-lock.json - run: npm ci - name: Restore Pulumi provider plugin cache uses: WarpBuilds/cache@v1 with: path: /home/runner/.pulumi/plugins key: ${{ runner.os }}-pulumi-plugins-${{ hashFiles('infra/package-lock.json') }} restore-keys: | ${{ runner.os }}-pulumi-plugins- - name: Exchange the GitHub OIDC token for AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/pulumi-preview aws-region: us-east-1 - name: Exchange the GitHub OIDC token for a Pulumi access token uses: pulumi/auth-actions@v1 with: organization: myorg requested-token-type: urn:pulumi:token-type:access_token:organization - uses: pulumi/actions@v6 with: command: preview stack-name: myorg/infra/production work-dir: infra diff: true comment-on-pr: true github-token: ${{ secrets.GITHUB_TOKEN }} up: if: github.ref == 'refs/heads/main' needs: preview runs-on: warp-ubuntu-latest-x64-4x environment: production concurrency: group: pulumi-up-myorg-infra-production cancel-in-progress: false permissions: contents: read id-token: write defaults: run: working-directory: infra steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm cache-dependency-path: infra/package-lock.json - run: npm ci - name: Restore Pulumi provider plugin cache uses: WarpBuilds/cache@v1 with: path: /home/runner/.pulumi/plugins key: ${{ runner.os }}-pulumi-plugins-${{ hashFiles('infra/package-lock.json') }} restore-keys: | ${{ runner.os }}-pulumi-plugins- - name: Exchange the GitHub OIDC token for AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/pulumi-apply aws-region: us-east-1 - name: Exchange the GitHub OIDC token for a Pulumi access token uses: pulumi/auth-actions@v1 with: organization: myorg requested-token-type: urn:pulumi:token-type:access_token:organization - uses: pulumi/actions@v6 with: command: up stack-name: myorg/infra/production work-dir: infra refresh: true ``` Five details in that file earn their place. **The two OIDC exchanges are independent.** `aws-actions/configure-aws-credentials@v4` assumes an IAM role from the GitHub OIDC token and exports temporary AWS credentials for the provider. `pulumi/auth-actions@v1` exchanges the same OIDC token for a short-lived Pulumi access token and exports `PULUMI_ACCESS_TOKEN` for later steps. Both need `id-token: write` on the job, and together they remove every long-lived cloud key and backend token from repository secrets. The mechanics of that exchange are covered in [OIDC token exchange](/glossary/oidc-token-exchange). **The preview and apply roles are different IAM roles.** `pulumi-preview` needs read and describe permissions to refresh state, while `pulumi-apply` needs write. Splitting them means a pull request from a fork or a branch cannot mutate infrastructure even if the workflow is edited in that branch. **The environment is what gates the job.** `environment: production` makes the job wait for the required reviewers configured on that environment before any step runs. The `if` line only keeps the job off pull request runs. **The concurrency group on the apply job names the stack.** Keying on `github.ref` looks equivalent while every apply comes from `main`, then breaks the first time someone triggers the same stack from a tag, a schedule, or `workflow_dispatch` on a branch. Those runs carry different refs and would land in different groups, which is exactly the case the group exists to prevent. **`cancel-in-progress` is true on preview and false on apply.** Cancelling a preview wastes nothing. Cancelling a `pulumi up` halfway leaves the stack partially updated and, on Pulumi Cloud, leaves the stack lock held until someone runs `pulumi cancel` against it. Pulumi update plans, saved with `pulumi preview --save-plan` and consumed with `pulumi up --plan`, are still an experimental feature gated behind `PULUMI_EXPERIMENTAL`. Most pipelines therefore re-preview at apply time rather than replaying a saved plan, which is the structural difference from the saved plan file used in [Terraform pipelines on GitHub Actions](/solutions/terraform-github-actions). Runner isolation matters here because these jobs hold cloud credentials. Each WarpBuild runner is a fresh virtual machine with its own encrypted storage volume, created on demand and destroyed after the job, as described in the [security documentation](/docs/ci/security). ## [Sizing](#sizing)Copy Anchor Link `pulumi preview` and `pulumi up` call the provider once per resource and spend the wait on HTTPS responses, so the small labels are usually correct. These are the Linux runners worth considering, with shapes from the [cloud runners documentation](/docs/ci/cloud-runners) and rates from the [pricing page](/pricing). | Runner label | vCPU | Memory | Storage | Price per minute | | --------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-arm64-2x | 2 | 8GB | 150GB SSD | $0.003 | | warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 | **warp-ubuntu-latest-x64-2x** at $0.004 per minute is the default for a preview job. Two cores keep up with plugin extraction and with a Node or Python program that describes a few hundred resources. **warp-ubuntu-latest-x64-4x** at $0.008 per minute is the step up for the apply job, for stacks that pull four or more provider plugins, and for programs that build container images or render Helm charts inside the same job. **warp-ubuntu-latest-x64-8x** rarely pays for itself on Pulumi alone. Reach for it when the job compiles a large TypeScript or Go program before the engine starts, or runs a policy pack over the preview JSON in the same job. ARM64 lowers the rate at every size, and the Pulumi CLI and the major provider plugins publish `linux-arm64` builds. Confirm every plugin your stack installs has an `arm64` binary before switching, because a missing one fails at install time rather than at plan time. Cache metering is small enough to state exactly: storage is $0.20 per GB-month and each write or restore operation is $0.0001\. A plugin cache of 2GB with 6,000 operations a month adds about $1.00. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the standard `ubuntu-latest` runner for private repositories is $0.006 per minute at 2 vCPU, and the 4-core Linux larger runner is $0.012 per minute. Take a platform team with 25 stacks running 3,000 Pulumi jobs a month at an average of 4 minutes each. That is 12,000 runner minutes. | Scenario | Rate | Monthly minutes | Monthly cost | | ------------------------------------ | ---------- | --------------- | ------------ | | GitHub-hosted standard Linux, 2 vCPU | $0.006/min | 12,000 | $72.00 | | warp-ubuntu-latest-x64-2x, 2 vCPU | $0.004/min | 12,000 | $48.00 | | warp-ubuntu-latest-arm64-2x, 2 vCPU | $0.003/min | 12,000 | $36.00 | | Plugin cache storage and operations | see above | n/a | $1.00 | The per-minute arithmetic at the 2 vCPU size: $0.006 minus $0.004 is $0.002, and $0.002 divided by $0.006 is 33 percent lower list price (GitHub pricing, checked on 2026-08-13). At the 4 vCPU size used by the apply job the same arithmetic runs against $0.012 per minute for the GitHub 4-core Linux larger runner and $0.008 per minute for `warp-ubuntu-latest-x64-4x`. Every rate above carries its source and the date it was checked, and the same numbers appear on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Cold plugin installs.** Without a restored `~/.pulumi/plugins`, every job downloads and unpacks each provider plugin the program imports. This is the largest fixed cost in a Pulumi pipeline and the reason the cache step exists. The key hashes `package-lock.json` because the pinned provider SDK versions decide which plugin versions the engine installs. **Stack lock conflicts.** Two runs against one stack end with a conflict error rather than a queue, and a cancelled `pulumi up` can leave the lock held. The job-level concurrency group above turns the collision into a wait, and `pulumi cancel` releases a lock left behind by an interrupted update. **Refresh against provider rate limits.** `refresh: true` reads every resource in the stack from the cloud API before diffing. On a stack with a thousand resources that is a thousand calls into a control plane that throttles. Refreshing on apply and skipping it on pull request previews is the usual split, and breaking one large stack into several smaller ones is the durable fix. **Missing secrets configuration on self-managed backends.** A stack created with the passphrase provider fails on a runner with an error about the passphrase not being set until `PULUMI_CONFIG_PASSPHRASE` is present. Moving the stack to a KMS secrets provider removes the variable from the workflow entirely. **Long-converging resources.** A database or a node group can hold a job open for twenty minutes while the runner sits idle. A bigger machine changes nothing there, so the lever is the label rate rather than the label size, which is another argument for the 2x and 4x labels. **Queueing at review time.** A monorepo with 25 stacks previewing on one pull request wants 25 jobs at once. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, and capacity adjusts dynamically, so the matrix width is a sizing choice rather than a quota question. **Opaque failures.** When a preview hangs and the cause is unclear, WarpBuild's CI observability correlates system metrics from the runner agent with GitHub Actions job logs, which separates a job waiting on a cloud API from one that ran out of disk. The Action Debugger pauses a workflow and opens an SSH session on the runner, so you can inspect `~/.pulumi`, the plugin directory, and backend reachability on the machine itself. The wider version of the gating question is covered in [deployment jobs on GitHub Actions](/guides/deployment-jobs-github-actions). ## [Proof](#proof)Copy Anchor Link Public OSS repositories running `warp-` labels are citable evidence, and you can read the `runs-on` line yourself instead of taking a claim on trust. The Trigger.dev end-to-end suite runs its matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x` in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml), checked on 2026-08-13\. That is the same 4x Linux label the apply job above uses. The cheapest check is your own stack. [Signup](https://app.warpbuild.com/) includes $10 free credits, which covers 2,500 minutes on `warp-ubuntu-latest-x64-2x`. Point the preview job at a `warp-` label, leave the apply job where it is, and compare the two timing views for the same commit against the same stack. The plugin cache makes the second run the one worth reading. ## [FAQ](#faq)Copy Anchor Link **How do I remove PULUMI\_ACCESS\_TOKEN from my GitHub Actions secrets?** Grant the job `id-token: write` and run `pulumi/auth-actions@v1` with your organization and the `urn:pulumi:token-type:access_token:organization` token type. The action exchanges the GitHub OIDC token for a short-lived Pulumi access token and exports it for later steps, so no long-lived token is stored in the repository. **What concurrency group serializes pulumi up correctly?** Key the group on the stack rather than on `github.ref`. Runs that target one stack can start from different refs, from a scheduled trigger, or from `workflow_dispatch`, so a group such as `pulumi-up-myorg-infra-production` with `cancel-in-progress: false` is what keeps two updates off the same checkpoint. **Which runner size do pulumi preview and pulumi up need?** Both are bound by provider API latency rather than by cores, so `warp-ubuntu-latest-x64-2x` at $0.004 per minute covers most preview jobs and `warp-ubuntu-latest-x64-4x` at $0.008 per minute covers apply jobs and stacks with several provider plugins to install. --- # Faster Python Test Suites on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/python-github-actions Description: Python tests run slow on GitHub Actions when pytest is starved for vCPUs and caches miss. Fix both with WarpBuild warp- runners, sizing, and cache config. # Faster Python Test Suites on GitHub Actions Python tests run slow on GitHub Actions when pytest is starved for vCPUs and caches miss. Fix both with WarpBuild warp- runners, sizing, and cache config. Python test suites slow down on GitHub Actions for two reasons that compound: the standard hosted Linux runner for private repositories carries 2 vCPUs, so pytest runs close to serially, and every job starts with a cold cache, so pip or uv rebuilds the environment from scratch. The fix is a larger WarpBuild runner plus a persistent dependency cache: point `runs-on` at a `warp-` label, cache the resolver directory and the virtualenv, and let `pytest -n auto` use every core. ## [Overview](#overview)Copy Anchor Link A typical Python job spends its time in four places: dependency install, test collection, test execution, and service startup for database-backed tests. On GitHub's standard hosted Linux runner for private repositories, which carries 2 vCPUs and 8GB of RAM per GitHub's published specs (checked on 2026-08-13), `pytest -n auto` gets two workers. A CPU-bound suite queues behind the interpreter, and a memory-hungry fixture set swaps. WarpBuild runners register against your organization under `warp-` labels, and the Linux runner images carry the same tooling as GitHub-hosted runners, so `setup-python`, `tox`, and `pytest` run unchanged. Runners are ephemeral virtual machines: freshly allocated for each job and destroyed when the job completes. The full label list, sizes, and rates are in the [cloud runners documentation](/docs/ci/cloud-runners). If you want the broader tour of what makes any workflow slow, the [checklist for speeding up GitHub Actions](/guides/speed-up-github-actions) covers the general case; this page stays on Python. The rest of this page walks through a working pytest configuration, sizing guidance for parallel runs from 4 vCPUs to 16 vCPUs, the four bottlenecks that dominate Python suites, and the public evidence that the setup holds up. ## [Configuration](#configuration)Copy Anchor Link The workflow below runs pytest on `warp-ubuntu-latest-x64-8x` (8 vCPUs, 32GB RAM) with uv as the package manager. It caches the uv download cache and the project virtualenv through `WarpBuilds/cache`, keyed on the hash of `uv.lock`. On a warm run, restore replaces resolution and install almost entirely, and `uv sync --frozen` only reconciles the delta. Copy Text ``` name: python-tests on: push: branches: [main] pull_request: jobs: pytest: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v5 with: python-version: "3.12" - name: Restore uv cache and virtualenv id: uv-cache uses: WarpBuilds/cache@v1 with: path: | ~/.cache/uv .venv key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }} restore-keys: | ${{ runner.os }}-uv- - name: Sync dependencies run: uv sync --frozen - name: Run tests run: uv run pytest -n auto --dist worksteal ``` `WarpBuilds/cache` is a drop-in replacement for `actions/cache@v4`, so the inputs you already know (`path`, `key`, `restore-keys`, `fail-on-cache-miss`) behave the same way. The cache is enabled by default on Linux runners, entries are scoped to key, version, and branch, and an entry expires 7 days after its last use. Details and the split restore/save variants are in the [caching documentation](/docs/ci/features/caching). Because the cache has no fixed size ceiling, large virtualenvs with compiled scientific packages fit without eviction gymnastics; if you have fought the hosted cache quota before, the [guide to the GitHub Actions cache size limit](/guides/github-actions-cache-size-limit) explains the difference in detail. If you use pip, pipenv, or poetry instead of uv, `WarpBuilds/setup-python` is a drop-in replacement for `actions/setup-python` that routes toolchain and dependency caching through the WarpBuild cache automatically: Copy Text ``` - uses: WarpBuilds/setup-python@v6 with: python-version: "3.12" cache: pip ``` The `restore-keys` fallback in the first workflow matters more for Python than for most ecosystems. When `uv.lock` changes, the partial match restores the previous environment and the sync step installs only the packages that changed, so a one-line dependency bump avoids a full reinstall. ## [Sizing](#sizing)Copy Anchor Link `pytest -n auto` with pytest-xdist starts one worker per vCPU, so the runner size sets your parallelism directly. These are the Linux sizes that matter for test suites, with rates as listed on the [pricing page](/pricing): | Runner label | vCPU | RAM | Storage | Price per minute | | ---------------------------- | ---- | ---- | --------- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 | | warp-ubuntu-latest-arm64-8x | 8 | 32GB | 150GB SSD | $0.012 | | warp-ubuntu-latest-arm64-16x | 16 | 64GB | 150GB SSD | $0.024 | How to pick: **4x (4 workers).** Suites under roughly ten single-threaded minutes, mostly unit tests, light fixtures. With 16GB of RAM you get about 4GB per worker, which is comfortable for plain pytest and tight for anything loading large models or dataframes per worker. **8x (8 workers).** The default recommendation for suites already on pytest-xdist. 32GB leaves 4GB per worker at full parallelism, enough for a per-worker database connection pool and mid-sized fixtures. Most teams land here. **16x (16 workers).** Large monorepo suites and integration matrices. Before paying for 16 vCPUs, time your collection phase: collection runs in a single process no matter how many workers you buy, and a suite that spends three minutes collecting will keep spending three minutes collecting. The Bottlenecks section covers the fix. The ARM64 rows carry lower per-minute rates at every size. If your dependencies publish ARM64 wheels, which numpy, pandas, and most of the scientific stack now do, the same suite runs on `warp-ubuntu-latest-arm64-8x` at $0.012 per minute. The [Linux ARM64 runner page](/runners/linux-arm64) covers wheel availability and the differences worth knowing before switching architectures. To verify a sizing choice instead of guessing, WarpBuild's CI observability reports CPU and memory metrics from the runner agent correlated with your job logs, so you can see whether all eight workers saturate the machine or whether four of them wait on a database. ### [A worked cost model](#a-worked-cost-model)Copy Anchor Link Assume a suite that takes 18 minutes on GitHub's standard 2 vCPU hosted runner and runs 400 times a month. Assume your own measurement shows `pytest -n auto` on 8 vCPUs brings wall clock to 6 minutes; substitute your real numbers, since parallel efficiency depends on the suite. GitHub's per-minute list prices for x64 Linux are $0.006 for the standard 2 vCPU runner on private repositories, $0.012 for the 4-core larger runner, $0.022 for the 8-core larger runner, and $0.042 for the 16-core larger runner, per the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. WarpBuild rates come from the table above. | Setup | Wall clock | Rate per minute | Cost per run | Cost per month (400 runs) | | ----------------------------------- | ---------- | --------------- | ------------ | ------------------------- | | GitHub hosted, 2 vCPU | 18 min | $0.006 | $0.108 | $43.20 | | GitHub hosted larger runner, 8 vCPU | 6 min | $0.022 | $0.132 | $52.80 | | warp-ubuntu-latest-x64-8x | 6 min | $0.016 | $0.096 | $38.40 | | warp-ubuntu-latest-arm64-8x | 6 min | $0.012 | $0.072 | $28.80 | Two things fall out of the arithmetic. Buying more vCPUs from GitHub raises the monthly bill even when the suite finishes sooner, because the rate climbs faster than the wall clock drops. And the equal-shape rows are cheaper on WarpBuild: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13\. On ARM64, warp-ubuntu-latest-arm64-8x (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB): 14 percent lower list price. GitHub list price checked on 2026-08-13. Every rate above is on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four bottlenecks account for most slow Python suites on GitHub Actions. Each has a specific fix. ### [Wheel builds for native extensions](#wheel-builds-for-native-extensions)Copy Anchor Link When pip or uv finds no wheel matching your platform, Python version, and architecture, it falls back to the sdist and compiles the extension in the job. This is common with internal packages, niche C extensions, and brand-new Python releases where the ecosystem has not caught up. The symptoms are install steps that swing from seconds to many minutes depending on the package set. Three mitigations, in order of value: cache the build products, since both `~/.cache/pip` and `~/.cache/uv` keep built wheels and the workflow above restores them; pin Python versions with wide wheel coverage rather than adopting a new minor release in the first weeks; and prebuild internal packages into wheels published to your index so jobs never see an sdist. When a compile is unavoidable, more vCPUs help directly, because extension builds parallelize across cores. ### [Cold dependency resolution](#cold-dependency-resolution)Copy Anchor Link Without a restored cache, every job pays for resolution and download of the full dependency set. Lockfiles make the cache exact: key on `hashFiles('uv.lock')` or your `poetry.lock` or hashed `requirements.txt`, and the cache hits precisely when the environment is unchanged. The carrying cost is small and visible: cache storage is $0.20 per GB-month and cache operations are $0.0001 each, so keeping a 500MB virtualenv cache warm costs about $0.10 a month in storage, and entries expire 7 days after last use anyway. ### [Service containers for database-backed tests](#service-containers-for-database-backed-tests)Copy Anchor Link Suites backed by Postgres or Redis pay a startup tax and a contention tax. The startup tax is bounded with health checks, so the job proceeds the moment the service is ready rather than after a fixed sleep: Copy Text ``` jobs: integration: runs-on: warp-ubuntu-latest-x64-8x services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10 ``` The contention tax appears when xdist workers share one database and deadlock on fixtures. Give each worker its own database, named from the `PYTEST_XDIST_WORKER` environment variable, and create them from a template database so per-worker setup stays cheap. This is also where RAM sizing earns its keep: eight workers each holding a connection pool and a fixture set is the workload the 32GB on an 8x runner exists for. ### [Serialized test collection](#serialized-test-collection)Copy Anchor Link Before xdist distributes anything, pytest collects the whole suite in a single process, importing every test module along the way. Heavy imports at module scope, such as a framework app object or a large settings graph, turn collection into a fixed serial cost that no runner size reduces. Measure it with `time pytest --collect-only -q`. If it is more than a few percent of the run, restrict `testpaths` in your pytest configuration, move expensive imports inside fixtures or functions, and avoid importing the application at collection time. This is the one phase where a bigger machine buys nothing, so fix it in the code. When a failure only reproduces in GitHub Actions, the WarpBuild Action Debugger pauses the workflow and opens an SSH session on the live runner, which beats adding print statements and re-running a 15 minute suite. ## [Proof](#proof)Copy Anchor Link Public repositories are checkable evidence. LanceDB runs the `Python macOS 3.14 ARM` job of its open source [lance repository](https://github.com/lance-format/lance) on the `warp-macos-14-arm64-6x` label; the job builds the macOS Python wheel and runs the test suite, and you can read the configuration directly in the [python.yml workflow file](https://github.com/lance-format/lance/blob/main/.github/workflows/python.yml) (checked on 2026-08-13). The same label carries the wheel build in [lancedb/lancedb's pypi-publish.yml](https://github.com/lancedb/lancedb/blob/main/.github/workflows/pypi-publish.yml), the workflow that produces the published PyPI artifact. The label appears in the runner catalog on the [cloud runners page](/docs/ci/cloud-runners). Teams running JavaScript alongside Python can apply the same runner and cache approach; the [Node.js on GitHub Actions page](/solutions/nodejs-github-actions) covers that ecosystem's equivalents. ## [FAQ](#faq)Copy Anchor Link ### [Do I need to change my pytest setup to run on WarpBuild runners?](#do-i-need-to-change-my-pytest-setup-to-run-on-warpbuild-runners)Copy Anchor Link No. Change the runs-on label in your workflow to a warp- label such as warp-ubuntu-latest-x64-8x. Steps, actions, and pytest configuration stay the same. ### [How many pytest-xdist workers should I run?](#how-many-pytest-xdist-workers-should-i-run)Copy Anchor Link Start with -n auto, which launches one worker per vCPU. That means 4 workers on a 4x runner, 8 on an 8x, and 16 on a 16x. Lower the count if workers contend for a shared database or run out of RAM, and raise the runner size when every worker sits at full CPU. ### [Does the WarpBuild cache work with uv and poetry as well as pip?](#does-the-warpbuild-cache-work-with-uv-and-poetry-as-well-as-pip)Copy Anchor Link Yes. WarpBuilds/cache stores any directory, so \~/.cache/uv, \~/.cache/pip, \~/.cache/pypoetry, and .venv all cache the same way. WarpBuilds/setup-python handles pip, pipenv, and poetry caching without extra configuration. ### [What does it cost to run Python tests on WarpBuild?](#what-does-it-cost-to-run-python-tests-on-warpbuild)Copy Anchor Link Linux x64 runners start at $0.004 per minute and Linux ARM64 runners at $0.003 per minute, billed per minute of runtime. --- # Qt Application Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/qt-github-actions Description: Build Qt 6 apps on GitHub Actions with a three-platform matrix, a cached Qt install and ccache per leg, and warp- runner sizes priced per minute. # Qt Application Builds on GitHub Actions Build Qt 6 apps on GitHub Actions with a three-platform matrix, a cached Qt install and ccache per leg, and warp- runner sizes priced per minute. Last verified: 2026-08-13 A Qt application builds on GitHub Actions by installing a pinned Qt version on each target platform, caching that install alongside a compiler cache, and running the deployment tool that belongs to the platform. On WarpBuild that is a three-leg matrix on `warp-ubuntu-latest-x64-16x`, `warp-windows-latest-x64-16x`, and `warp-macos-26-arm64-6x`, each billed per minute. This page covers the Qt install and cache strategy per platform with the module selection, a matrix workflow with the packaging step for each leg, sizing for compile-heavy Qt builds with the rate for every label applied, and the list-price arithmetic against GitHub-hosted runners of the same shape. ## [Overview](#overview)Copy Anchor Link A desktop release matrix runs on one account with one billing model. Moving an existing Qt workflow across is a change to the `runs-on` line in each leg plus the cache steps. A Qt build spends its minutes in five places. The Qt install downloads and unpacks before anything compiles. Then `moc`, `uic`, and `rcc` generate C++ from headers carrying `Q_OBJECT`, from `.ui` files, and from resource files. Then the compiler works through the generated sources plus your own translation units. Then the linker pulls in the Qt libraries. Then the deployment tool walks the binary, resolves the Qt libraries and plugins it needs, and writes an installable artifact. The middle three phases behave like any C++ build, and the [C++ builds page](/solutions/cpp-github-actions) covers that half in depth. The first and last phases are what make Qt its own shape: the install is large and repeats every run without a cache, and the deployment tool is different on every platform. That is also why the matrix has three legs rather than one. Qt ships prebuilt binaries per platform and per compiler, identified by an arch string, so the Linux leg pulls `linux_gcc_64`, the Windows leg pulls `win64_msvc2022_64`, and the macOS leg pulls `clang_64`. The default `warp-windows-latest-x64-*` labels carry Visual Studio 2022, which is the toolchain the `msvc2022_64` binaries expect. The transitional `warp-windows-2025-vs2026-x64-*` labels carry Visual Studio 2026 instead, so pick the label that matches the Qt arch string rather than the newest one available. ### [Qt install and cache strategy per platform](#qt-install-and-cache-strategy-per-platform)Copy Anchor Link | Platform | Runner label | Qt arch string | Qt install cache | Compiler cache | Extra packages the job installs | | -------- | --------------------------- | ------------------- | ---------------------------------- | --------------------------------------- | ---------------------------------------------------------------------- | | Linux | warp-ubuntu-latest-x64-16x | linux\_gcc\_64 | install-qt-action with cache: true | WarpBuilds/cache over CCACHE\_DIR | libgl1-mesa-dev, libxkbcommon-dev, libxcb-cursor0, ninja-build, ccache | | Windows | warp-windows-latest-x64-16x | win64\_msvc2022\_64 | install-qt-action with cache: true | actions/cache over the ccache directory | ninja and ccache through the preinstalled package manager | | macOS | warp-macos-26-arm64-6x | clang\_64 | install-qt-action with cache: true | WarpBuilds/cache over CCACHE\_DIR | ninja and ccache through Homebrew | Two constraints set that layout. The [caching documentation](/docs/ci/features/caching) states that WarpBuild Cache is not supported on Windows runners, so the Windows leg falls back to `actions/cache`. The same documentation explains that a cache entry is versioned by a hash of the paths and the compression tool, so an entry written on a macOS runner cannot restore on a Linux runner regardless of the key. Both facts point the same way: one cache step and one key per leg. Module selection is the other half of the strategy. The base install carries Qt Base, and everything else downloads as a named module: `qtcharts`, `qtmultimedia`, `qtquick3d`, `qtshadertools`, `qtserialport`, `qtimageformats`, and `qt5compat` for the classes that moved out of the default set in Qt 6\. `qtwebengine` is the outlier, since it carries a Chromium build and needs `qtwebchannel` and `qtpositioning` alongside it, and it dominates both the download and the disk footprint of the install. Ask for the modules the project actually links against and no more, and put that module list in the cache key. An install restored under a key that predates a module addition looks healthy until `find_package(Qt6 COMPONENTS ...)` fails partway through the configure step. The runner images carry the same tooling as GitHub-hosted runners, which covers the MSVC toolchain on Windows and the Xcode command line tools on macOS. The macOS 26 image ships the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, with a dedicated macOS 27 image to follow once that image is released, so set `DEVELOPER_DIR` explicitly if the build depends on a specific SDK. The full catalog is in the [cloud runner documentation](/docs/ci/cloud-runners). ## [Configuration](#configuration)Copy Anchor Link The workflow below builds, tests, and packages a Qt 6 application on all three platforms from one matrix. Each leg installs Qt with the same module list, restores a compiler cache with the action its platform supports, configures CMake with Ninja, runs the test suite headless, and produces the platform's artifact. Copy Text ``` name: qt-release on: push: branches: [main] pull_request: env: QT_VERSION: 6.8.2 QT_MODULES: qtcharts qtmultimedia qtshadertools qtimageformats CCACHE_MAXSIZE: 2G jobs: build: name: build-${{ matrix.platform }} runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: - platform: linux runner: warp-ubuntu-latest-x64-16x host: linux arch: linux_gcc_64 - platform: windows runner: warp-windows-latest-x64-16x host: windows arch: win64_msvc2022_64 - platform: macos runner: warp-macos-26-arm64-6x host: mac arch: clang_64 steps: - uses: actions/checkout@v4 - name: Install Linux build and runtime dependencies if: matrix.platform == 'linux' run: | sudo apt-get update sudo apt-get install -y ninja-build ccache libgl1-mesa-dev \ libxkbcommon-dev libxcb-cursor0 libvulkan-dev - name: Install macOS build dependencies if: matrix.platform == 'macos' run: brew install ninja ccache - name: Install Windows build dependencies if: matrix.platform == 'windows' run: choco install ninja ccache --no-progress -y - uses: ilammy/msvc-dev-cmd@v1 if: matrix.platform == 'windows' - name: Install Qt uses: jurplel/install-qt-action@v4 with: version: ${{ env.QT_VERSION }} host: ${{ matrix.host }} target: desktop arch: ${{ matrix.arch }} modules: ${{ env.QT_MODULES }} dir: ${{ github.workspace }}/qt cache: true cache-key-prefix: qt-${{ matrix.platform }}-${{ env.QT_VERSION }} - name: Restore compiler cache (Linux and macOS) if: matrix.platform != 'windows' id: ccache-unix uses: WarpBuilds/cache/restore@v1 with: path: ${{ github.workspace }}/.ccache key: ${{ matrix.platform }}-ccache-${{ github.sha }} restore-keys: | ${{ matrix.platform }}-ccache- - name: Restore compiler cache (Windows) if: matrix.platform == 'windows' uses: actions/cache@v4 with: path: ${{ github.workspace }}\.ccache key: windows-ccache-${{ github.sha }} restore-keys: | windows-ccache- - name: Configure env: CCACHE_DIR: ${{ github.workspace }}/.ccache run: > cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=$QT_ROOT_DIR -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_MSVC_DEBUG_INFORMATION_FORMAT=Embedded - name: Build env: CCACHE_DIR: ${{ github.workspace }}/.ccache run: cmake --build build --parallel - name: Test env: QT_QPA_PLATFORM: offscreen run: ctest --test-dir build --output-on-failure --parallel 4 - name: Package AppImage if: matrix.platform == 'linux' run: | curl -sSLO https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage curl -sSLO https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage chmod +x linuxdeploy*.AppImage ./linuxdeploy-x86_64.AppImage --appdir AppDir \ --executable build/myapp --desktop-file packaging/myapp.desktop \ --icon-file packaging/myapp.png --plugin qt --output appimage - name: Package Windows directory if: matrix.platform == 'windows' run: | cmake --install build --prefix dist & "$env:QT_ROOT_DIR\bin\windeployqt.exe" --release --no-translations dist\myapp.exe - name: Package macOS bundle if: matrix.platform == 'macos' run: | cmake --install build --prefix dist "$QT_ROOT_DIR/bin/macdeployqt" dist/myapp.app -dmg - name: Save compiler cache (Linux and macOS) if: matrix.platform != 'windows' && github.ref == 'refs/heads/main' uses: WarpBuilds/cache/save@v1 with: path: ${{ github.workspace }}/.ccache key: ${{ steps.ccache-unix.outputs.cache-primary-key }} - uses: actions/upload-artifact@v4 with: name: ${{ matrix.platform }}-package if-no-files-found: error path: | *.AppImage dist/** ``` Five details in that file carry weight. **`QT_ROOT_DIR` is what ties the install to CMake.** The install action exports it, and passing it as `CMAKE_PREFIX_PATH` is what lets `find_package(Qt6 ...)` locate the install placed under `${{ github.workspace }}/qt`. The same variable gives the packaging steps the path to `windeployqt` and `macdeployqt`, which live in the Qt `bin` directory rather than on `PATH`. **`CMAKE_MSVC_DEBUG_INFORMATION_FORMAT=Embedded` is what makes ccache work on the Windows leg.** MSVC writes debug information into a shared PDB by default, which a compiler cache cannot reproduce from a hash, so cache hits stay at zero until the build switches to embedded debug info. The flag is ignored on the other two legs, which is why it can sit in the shared configure command. **`fail-fast: false` keeps one platform from cancelling the others.** A Windows packaging failure otherwise kills a macOS leg that has already paid for its compile. **The save step runs only on `main`.** Pull request branches miss the exact key, fall back to the `${{ matrix.platform }}-ccache-` prefix, and restore the newest main-branch entry. That keeps one authoritative cache per platform instead of one per run. Entries expire after 7 days without use. **`QT_QPA_PLATFORM=offscreen` is what lets widget and Quick tests run without a display server.** Without it a `QApplication` constructor aborts on a headless runner, and the failure reads as a plugin error rather than a missing display. Third-party dependencies deserve their own cache step. A Qt application that pulls libraries through vcpkg or Conan should persist those trees keyed on the manifest lockfile, which the [vcpkg and Conan caching guide](/guides/vcpkg-and-conan-caching-github-actions) covers in full. ## [Sizing](#sizing)Copy Anchor Link Catalog rows and per-minute rates for the labels in the matrix, plus the sizes above and below them, from the [cloud runner catalog](/docs/ci/cloud-runners) and the [pricing page](/pricing): | Runner label | OS | vCPU | Memory | Storage | Price per minute | | --------------------------- | ------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64 GB | 256GB SSD | $0.064 | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14 GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28 GB | 270GB SSD | $0.16 | Compilation is the phase that scales with cores, so size the Linux and Windows legs for it. Every Linux and Windows size carries 4 GB of memory per vCPU, which means `cmake --build build --parallel` with the default job count budgets about 4 GB per compile process. That covers ordinary Qt translation units and the generated `moc` sources comfortably, and it covers template-dense headers adequately. The matrix above runs both legs at 16 vCPU because a compile-heavy Qt tree, meaning one with generated `moc` sources across a thousand-plus translation units or a QML-heavy Quick application with `qtquick3d`, keeps that many cores fed on a cold cache. A smaller application should start at 8 vCPU and measure before paying for 16\. With a few hundred translation units and a warm compiler cache, 16 cores rarely stay busy, because a warm ccache turns most of the compile into hash lookups and the run becomes dominated by the link and the deployment walk. Move up when the CPU chart shows every core busy through compilation rather than in a short burst. The macOS leg is sized differently. `warp-macos-26-arm64-6x` carries 14 GB across 6 vCPU, so a parallel build there budgets closer to 2.3 GB per process. That is enough for most Qt sources, and it is worth capping `--parallel` to 4 or 5 on a codebase with heavy template instantiation to keep headroom for the linker. Disk is the other macOS constraint: 120GB has to hold the Qt install, the build tree, the compiler cache, the installed prefix, and the dmg staging copy. `warp-macos-26-arm64-12x` carries 270GB when that gets tight. Two refinements apply to any leg. Cap link parallelism separately with CMake job pools, `-DCMAKE_JOB_POOLS=link=2 -DCMAKE_JOB_POOL_LINK=link`, because linking a Qt binary with full debug information can consume several gigabytes per process while compilation never does. And keep light jobs off the large sizes: a `clang-format` check or a translation lint belongs on `warp-ubuntu-latest-x64-2x` at $0.004 per minute. ### [List prices against GitHub-hosted runners of the same shape](#list-prices-against-github-hosted-runners-of-the-same-shape)Copy Anchor Link GitHub publishes per-minute list prices on the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and runner shapes on the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Both were checked on 2026-08-13. `warp-windows-latest-x64-16x` (16 vCPU, 64 GB) costs $0.064 per minute against $0.082 per minute for the 16-core Windows larger runner (16 vCPU, 64 GB): 22 percent lower list price. The 6 vCPU macOS labels, including `warp-macos-26-arm64-6x`, cost $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price, with one more vCPU and the same memory. GitHub list prices checked on 2026-08-13. ### [Worked monthly model](#worked-monthly-model)Copy Anchor Link Take a Qt desktop team whose release matrix runs 150 times a month across pull requests and tagged releases, at 14 minutes on the Linux leg, 18 minutes on the Windows leg, and 16 minutes on the macOS leg, with warm caches everywhere. | Leg | Monthly minutes | WarpBuild rate | WarpBuild cost | | ---------------- | --------------- | -------------- | -------------- | | Linux, 16 vCPU | 2,100 | $0.032 | $67.20 | | Windows, 16 vCPU | 2,700 | $0.064 | $172.80 | | macOS, 6 vCPU | 2,400 | $0.08 | $192.00 | | Total | 7,200 | $432.00 | | On the two legs where GitHub publishes a same-shape list price, the same minutes come to $221.40 on the 16-core Windows larger runner and $244.80 on the largest GitHub-hosted macOS ARM64 runner, which is $466.20 against the $364.80 those two legs cost above: a difference of $101.40 a month at list prices. Cache metering adds a little on top, since storage is $0.20 per GB-month and each write or restore is $0.0001, so a 9 GB Qt and ccache footprint across the Linux and macOS legs with 4,000 operations a month comes to $2.20. Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **The Qt install repeating every run.** Runners are ephemeral virtual machines, so without the install cache every job downloads and unpacks Qt again before a single source file compiles, on all three legs. The fix is `cache: true` on the install action with a key that names the version, host, arch, and module list. Trim the module list at the same time, since `qtwebengine` alone can outweigh everything else in the install. **Cold compiler cache and moc churn.** A touched header with `Q_OBJECT` re-runs `moc` and invalidates every translation unit that includes it, which on a widget-heavy codebase is most of them. A persisted `CCACHE_DIR` absorbs the repeat work. Two things quietly cost you the warm cache: a compiler upgrade, since ccache keys on the compiler, and flag drift between jobs, since a `-O2` object never satisfies an `-O3` request. Pin the toolchain and keep flags identical across jobs that share a key. The [ccache guide](/guides/ccache-github-actions) covers the tuning in detail. **The deployment walk.** `windeployqt`, `macdeployqt`, and the `linuxdeploy` Qt plugin each scan the binary, resolve every Qt library and plugin it references, and copy them into the artifact. That work is disk-bound and largely single threaded, so extra cores do nothing for it. Narrowing the deployed set helps instead: skip translations you do not ship, and exclude QML import directories the application never loads. **Headless GUI tests.** Tests that construct a `QApplication` need a platform plugin. `QT_QPA_PLATFORM=offscreen` covers most widget and Quick test suites on Linux and works on the other legs too. Suites that require a real windowing system need `xvfb-run` on the Linux leg, and that is worth confirming before assuming a test failure is a code failure. The `libxcb-cursor0` package belongs in the Linux install step for the same reason: the xcb platform plugin fails to load without it on recent Qt versions. **Windows without WarpBuild Cache.** That leg falls back to `actions/cache`, which has its own size limits, so keep the cached paths narrow: the ccache directory and the Qt install rather than the whole workspace. The [Windows runner page](/runners/windows) covers the label set and the Visual Studio pairings. CI observability separates a CPU-bound compile from a deployment step stuck on disk, and the Action Debugger pauses the workflow and opens an SSH session on the live runner, which is the shortest path to inspecting a half-written app bundle or reading `ccache -s` in place. ## [Proof](#proof)Copy Anchor Link Public OSS repositories running `warp-` labels are citable evidence, and the check is to open the workflow file and read the `runs-on` line next to the dependency install. The [bitcoin/bitcoin GitHub Actions workflow](https://github.com/bitcoin/bitcoin/blob/master/.github/workflows/ci.yml) runs its per-commit build on `warp-ubuntu-latest-x64-8x` and installs `qt6-base-dev`, `qt6-tools-dev`, and `qt6-l10n-tools` alongside `ccache`, `mold`, `cmake`, and `ninja-build` in that job, which is the Qt 6 toolchain for the project's desktop GUI. The same file routes the rest of its Linux matrix across `warp-` labels sized per job: the Windows cross build on `warp-ubuntu-latest-x64-4x`, the sanitizer matrix on `warp-ubuntu-latest-x64-8x` with the ASan job pinned to `warp-ubuntu-2404-x64-8x`, the fuzz and MSan jobs on `warp-ubuntu-latest-x64-16x`, and the lint job on `warp-ubuntu-latest-x64-2x` (checked on 2026-08-13). The workflow file is open to read, cache keys and size choices included. Every cost number on this page carries its source and a checked-on date, so the arithmetic is yours to re-derive. Rates for every size are on the [pricing page](/pricing). Related reading: [C++ builds on GitHub Actions](/solutions/cpp-github-actions) for the compile and link half in depth, [using ccache in GitHub Actions](/guides/ccache-github-actions) for compiler cache tuning, and [vcpkg and Conan caching](/guides/vcpkg-and-conan-caching-github-actions) for the dependency tree a Qt application usually carries alongside Qt itself. ## [FAQ](#faq)Copy Anchor Link **How should a GitHub Actions workflow install and cache Qt?** Install a pinned Qt version per platform with `install-qt-action` and let it cache the install directory, then cache the compiler cache separately. Put the Qt version, host, arch string, and the full module list in the cache key, because an install restored under a stale key is missing the modules you added and `find_package` fails after the configure step. **What runner size should a Qt build start on?** Start at `warp-ubuntu-latest-x64-8x` at $0.016 per minute for the Linux leg, `warp-windows-latest-x64-8x` at $0.032 per minute for Windows, and `warp-macos-26-arm64-6x` at $0.08 per minute for macOS. Move the Linux and Windows legs to the 16 vCPU sizes when the CPU chart in WarpBuild's CI observability shows every core saturated through compilation. **Does a Qt release need one runner per platform?** Yes. Qt binaries are built per platform and per compiler, so the arch string differs on each leg, and the deployment tools are per platform as well: `linuxdeploy` for an AppImage, `windeployqt` for a Windows directory, and `macdeployqt` for a signed app bundle or dmg. --- # Rails Test Suites on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/rails-github-actions Description: Rails suites on GitHub Actions lose minutes to cold gem installs, per-worker schema loads, and serial runs. Workflow YAML, worker sizing, and real rates. # Rails Test Suites on GitHub Actions Rails suites on GitHub Actions lose minutes to cold gem installs, per-worker schema loads, and serial runs. Workflow YAML, worker sizing, and real rates. Last verified: 2026-08-13 A Rails suite on GitHub Actions spends its minutes in four places: installing gems, loading the schema into a test database, precompiling assets for system tests, and running the tests themselves. Cache `vendor/bundle` on a `Gemfile.lock` key, prepare the schema once and let Rails clone it per worker, and put `runs-on` on a `warp-` label with enough cores to fan the suite out. ## [Overview](#overview)Copy Anchor Link Rails ships parallel testing in the framework. `parallelize(workers: :number_of_processors)` in `test/test_helper.rb` forks one worker per core and gives each worker its own database, named after the primary test database with a worker index appended ([Rails testing guide](https://guides.rubyonrails.org/testing.html#parallel-testing)). The framework side is done; what decides wall clock is how many cores the runner has and how much of the job is spent before the first test runs. WarpBuild runners register against your organization under `warp-` labels, and the Linux images carry the same tooling as GitHub-hosted runners, so Bundler, Rails, Minitest, RSpec, and Capybara run unchanged. Runners are ephemeral virtual machines, allocated fresh per job and destroyed at the end, which is also why the gem directory and the test database start empty every time. Labels, machine shapes, and per-minute rates are in the [cloud runners documentation](/docs/ci/cloud-runners). The rest of this page is the Rails specifics: a workflow with a database service and parallel workers, worker sizing against the Linux x64 ladder with rates applied, and the four bottlenecks that dominate a Rails job. For plain Ruby libraries and gems without ActiveRecord or asset compilation, [Ruby builds on GitHub Actions](/solutions/ruby-github-actions) is the shorter path. ## [Configuration](#configuration)Copy Anchor Link The workflow below runs a Rails suite on `warp-ubuntu-latest-x64-8x` with a Postgres service container, a bundler cache keyed on `Gemfile.lock`, one schema preparation step, and eight parallel workers. Copy Text ``` name: rails-tests on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-8x services: postgres: image: postgres:16 env: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -h 127.0.0.1 -U app -d app_test" --health-interval 5s --health-timeout 5s --health-retries 20 --health-start-period 10s env: RAILS_ENV: test DATABASE_URL: postgres://app:app@127.0.0.1:5432/app_test PARALLEL_WORKERS: "8" RAILS_MAX_THREADS: "5" steps: - uses: actions/checkout@v5 - uses: ruby/setup-ruby@v1 with: ruby-version: "3.3.9" - name: Point bundler at vendor/bundle run: | bundle config set --local path vendor/bundle bundle config set --local deployment true bundle config set --local without "development" - name: Restore gems uses: WarpBuilds/cache@v1 with: path: vendor/bundle key: ${{ runner.os }}-${{ runner.arch }}-gems-3.3.9-${{ hashFiles('**/Gemfile.lock') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-gems-3.3.9- - run: bundle install --jobs 4 - name: Prepare the test schema run: bin/rails db:test:prepare - name: Run the suite run: bin/rails test test/ ``` Three details in that file carry the weight. The bundler config step runs before the restore step. `bundle config set --local path vendor/bundle` moves the install target into the checkout, which is what makes a cached directory the same directory Bundler is about to write to ([bundle config reference](https://bundler.io/man/bundle-config.1.html)). Deployment mode then requires a committed `Gemfile.lock` that matches the `Gemfile`, so a pull request that edits one and forgets the other fails the job instead of resolving unreviewed versions on the runner. The key hashes `Gemfile.lock` and pins the Ruby version and architecture, because compiled native extensions are built for one platform and one Ruby ABI. The [bundler gem caching answer](/answers/how-do-i-cache-bundler-gems-in-github-actions) covers the save step, `bundle clean`, and partial restores in full. `WarpBuilds/cache@v1` takes the same `path`, `key`, and `restore-keys` inputs as `actions/cache@v4`; the drop-in `setup-*` forks are listed in the [setup actions documentation](/docs/ci/features/caching/setup-actions). `bin/rails db:test:prepare` runs once against the primary test database. Rails parallel testing then creates the per-worker databases during setup, so there is no loop over worker indexes in the workflow. `PARALLEL_WORKERS` sets the worker count explicitly rather than leaving it to core detection, which keeps the number visible in the workflow file when you change runner size. The health check on the Postgres container is addressed at `127.0.0.1` rather than the Unix socket, because the official image runs its initialization scripts against a temporary server that does not accept TCP connections. The [service containers guide](/guides/service-containers-github-actions) covers that startup race and the flags around it. ## [Sizing](#sizing)Copy Anchor Link Rails parallelizes by process, so runner size sets parallelism directly and memory sets the ceiling on it. | Runner label | vCPU | RAM | Storage | Price per minute | Workers to start with | | -------------------------- | ---- | ----- | --------- | ---------------- | --------------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | 4 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | 8 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | 16 | Budget memory as workers plus services plus overhead, then take the first label that clears it. A booted Rails worker holds roughly 1 GB for a mid-sized application, a system test adds a headless browser process on top of that worker, Postgres holds about 1 GB plus a backend per connection, and the runner agent and Docker daemon take about 0.5 GB. Eight workers running system tests therefore want the 32 GB shape rather than the 16 GB one, even though eight workers fit the core count of a smaller machine on paper. Substitute your own numbers; the point is that services share the machine with the workers. Two things cap the return on more cores. Rails splits tests across workers as they finish rather than by a fixed slice, so an uneven suite balances better than a file-sliced one, but a single 12 minute system test still pins the run to 12 minutes no matter how many workers idle behind it. And every added worker adds a schema clone at setup and a database backend at runtime, so past 16 workers the database is usually the constraint. Linux ARM64 carries lower per-minute rates at the same shapes: $0.006, $0.012, and $0.024 for the 4x, 8x, and 16x sizes. Most gems with native extensions publish ARM64 builds, and the rest compile on the runner, so a trial branch is cheap to run. Applying rates to a fixed suite makes the bill concrete. GitHub publishes its per-minute Actions rates in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13; WarpBuild rates come from the table above. | Setup | Shape | Rate per minute | 9 minute run | 600 runs per month | | ---------------------------------- | ------------- | --------------- | ------------ | ------------------ | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $0.144 | $86.40 | | GitHub-hosted 8-core larger runner | 8 vCPU, 32 GB | $0.022 | $0.198 | $118.80 | `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price, GitHub list price checked on 2026-08-13\. At the same shape and the same wall clock, that is $32.40 a month on this suite. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **A cold `vendor/bundle` on every run.** A Rails `Gemfile` with 250 gems resolves, downloads, and rebuilds before a single test runs, and native extensions in `pg`, `nokogiri`, and `grpc` compile whenever no precompiled platform gem matches. Run `bundle lock --add-platform x86_64-linux aarch64-linux` and commit the lockfile so Bundler picks precompiled gems where they exist, and keep the cache keyed on the lockfile so a compile happens once per dependency change. **Schema load repeated per worker.** Each worker database gets the schema during parallel setup, so a wide schema multiplies. `db/schema.rb` loads faster than a `structure.sql` full of extensions and functions, and squashing years of migrations into the checked-in schema keeps that load short. The [database migrations page](/solutions/database-migrations-github-actions) covers the migration side of the same workflow. **Asset precompilation before system tests.** `rails assets:precompile` runs on every job that boots the full application. The work is deterministic for a given set of sources, so cache `public/assets` and `tmp/cache/assets` keyed on `app/assets`, `app/javascript`, and the JavaScript lockfile, and keep `tmp/cache/bootsnap` in the same entry to cut the boot cost each worker pays. **A suite that never fans out.** An application upgraded from an older Rails version often has no `parallelize` call in `test/test_helper.rb`, so `bin/rails test` runs one process on a machine with eight. Add the call, confirm the worker databases appear, and only then buy cores. When a failure reproduces only inside GitHub Actions, the Action Debugger pauses the workflow and opens a shell on the live runner, which beats adding print statements to a 20 minute suite. ## [Proof](#proof)Copy Anchor Link The rest is measurable in your own repository. Run the suite unchanged on the label you use today, then on `warp-ubuntu-latest-x64-8x` with `PARALLEL_WORKERS` matched to the core count, and compare job duration at P75 rather than a single run, since a single run hides queue time and cache-miss variance. The CI observability Jobs report shows duration, queue time, CPU, and memory per job, which is how you tell a suite that saturates eight workers from one where six sit blocked on Postgres. Every cost number on this page comes from a published rate with a link and a checked-on date, and no build-time multiplier is claimed for your suite, because your parallel efficiency is a property of your tests. Measure it, then pick the label. ## [FAQ](#faq)Copy Anchor Link ### [How many parallel test workers should a Rails suite run?](#how-many-parallel-test-workers-should-a-rails-suite-run)Copy Anchor Link Start with one worker per vCPU, so 4 on `warp-ubuntu-latest-x64-4x`, 8 on the 8x, and 16 on the 16x, then check memory before going higher. Budget roughly 1 GB per worker for a booted Rails process, more when system tests add a browser, plus the memory the service containers hold on the same machine. ### [Do I still need db:test:prepare when the schema has not changed?](#do-i-still-need-dbtestprepare-when-the-schema-has-not-changed)Copy Anchor Link Yes. The runner is a fresh virtual machine and the Postgres service container starts empty on every job. `bin/rails db:test:prepare` loads `db/schema.rb` into the primary test database, and Rails parallel testing clones that database once per worker during setup. ### [Why do parallel Rails tests fail with too many connections?](#why-do-parallel-rails-tests-fail-with-too-many-connections)Copy Anchor Link Each worker holds its own ActiveRecord connection pool, so the connection count is workers multiplied by the pool size in `database.yml`. The official Postgres image ships `max_connections` at 100, which 16 workers with a pool of 5 plus fixture and system-test connections can pass. Lower the pool, or raise `max_connections` through the service container command. --- # React Native Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/react-native-github-actions Description: Run React Native builds on GitHub Actions by splitting Android onto warp-ubuntu-latest-x64-8x and iOS onto warp-macos-15-arm64-6x, with per-platform caches. # React Native Builds on GitHub Actions Run React Native builds on GitHub Actions by splitting Android onto warp-ubuntu-latest-x64-8x and iOS onto warp-macos-15-arm64-6x, with per-platform caches. Last verified: 2026-08-13 A React Native pipeline on GitHub Actions needs two runner families, because the Android build wants a Linux machine with cores and memory while the iOS build has to run on macOS for `xcodebuild` and CocoaPods. On WarpBuild that is one workflow file with the Android job on `warp-ubuntu-latest-x64-8x` at $0.016 per minute and the iOS job on `warp-macos-15-arm64-6x` at $0.08 per minute, each keeping its own cache keys. This page covers the workflow YAML for that split, the catalog rows and per-minute rates behind both labels, the four bottlenecks that dominate React Native wall clock time, the instrumentation-test setup for Android emulators, and the arithmetic against GitHub-hosted list prices. ## [Overview](#overview)Copy Anchor Link A React Native repository produces three kinds of work on every pull request, and they want three different machines. The first is pure JavaScript: install dependencies, typecheck, lint, and run Jest. It never touches a native toolchain, so it belongs on a small Linux runner and can start immediately. The second is the Android build: Gradle, the Android SDK, the Kotlin and Java compilers, the NDK for any project with native modules, and a Metro bundle step that Gradle invokes as `bundleReleaseJsAndAssets`. This is CPU-bound and memory-hungry, and it runs on Linux. The third is the iOS build: `pod install`, then `xcodebuild` against a workspace, with the same Metro bundle step invoked from the Xcode build phase. This has to run on macOS. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so all three jobs live in one workflow file and differ only in the `warp-` label on `runs-on`. Every label, size, and per-minute rate is in the [cloud runner catalog](/docs/ci/cloud-runners), and the full rate card is on the [pricing page](/pricing). Two platform limits shape the layout. WarpBuild cache is enabled by default on Linux runners, so Linux jobs get a cache backend without any workflow change, while the macOS job uses `actions/cache` against the GitHub Actions cache backend. And macOS runners do not support nested virtualization and cannot run Docker, so anything containerized, including an Android emulator, stays on a Linux x86-64 label. For the Flutter equivalent of this split see [Flutter builds on GitHub Actions](/solutions/flutter-github-actions), and for the deeper Xcode and signing material see [iOS builds on GitHub Actions](/solutions/ios-github-actions). ## [Configuration](#configuration)Copy Anchor Link Moving an existing React Native workflow across is a label change. The images carry the same tooling as GitHub-hosted runners, so the Android SDK, Node, Ruby, Xcode, and the CocoaPods toolchain are already on disk. The Linux jobs can go one step further and use the WarpBuild forks of the common setup actions. `WarpBuilds/setup-node@v6` and `WarpBuilds/gradle-actions/setup-gradle@v5` accept the same inputs as their upstream counterparts and route their caching through WarpBuild cache automatically on WarpBuild runners. The full list is in the [setup actions reference](/docs/ci/features/caching/setup-actions). Metro needs one configuration change before its cache is portable. By default the transform cache lands in the system temp directory, which an ephemeral runner throws away at the end of every job. Point it at a path inside the workspace instead: Copy Text ``` // metro.config.js const path = require('path'); const {FileStore} = require('metro-cache'); module.exports = { cacheStores: [ new FileStore({root: path.join(__dirname, 'node_modules/.cache/metro')}), ], }; ``` With that in place, the three jobs look like this: Copy Text ``` name: react-native on: pull_request: push: branches: [main] concurrency: group: rn-${{ github.ref }} cancel-in-progress: true jobs: js-checks: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 20 cache: yarn - run: yarn install --immutable - run: yarn lint - name: Restore the Metro transform cache uses: WarpBuilds/cache@v1 with: path: node_modules/.cache/metro key: metro-jest-${{ hashFiles('yarn.lock') }}-${{ github.sha }} restore-keys: | metro-jest-${{ hashFiles('yarn.lock') }}- metro-jest- - run: yarn jest --ci android: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 20 cache: yarn - run: yarn install --immutable - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: '17' - uses: WarpBuilds/gradle-actions/setup-gradle@v5 - name: Restore the Metro transform cache uses: WarpBuilds/cache@v1 with: path: node_modules/.cache/metro key: metro-android-${{ hashFiles('yarn.lock') }}-${{ github.sha }} restore-keys: | metro-android-${{ hashFiles('yarn.lock') }}- metro-android- - name: Assemble release working-directory: android run: ./gradlew assembleRelease --build-cache ios: runs-on: warp-macos-15-arm64-6x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: yarn - run: yarn install --immutable - name: Restore the CocoaPods cache uses: actions/cache@v4 with: path: | ios/Pods ~/Library/Caches/CocoaPods key: pods-${{ hashFiles('ios/Podfile.lock') }} restore-keys: pods- - name: Restore the Metro transform cache uses: actions/cache@v4 with: path: node_modules/.cache/metro key: metro-ios-${{ hashFiles('yarn.lock') }}-${{ github.sha }} restore-keys: metro-ios-${{ hashFiles('yarn.lock') }}- - name: Install pods working-directory: ios run: pod install - name: Build for the simulator run: | xcodebuild build \ -workspace ios/App.xcworkspace \ -scheme App \ -configuration Release \ -destination 'generic/platform=iOS Simulator' \ -derivedDataPath ios/DerivedData \ CODE_SIGNING_ALLOWED=NO ``` Four details in that file are worth calling out. **The Metro cache key is per platform.** Android and iOS transform the same source with different platform extensions and different Babel output, so one shared key produces a cache that both jobs keep invalidating. Three prefixes, `metro-jest-`, `metro-android-`, and `metro-ios-`, keep them apart while the `restore-keys` fallback still lets a new commit start from the previous run. **The Linux jobs use the WarpBuilds forks and the macOS job does not.** WarpBuild cache is enabled by default on Linux runners, so `WarpBuilds/setup-node` and `WarpBuilds/cache` write there with no extra configuration. The macOS job uses `actions/setup-node` and `actions/cache`, which store against the GitHub Actions cache backend. **The CocoaPods key hashes `Podfile.lock` alone.** The resolved pod set changes only when the lockfile changes, so a broad key with a `pods-` fallback gives a high hit rate. Caching `~/Library/Caches/CocoaPods` alongside `ios/Pods` keeps the downloaded pod archives, which is what makes a partial hit useful. **Gradle caching comes from two layers.** `WarpBuilds/gradle-actions/setup-gradle@v5` persists the Gradle User Home, meaning wrapper distributions, downloaded dependencies, and build caches, while `--build-cache` lets Gradle reuse task outputs across runs. ### [Android instrumentation tests need nested virtualization](#android-instrumentation-tests-need-nested-virtualization)Copy Anchor Link Espresso and Detox Android tests boot a real Android system image, and the emulator runs at useful speed only with hardware acceleration through `/dev/kvm`. On WarpBuild cloud runners you enable that by appending a dynamic label to `runs-on` with a semicolon: Copy Text ``` runs-on: warp-ubuntu-latest-x64-8x;nested-virtualization.enabled=true ``` Nested virtualization is available on Linux x86-64 cloud runners only. ARM64, Windows, and macOS runners do not support it, and the label is silently ignored there, so an emulator job that looks healthy but crawls is worth checking for a misplaced label first. On BYOC the label is unnecessary: BYOC runs on AWS, GCP, and Azure, and nested virtualization is enabled automatically when every instance type in the runner set supports it. One more step is required after `/dev/kvm` exists. Default device permissions block the `runner` user, so add the udev rule before the emulator starts, exactly as documented in the [nested virtualization reference](/docs/ci/features/nested-virtualization). This step is standard on all GitHub and GitHub-compatible runners. Detox on iOS needs none of this, because it drives the iOS simulator, which runs natively on the macOS runner. ## [Sizing](#sizing)Copy Anchor Link These are the Linux x86-64 rows from the [cloud runner catalog](/docs/ci/cloud-runners). Ubuntu 22.04 and 26.04 labels exist at the same rates. | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 | And the macOS rows for the iOS half: | Runner label | macOS | vCPU | Memory | Storage | Price per minute | Alias | | ----------------------- | -------- | ---- | ------ | --------- | ---------------- | --------------------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | warp-macos-latest-arm64-6x | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | warp-macos-latest-arm64-12x | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | | The `latest` aliases track macOS 15, in sync with GitHub's `macos-latest` tag. The macOS 26 images ship Xcode 27.0 with the iOS, tvOS, watchOS, and visionOS 27.0 simulator runtimes on top of the upstream GitHub macOS 26 image, while GitHub's upstream macOS 27 runner image is in beta; a dedicated macOS 27 image follows once that image is released. Recommended starting points for a React Native repository: | Job | Label | Reasoning | | ----------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | JavaScript lint and Jest | warp-ubuntu-latest-x64-4x | Jest workers scale with cores, and 4 vCPU with 16GB covers a mid-sized suite. Move to 8x when the suite runs long enough to gate the pull request. | | Android assemble | warp-ubuntu-latest-x64-8x | Kotlin, Java, and NDK compilation parallelize across modules, and the Gradle daemon plus Metro want real memory. Move to 16x when the module graph is wide or the project builds C++ from source. | | Android instrumentation | warp-ubuntu-latest-x64-8x;nested-virtualization.enabled=true | The emulator claims cores and memory for itself while Gradle and adb run beside it. 16x leaves room for two emulators in a sharded matrix. | | iOS build and simulator tests | warp-macos-15-arm64-6x | A debug or simulator build with a warm Pods cache rarely saturates 12 vCPU. | | iOS release archive | warp-macos-26-arm64-12x | Release configuration raises peak memory, and the 270GB disk holds Xcode, the runtimes, DerivedData, and an .xcarchive together. | The [CI observability](/docs/ci/features/observability) view reports right-sizing recommendations from measured CPU and memory utilization per repository, workflow, job, and instance type, which settles the question faster than reading a single run. ### [Against GitHub-hosted list prices](#against-github-hosted-list-prices)Copy Anchor Link GitHub publishes its per-minute rates on the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), and the shapes on its [hosted runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Both were checked on 2026-08-13\. These are the rows a React Native pipeline touches: | Shape | WarpBuild label | WarpBuild rate | GitHub-hosted equivalent | GitHub rate | Lower list price | | ------------------------ | -------------------------- | -------------- | --------------------------------------------------------- | ----------- | ---------------- | | 4 vCPU, 16GB Linux | warp-ubuntu-latest-x64-4x | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent | | 8 vCPU, 32GB Linux | warp-ubuntu-latest-x64-8x | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent | | 16 vCPU, 64GB Linux | warp-ubuntu-latest-x64-16x | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent | | 6 vCPU, 14GB macOS ARM64 | warp-macos-15-arm64-6x | $0.08 | largest GitHub-hosted macOS ARM64 runner, 5 vCPU and 14GB | $0.102 | 22 percent | The macOS row is the one that moves a React Native bill, and it compares different hardware in WarpBuild's favor: 6 vCPU with 14GB against 5 vCPU with 14GB, at $0.102 - $0.080 = $0.022 per minute less. GitHub also lists a smaller standard macOS runner at $0.062 per minute with 3 vCPU and 7GB, which is a different amount of hardware rather than the same machine at another price. ### [Worked cost model](#worked-cost-model)Copy Anchor Link Take a team merging often enough to run the full pipeline 300 times a month, with three jobs per run: 6 minutes of JavaScript checks on 4 vCPU, 14 minutes of Android assemble on 8 vCPU, and 18 minutes of iOS build on the 6 vCPU macOS label. Job durations are held equal on both platforms so the table isolates the rate difference. | Job | Minutes | WarpBuild rate | WarpBuild cost | GitHub-hosted rate | GitHub-hosted cost | | -------------------------- | ------- | -------------- | -------------- | ------------------ | ------------------ | | JavaScript checks (4 vCPU) | 6 | $0.008/min | $0.048 | $0.012/min | $0.072 | | Android assemble (8 vCPU) | 14 | $0.016/min | $0.224 | $0.022/min | $0.308 | | iOS build (macOS 6 vCPU) | 18 | $0.08/min | $1.44 | $0.102/min | $1.836 | | **Per pipeline run** | 38 | **$1.712** | **$2.216** | | | | **Per month at 300 runs** | 11,400 | **$513.60** | **$664.80** | | | The monthly gap is $151.20 before any duration change from caching. GitHub list prices checked on 2026-08-13; the WarpBuild rates are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four things dominate React Native wall clock time on GitHub Actions. **1\. CocoaPods resolution.** A cold `pod install` resolves the dependency graph and downloads every pod, and React Native projects carry dozens of them from the React core alone. Cache `ios/Pods` and `~/Library/Caches/CocoaPods` keyed on `Podfile.lock`, and keep `Podfile.lock` committed so the key is stable across machines. When the resolution itself is slow rather than the download, check whether the Podfile still points at the full specs repository instead of the CDN source that current React Native templates use. **2\. Gradle daemon cold start.** Runners are ephemeral VMs, so every Android job starts a fresh Gradle daemon and a cold JVM, then re-reads the whole build script graph. Most of that time comes back through the configuration cache, `--build-cache` for task output reuse, and `WarpBuilds/gradle-actions/setup-gradle@v5` persisting the Gradle User Home between runs. Projects with C++ native modules should also cache the CMake and NDK build directories, because recompiling native code is the longest step in a cold Android job. **3\. Metro bundler cache.** Metro transforms every JavaScript and TypeScript file through Babel, and a large app has thousands of them plus the module graph of its dependencies. With the `FileStore` configuration above, the transform cache survives between runs and Metro only re-transforms the files that changed. Without it, both the Android bundle step and the iOS bundle phase pay the full transform on every run. Keep the key per platform, and include the commit SHA in the primary key with a lockfile-scoped `restore-keys` prefix so each run writes a fresh entry while still starting warm. **4\. Hermes compilation.** The release bundle step compiles the JavaScript bundle to Hermes bytecode with `hermesc`, which is CPU-bound and runs after Metro finishes. React Native ships prebuilt `hermes-engine` artifacts for released versions, so a normal iOS build downloads the engine rather than compiling it. A project pinned to a nightly, or one patching React Native from source, can fall back to building the engine itself, which is the longest single step in a cold iOS job. Check the build log for a Hermes source build before blaming Xcode. When a pipeline is still slow after these four, measure rather than guess. WarpBuild's CI observability correlates OpenTelemetry system metrics from the runner agent with GitHub Actions job logs, which shows whether a job is CPU-bound, memory-bound, or waiting on the network. The Action Debugger pauses a workflow and opens an SSH session on the runner, which is the fastest way to inspect a Pods directory, a Gradle daemon, or `/dev/kvm` permissions on the live machine. ## [Proof](#proof)Copy Anchor Link The migration cost is the label. The Linux and macOS images carry the same tooling as their GitHub-hosted counterparts, so Gradle, the Android SDK, `xcodebuild`, `pod`, Node, and every marketplace action behave the same way. Emulator jobs need the nested virtualization label described above; everything else moves as written. ## [FAQ](#faq)Copy Anchor Link **Which runner labels should a React Native pipeline use?** Put the Android job on `warp-ubuntu-latest-x64-8x` (8 vCPU, 32GB, $0.016 per minute), the iOS job on `warp-macos-15-arm64-6x` (6 vCPU, 14GB, $0.08 per minute), and JavaScript lint and Jest on `warp-ubuntu-latest-x64-4x` (4 vCPU, 16GB, $0.008 per minute). **Can the Android and iOS builds share one runner?** No. `xcodebuild`, CocoaPods, and the iOS simulator runtimes exist only on macOS, and macOS runners cannot run Docker. Keep the two platforms in separate jobs so each one sits on the right label and keeps its own cache keys. **How do I run Android instrumentation tests with an emulator?** Append `nested-virtualization.enabled=true` to an x86-64 label, for example `warp-ubuntu-latest-x64-8x;nested-virtualization.enabled=true`, and add the KVM udev permissions step before the emulator starts. Nested virtualization is Linux x86-64 only on cloud runners. **What does a React Native pipeline cost on WarpBuild runners?** A pipeline of one 4x JavaScript job, one 8x Android job, and one macOS 6x iOS job bills at $0.008, $0.016, and $0.08 per minute respectively. --- # Fast Ruby Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/ruby-github-actions Description: Ruby builds slow down on GitHub Actions from cold gem caches and 2 vCPU runners. Fix both with warp- labels, bundler-cache, and parallel RSpec. # Fast Ruby Builds on GitHub Actions Ruby builds slow down on GitHub Actions from cold gem caches and 2 vCPU runners. Fix both with warp- labels, bundler-cache, and parallel RSpec. Last verified: 2026-08-13 Ruby builds run slow on GitHub Actions for two reasons that compound: the standard hosted Linux runner for private repositories carries 2 vCPUs, so RSpec and Minitest run close to serially, and every job starts with a cold gem cache, so Bundler resolves and recompiles the whole Gemfile again. The fix is a bigger runner plus a gem cache that survives between jobs: point `runs-on` at a `warp-` label, set `bundler-cache: true` on `WarpBuilds/setup-ruby`, and fan the suite out across every core. ## [Overview](#overview)Copy Anchor Link A Ruby job on GitHub Actions spends its minutes in four places: `bundle install`, native gem compilation, asset precompilation for Rails apps, and the test run itself. On GitHub's standard hosted Linux runner for private repositories, which carries 2 vCPUs and 8 GB of RAM per GitHub's published runner specs (checked on 2026-08-13), the test run gets two processes at best, and the install phase pays full price on every single run. WarpBuild runners register against your organization under `warp-` labels, and the Linux runner images carry the same tooling as GitHub-hosted runners, so Bundler, rbenv-style version pinning, Rails, RSpec, and Minitest all run unchanged. Runners are ephemeral virtual machines, freshly allocated for each job and destroyed when the job finishes. The full label list, machine shapes, and per-minute rates live in the [cloud runners documentation](/docs/ci/cloud-runners). The rest of this page covers a working Ruby workflow, sizing guidance for RSpec and Minitest suites from 4 to 16 vCPUs, the four bottlenecks that dominate Ruby jobs, and the arithmetic behind the bill. For the general case across every language, the [checklist for speeding up GitHub Actions](/guides/speed-up-github-actions) is the wider tour; this page stays on Ruby. ## [Configuration](#configuration)Copy Anchor Link The workflow below runs an RSpec suite on `warp-ubuntu-latest-x64-8x` (8 vCPUs, 32 GB RAM) with a Postgres service container and eight parallel test processes. Gems are installed and cached by `WarpBuilds/setup-ruby`, which is a drop-in replacement for `ruby/setup-ruby` that routes caching through the WarpBuild cache automatically on WarpBuild runners. Copy Text ``` name: ruby-tests on: push: branches: [main] pull_request: jobs: rspec: runs-on: warp-ubuntu-latest-x64-8x services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10 env: RAILS_ENV: test DATABASE_URL: postgres://postgres:postgres@localhost:5432 PARALLEL_TEST_PROCESSORS: "8" steps: - uses: actions/checkout@v4 - name: Install Ruby and gems uses: WarpBuilds/setup-ruby@v1.295.0 with: ruby-version: "3.3" bundler-cache: true cache-version: 1 - name: Prepare the parallel test databases run: bundle exec rake parallel:create parallel:load_schema - name: Run RSpec run: bundle exec parallel_rspec spec ``` Two details in that file carry most of the weight. `bundler-cache: true` runs `bundle install` and caches the installed gems, so a separate `bundle install` step can be deleted from the workflow. The input defaults to `false`, so a workflow that omits it silently installs gems from scratch on every run while looking like it caches them. Set it explicitly. `cache-version` is an arbitrary string folded into the gem cache key. Bumping it from `1` to `2` writes a fresh entry and abandons the old one, which is what you want after a Ruby patch upgrade, a Bundler upgrade, or an image change that leaves compiled native gems linked against the wrong system libraries. Leave it alone the rest of the time, because a version bump costs one full cold install. For paths outside the gem bundle, `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` with the same `path`, `key`, and `restore-keys` inputs. A Rails app usually wants precompiled assets and the Bootsnap compile cache kept warm: Copy Text ``` - name: Cache assets and bootsnap uses: WarpBuilds/cache@v1 with: path: | public/assets tmp/cache/assets tmp/cache/bootsnap key: ${{ runner.os }}-assets-${{ hashFiles('app/assets/**', 'app/javascript/**', 'package-lock.json') }} restore-keys: | ${{ runner.os }}-assets- ``` Cache entries are scoped to key, version, and branch, and the version hash covers both the compression tool and the cached paths, so a cache written on macOS never restores on Linux. Entries expire 7 days after their last use. Cache storage bills at $0.20 per GB-month with cache operations at $0.0001 each on hosted runners, and cache is included at no charge on BYOC runners. The full input reference for every WarpBuild `setup-*` fork is in the [setup actions documentation](/docs/ci/features/caching/setup-actions). ## [Sizing](#sizing)Copy Anchor Link Ruby test frameworks parallelize by process, so runner size sets your parallelism directly. RSpec through `parallel_rspec` and Minitest through Rails' `parallelize(workers: :number_of_processors)` both fork one worker per core, and each worker wants its own test database. | Runner label | vCPU | RAM | Storage | Price per minute | | -------------------------- | ---- | ----- | --------- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | **4x, four test processes.** Suites under roughly ten single-threaded minutes: model specs, service objects, a thin request-spec layer. 16 GB gives about 4 GB per process, which is comfortable when the only service container is Postgres. Gem installs also compile faster here than on a 2 vCPU machine, because native extension builds use every core. **8x, eight test processes.** The default choice for a Rails monolith with system specs. 32 GB covers eight Rails processes, each holding a connection pool and a fixture set, plus a Postgres container and a Redis container on the same runner. Watch the Postgres side first: eight workers hammering one container will saturate its shared buffers before the Ruby processes saturate the CPU, so raise `max_connections` and `shared_buffers` in the service container before buying more cores. **16x, sixteen test processes.** Large suites and integration matrices, especially ones running Capybara with a headless browser, which adds a browser process per worker on top of the Rails process. 64 GB is the reason to be here as much as the core count. Before paying for 16 vCPUs, check that your suite actually splits evenly; `parallel_rspec` balances by file size by default, and one 12 minute file pins the whole run to 12 minutes no matter how many workers wait idle. Linux ARM64 runners carry lower per-minute rates at the same shapes, at $0.006, $0.012, and $0.024 for the 4x, 8x, and 16x sizes. Most gems with native extensions publish ARM64 builds now, and the rest compile from source on the runner, so ARM64 is worth a trial branch for a pure Ruby suite. To size from data instead of guesswork, WarpBuild's CI observability reports CPU and memory from the runner agent correlated with the GitHub Actions job logs, which shows whether all eight processes saturate the machine or four of them sit blocked on the database. ### [What the sizes cost against GitHub-hosted list prices](#what-the-sizes-cost-against-github-hosted-list-prices)Copy Anchor Link GitHub publishes its per-minute Actions rates in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. WarpBuild rates come from the runner catalog above. | WarpBuild runner | Shape | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | Difference | | -------------------------- | -------------- | -------------------- | ------------------------------------- | ----------------- | --------------------------- | | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | ubuntu-latest on private repositories | $0.006 | 33 percent lower list price | | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | 4-core Linux larger runner | $0.012 | 33 percent lower list price | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | 8-core Linux larger runner | $0.022 | 27 percent lower list price | | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | 16-core Linux larger runner | $0.042 | 24 percent lower list price | A worked model makes the shape clear. Assume an RSpec suite that takes 24 minutes on GitHub's standard 2 vCPU hosted runner and runs 500 times a month, and assume your own timing shows eight parallel processes bring wall clock to 7 minutes. Substitute your real numbers, because parallel efficiency depends on how evenly your spec files split. | Setup | Wall clock | Rate per minute | Cost per run | Cost per month at 500 runs | | ---------------------------------- | ---------- | --------------- | ------------ | -------------------------- | | GitHub-hosted, 2 vCPU | 24 min | $0.006 | $0.144 | $72.00 | | GitHub-hosted 8-core larger runner | 7 min | $0.022 | $0.154 | $77.00 | | warp-ubuntu-latest-x64-8x | 7 min | $0.016 | $0.112 | $56.00 | Buying more cores from GitHub raises the monthly bill even though the suite finishes sooner, because the rate climbs faster than the wall clock falls. At the same 8 vCPU shape, the WarpBuild rate is lower, so the shorter suite also costs less than the 24 minute status quo. ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four bottlenecks account for most slow Ruby jobs. Each has a specific fix. ### [A cold vendor/bundle on every run](#a-cold-vendorbundle-on-every-run)Copy Anchor Link Without a restored cache, every job resolves the Gemfile, downloads every gem, and rebuilds the bundle. A mid-sized Rails Gemfile with 250 gems turns that into minutes of pure setup before a single test runs. `bundler-cache: true` fixes it, and the key is derived from your `Gemfile.lock`, so the cache hits exactly when the dependency set is unchanged and misses when it moves. Keep `Gemfile.lock` committed; without it the key has nothing stable to hash. ### [Native gem compilation](#native-gem-compilation)Copy Anchor Link `nokogiri`, `pg`, `mysql2`, `grpc`, and `sassc` compile C extensions when no precompiled platform gem matches the runner. The symptom is an install step whose duration swings from 20 seconds to several minutes depending on which gems moved. Two mitigations: run `bundle lock --add-platform x86_64-linux` (or `aarch64-linux` on ARM64 runners) so Bundler resolves to precompiled platform gems where they exist, and keep the gem cache warm so a compile happens once per lockfile change rather than once per job. When a compile is unavoidable, extra vCPUs help directly, because extension builds parallelize across cores. ### [Asset precompilation](#asset-precompilation)Copy Anchor Link `rails assets:precompile` runs Sprockets or the JavaScript bundler on every job that needs a full app boot, including system specs. This work is deterministic for a given set of asset sources, so it belongs in a cache keyed on those sources, exactly as the second workflow snippet above does. Keep `tmp/cache/assets` in the cached paths alongside `public/assets`, because the incremental compile reads it to skip unchanged files. The Bootsnap compile cache is worth caching in the same step; it removes a repeated boot cost from every process a parallel run forks. ### [A serialized suite that never fans out](#a-serialized-suite-that-never-fans-out)Copy Anchor Link The most common waste is a suite that runs on one process on a machine with eight. `rspec spec` uses a single process by default, and Minitest outside Rails does the same. Adopt `parallel_rspec` or `parallelize(workers: :number_of_processors)`, create one database per worker (`parallel:create` and `parallel:load_schema` handle this for parallel\_tests, and Rails names them `app_test-0`, `app_test-1`, and so on), and confirm the split is even before buying a larger runner. A suite pinned by one long file gets nothing from more cores until that file is split. When a failure only reproduces inside GitHub Actions, the WarpBuild Action Debugger pauses the workflow and opens an SSH session on the live runner, which is faster than adding `puts` statements and re-running a 20 minute suite. For suites that stand up their dependencies with Compose instead of service containers, the [Docker Compose integration tests page](/solutions/docker-compose-integration-tests-github-actions) covers that setup. ## [Proof](#proof)Copy Anchor Link Teams running Python services next to a Rails app can apply the same runner and cache pattern there; the [Python test suites page](/solutions/python-github-actions) covers the pytest equivalents, and the [dependency caching answer](/answers/how-do-i-cache-pip-dependencies-in-github-actions) covers cache keys in general. ## [FAQ](#faq)Copy Anchor Link ### [Do I need to change my Ruby workflow to run on WarpBuild runners?](#do-i-need-to-change-my-ruby-workflow-to-run-on-warpbuild-runners)Copy Anchor Link Change the runs-on label to a warp- label such as warp-ubuntu-latest-x64-8x, and swap ruby/setup-ruby for WarpBuilds/setup-ruby@v1.295.0\. Your Gemfile, RSpec configuration, and rake tasks stay the same. ### [Does bundler-cache: true replace my bundle install step?](#does-bundler-cache-true-replace-my-bundle-install-step)Copy Anchor Link Yes. With bundler-cache: true, WarpBuilds/setup-ruby runs bundle install and caches the installed gems, so a separate bundle install step can be removed. The input defaults to false, so the workflow has to set it explicitly. ### [How do I force a gem cache rebuild?](#how-do-i-force-a-gem-cache-rebuild)Copy Anchor Link Bump cache-version on WarpBuilds/setup-ruby. It is an arbitrary string folded into the cache key, so changing it writes a fresh entry. Use it after a Ruby patch upgrade or a system library change that leaves compiled native gems stale. ### [How many parallel test processes should a Ruby suite run?](#how-many-parallel-test-processes-should-a-ruby-suite-run)Copy Anchor Link Start with one process per vCPU: 4 on warp-ubuntu-latest-x64-4x, 8 on the 8x, and 16 on the 16x. Lower the count when processes contend on a single Postgres service container, and give every process its own test database. --- # Fast Rust Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/rust-github-actions Description: Fast Rust builds on GitHub Actions come from bigger warp- runners, warm cargo and target caches, and less link work. Sizing tables and cost math inside. # Fast Rust Builds on GitHub Actions Fast Rust builds on GitHub Actions come from bigger warp- runners, warm cargo and target caches, and less link work. Sizing tables and cost math inside. Last verified: 2026-08-13 Fast Rust builds on GitHub Actions come from three levers: enough vCPUs to feed cargo's parallel compilation, a cargo cache that survives between jobs, and less link-time work per change. WarpBuild covers the first two directly, with warp- labeled runners from 2 to 32 vCPUs billed per minute and a cache that keeps `~/.cargo` and `target` warm across runs. Switching takes one line. Change `runs-on` from a GitHub-hosted label to a warp- label, add a cache step, and the same workflow runs on a machine sized for rustc. The sections below cover the exact configuration, which size fits which workload, and the arithmetic behind the bill. ## [Overview](#overview)Copy Anchor Link Rust spends GitHub Actions minutes in four places: compiling the dependency graph, compiling your own crates, linking binaries, and building test executables. The first is embarrassingly parallel across crates. The second is bounded by your workspace's dependency structure. The third and fourth are dominated by the linker and by how many binaries `cargo test` produces. That profile rewards two things a hosted runner controls: core count and cache persistence. Cargo compiles independent crates concurrently, so a 16 vCPU machine chews through a 400-crate dependency graph in far fewer wall-clock minutes than a 2 vCPU machine. And because every dependency compiles from source, a cold `~/.cargo` and a cold `target` directory turn every push into a full rebuild. For Rust, the Linux runners do the work: [the runner catalog](/docs/ci/cloud-runners) lists Ubuntu 22.04, 24.04, and 26.04 images on x64 and Ubuntu 24.04 and 26.04 on ARM64, each in sizes from 2 to 32 vCPUs with 150GB SSDs. The images carry the same tooling as GitHub-hosted runners, so rustup, common linkers, and build essentials are already present. Runners are ephemeral VMs, freshly allocated per job and destroyed afterward. Cache is enabled by default on all Linux runners. The [WarpBuild cache documentation](/docs/ci/features/caching) covers the general mechanics; the next section applies them to cargo specifically. ## [Configuration](#configuration)Copy Anchor Link Here is a working Rust pipeline on WarpBuild runners. The build and test job runs on 16 vCPUs, the lint job on 4, and both keep their cargo state warm through [WarpBuilds/rust-cache](https://github.com/WarpBuilds/rust-cache), the WarpBuild fork of the standard Rust caching action. Copy Text ``` name: rust on: push: branches: [main] pull_request: env: CARGO_TERM_COLOR: always jobs: build-and-test: runs-on: warp-ubuntu-latest-x64-16x steps: - uses: actions/checkout@v4 - run: rustup toolchain install stable --profile minimal - uses: WarpBuilds/rust-cache@v2 with: cache-provider: warpbuild shared-key: rust-ci save-if: ${{ github.ref == 'refs/heads/main' }} - run: cargo build --workspace --locked - run: cargo test --workspace --locked lint: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - run: rustup toolchain install stable --profile minimal --component clippy --component rustfmt - uses: WarpBuilds/rust-cache@v2 with: cache-provider: warpbuild shared-key: rust-lint - run: cargo fmt --check - run: cargo clippy --workspace --all-targets --locked ``` Three details matter here. **Install the toolchain before the cache step.** The rustc version is part of the cache key, so `rustup toolchain install` has to run first or the action keys the cache against the wrong compiler. **`cache-provider: warpbuild` routes storage to WarpBuild Cache.** The action caches the cargo registry (index and downloaded crate archives), git dependency checkouts, and the workspace `target` directories with sensible pruning defaults. **`save-if` limits cache writes to main.** PR branches restore from the main-branch cache and skip the save, which keeps the cache count low and the restore keys predictable. `shared-key` gives every job in the group one stable cache instead of a per-job key. If you want explicit control over the paths instead, use `WarpBuilds/cache@v1` directly. It is a drop-in replacement for `actions/cache@v4`, and the three directories that matter for cargo are the registry, the git index, and the target directory: Copy Text ``` - uses: WarpBuilds/cache@v1 with: path: | ~/.cargo/registry ~/.cargo/git target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo- ``` Keying on `Cargo.lock` means the cache refreshes exactly when the dependency set changes, and `restore-keys` falls back to the newest previous cache so a lockfile bump starts from a mostly warm `target` rather than from nothing. Cache entries expire after 7 days without use. For aarch64 targets, swap the label to an ARM64 size such as `warp-ubuntu-latest-arm64-8x` and the same configuration compiles natively with no cross-compilation toolchain. The [Linux ARM64 runner page](/runners/linux-arm64) lists the available sizes. ## [Sizing](#sizing)Copy Anchor Link The Linux x64 catalog, with per-minute rates from the [WarpBuild pricing page](/pricing): | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | How to map those sizes to cargo work: **warp-ubuntu-latest-x64-4x** fits `cargo fmt`, `cargo clippy`, and `cargo check` jobs, plus full builds of small crates. Lint jobs reuse compiled dependencies from cache and spend most of their time in single-crate analysis, which rarely holds more than a few cores busy. **warp-ubuntu-latest-x64-8x** is the default for a mid-size workspace. Dependency compilation fans out across all 8 vCPUs, and `cargo test` link steps for a handful of test binaries overlap with remaining compilation. **warp-ubuntu-latest-x64-16x** earns its rate on large workspaces. Two properties of rustc decide this. First, release builds split each crate into up to 16 codegen units by default, so even a single large crate can keep 16 threads busy during code generation. Second, each integration test file under `tests/` links its own binary; a crate with 20 integration test files has 20 link jobs that cargo schedules concurrently when cores are available. Debug builds use 256 codegen units per crate, so compilation parallelism is rarely the constraint there; the constraint is total crate count, and 16 vCPUs drain a wide graph fastest. **warp-ubuntu-latest-x64-32x** pays off only when the 16x chart shows sustained full-core saturation. Late in most Rust builds the dependency graph narrows to a few final crates plus their links, and extra cores idle. Check utilization before moving up. Link-time optimization changes the picture. Fat LTO (`lto = true`) serializes most code generation into one link step, so a fat-LTO release build gains little beyond 8 vCPUs; thin LTO (`lto = "thin"`) preserves parallelism and scales with the larger sizes. If your release profile uses fat LTO, size the release job separately from the test job. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes list prices for its hosted runners: the standard Linux runner meters at $0.006 per minute, and Linux larger runners meter at $0.012 for 4 vCPU, $0.022 for 8 vCPU, $0.042 for 16 vCPU, and $0.082 for 32 vCPU. Rates are from the [GitHub Actions billing documentation](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. Take a Rust workspace whose pull request pipeline consumes 40,000 runner-minutes per month on 16 vCPU machines, storing 25 GB of cargo cache and performing 10,000 cache operations: | Line item | Rate | Volume | Monthly cost | | ----------------------------------------- | --------------------- | ----------------- | ------------ | | GitHub-hosted Linux 16 vCPU larger runner | $0.042 per minute | 40,000 minutes | $1,680.00 | | warp-ubuntu-latest-x64-16x | $0.032 per minute | 40,000 minutes | $1,280.00 | | WarpBuild cache storage | $0.20 per GB-month | 25 GB | $5.00 | | WarpBuild cache operations | $0.0001 per operation | 10,000 operations | $1.00 | The WarpBuild total is $1,286.00 against $1,680.00 on GitHub-hosted larger runners for the same minutes, a difference of $394.00 per month. Put differently, warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13. Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Three failure modes account for most slow Rust pipelines on GitHub Actions. **Cold `~/.cargo` and `target` directories.** Every runner starts as a fresh VM. Without a cache step, each job fetches the registry index, downloads every crate archive, and compiles the entire dependency graph from source before touching your code. A workspace with 500 transitive dependencies can spend the majority of its wall-clock time rebuilding code that has not changed in months. The fix is the cache configuration above: registry, git checkouts, and `target`, keyed on `Cargo.lock`. Restores come from WarpBuild's cache rather than a distant object store, so pulling a multi-gigabyte `target` directory does not eat the time the cache was meant to save. **Link time on large crates.** Linking is the serial tail of a Rust build: one link per binary, and the default linker uses one process regardless of vCPU count. Big integration test suites multiply this, since every file in `tests/` is its own binary. Mitigations stack: switch the linker to `lld` or `mold` via `RUSTFLAGS`, merge integration tests into fewer files so there are fewer binaries to link, reduce debug info with `debug = "line-tables-only"` on the dev profile, and keep fat LTO out of PR builds. More memory also helps here, which is a reason the 16x runner's 64 GB matters for workspaces with large final binaries. **Dependency recompilation on feature-flag churn.** Cargo compiles a dependency once per unique feature set. When one workflow job runs `cargo check` on a subset of the workspace and another runs `cargo test --workspace`, feature unification can resolve differently and shared dependencies compile twice with different flags, invalidating what the cache stored. Changed `RUSTFLAGS`, a bumped toolchain, or a `--features` matrix have the same effect. Keep flags identical across jobs that share a cache key, pass `--locked` everywhere, and give jobs with genuinely different feature sets their own `shared-key` so they stop thrashing each other's cache. Finding which of these applies is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so a build stuck at one busy core during a long link step looks visibly different from a build saturating all cores through dependency compilation. Broader workflow-level tactics, including job parallelization and test sharding, are covered in the [guide to speeding up GitHub Actions](/guides/speed-up-github-actions). The same bottleneck analysis applies with different weights to other compiled languages; see the companion pages on [fast Go builds](/solutions/go-github-actions) and [fast C++ builds](/solutions/cpp-github-actions). ## [Proof](#proof)Copy Anchor Link Public Rust projects run production workloads on warp- labels, and the workflow files are open to read. * [near/nearcore](https://github.com/near/nearcore/blob/master/.github/workflows/ci.yml) runs the NEAR protocol node's GitHub Actions pipeline, including cargo-nextest suites, clippy, and coverage builds, on `warp-ubuntu-2404-x64-16x` and `warp-ubuntu-2404-x64-8x`. * [FuelLabs/sway](https://github.com/FuelLabs/sway/blob/master/.github/workflows/ci.yml) builds and tests the Sway compiler on `warp-ubuntu-latest-x64-4x` jobs alongside GitHub-hosted ones. * [lance-format/lance](https://github.com/lance-format/lance/blob/main/.github/workflows/rust-benchmark.yml) runs its Rust benchmark suite with `cargo bench` on `warp-ubuntu-latest-arm64-8x`. ## [FAQ](#faq)Copy Anchor Link **Which WarpBuild runner size should a Rust project start with?** Start on warp-ubuntu-latest-x64-8x at $0.016 per minute. Watch the CPU chart in WarpBuild's CI observability for your longest jobs. Move the build and test jobs to warp-ubuntu-latest-x64-16x when all 8 vCPUs stay saturated through compilation, and drop cargo fmt and clippy jobs down to warp-ubuntu-latest-x64-4x. **Do I have to rewrite my workflow to cache cargo on WarpBuild?** No. Add WarpBuilds/rust-cache@v2 with cache-provider set to warpbuild, or use WarpBuilds/cache@v1 with `~/.cargo/registry`, `~/.cargo/git`, and `target` as the cached paths. Both are drop-in compatible with the upstream actions they replace. **Can I build Rust for ARM64 on GitHub Actions with WarpBuild?** Yes. Linux ARM64 labels such as warp-ubuntu-latest-arm64-8x compile aarch64 targets natively, with rates starting at $0.003 per minute for the 2 vCPU size. **Is WarpBuild SOC 2 compliant?** The audit evidence is published at [trust.warpbuild.com](https://trust.warpbuild.com/). --- # Scala and sbt Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/scala-github-actions Description: Scala and sbt builds on GitHub Actions speed up with larger warp- runners, a warm coursier cache, and snapshot runners that carry zinc incremental state. # Scala and sbt Builds on GitHub Actions Scala and sbt builds on GitHub Actions speed up with larger warp- runners, a warm coursier cache, and snapshot runners that carry zinc incremental state. Last verified: 2026-08-13 Scala and sbt builds are slow on GitHub Actions for four specific reasons: a cold coursier cache on every fresh VM, zinc incremental compilation state thrown away between runs, macro and implicit derivation work that extra cores cannot split, and a JVM that starts cold on each job. WarpBuild addresses the first two directly, with warp- labeled Linux runners from 2 to 32 vCPUs billed per minute, a cache that keeps `~/.cache/coursier` and `~/.ivy2` warm, and snapshot runners that boot a job from a disk image where `target/` is already populated. Adoption is a label change plus a cache step. Point `runs-on` at a warp- label, swap `actions/setup-java` for `WarpBuilds/setup-java@v5` with `cache: sbt`, and the same build runs on a machine with the memory a multi-subproject sbt reactor and a fleet of forked test JVMs actually need. The sections below cover the exact configuration, which size fits which job, the four bottlenecks in order, and the arithmetic behind the bill. ## [Overview](#overview)Copy Anchor Link An sbt run spends GitHub Actions minutes in five places: resolving and downloading artifacts through coursier, compiling the build definition under `project/`, compiling sources with scalac, running tests under a forked JVM, and packaging or publishing. Only two of those parallelize on vCPU count. sbt compiles independent subprojects concurrently, and it runs test tasks concurrently across subprojects. Inside a single subproject, scalac is largely single threaded on both Scala 2.13 and Scala 3, so a build laid out as one large subproject gets almost nothing from a 32 vCPU machine. Build layout decides how much of the runner you can use. Memory is the constraint that surprises people. The sbt JVM holds the whole compiler state plus zinc's analysis, and every forked test JVM owns a separate heap on top of that. The JVM defaults the maximum heap to a quarter of physical memory, so on a 32 GB runner the sbt JVM will happily take 8 GB, and then each of eight forked test JVMs applies the same default independently. The sizing section sets explicit numbers instead. Scala work belongs on the Linux runners: Ubuntu 22.04, 24.04, and 26.04 images on x64 and Ubuntu 24.04 and 26.04 on ARM64, each from 2 to 32 vCPUs with 150GB SSDs, carrying the same tooling as GitHub-hosted runners. Runners are ephemeral VMs, allocated per job and destroyed afterward, which is exactly why coursier arrives empty unless a cache step fills it. Cache is enabled by default on Linux runners. Two tools matter here. Snapshot runners carry incremental state that a cache archive handles badly, and CI observability shows whether a slow job is pinned on one core in typer or spread across all of them in tests. One scope note. This page covers sbt and the Scala compiler. Projects that build Scala through Maven or Gradle share the JVM and heap behavior described here, and the build-tool specifics live on the [Java solutions page](/solutions/java-github-actions) and the [Maven solutions page](/solutions/maven-github-actions). ## [Configuration](#configuration)Copy Anchor Link Here is a working sbt pipeline on WarpBuild runners. The setup action handles the toolchain and the sbt dependency cache, and the explicit cache step covers the coursier and ivy directories plus the sbt boot directory. Copy Text ``` name: scala on: push: branches: [main] pull_request: jobs: build-and-test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: '21' cache: sbt - uses: WarpBuilds/cache@v1 with: path: | ~/.cache/coursier/v1 ~/.ivy2/cache ~/.sbt key: >- ${{ runner.os }}-sbt-${{ hashFiles('**/build.sbt', 'project/build.properties', 'project/plugins.sbt', 'project/**/*.scala') }} restore-keys: | ${{ runner.os }}-sbt- - run: sbt -batch "compile; Test/compile; test" ``` Four details matter. **`cache: sbt` has to be set explicitly.** Caching is off by default in the Java setup action, so an otherwise correct `WarpBuilds/setup-java@v5` step caches nothing until the input is present. The accepted values are `maven`, `gradle`, and `sbt`. Full input reference is in the [setup actions documentation](/docs/ci/features/caching/setup-actions). **The explicit cache step covers what the toolchain cache misses.** sbt resolves through coursier, whose Linux cache lives under `~/.cache/coursier/v1`, while older plugins and locally published artifacts still land in `~/.ivy2/cache`. `~/.sbt` holds the launcher boot jars and the global plugin set. Caching all three keeps the resolution phase off the network. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` with identical syntax. **The cache key hashes build inputs, not sources.** `build.sbt`, `project/build.properties`, and `project/plugins.sbt` are the files that change the dependency set, and `project/**/*.scala` catches multi-file build definitions. `restore-keys` falls back to the newest previous entry, so a single version bump starts from a mostly warm cache instead of an empty one. **One sbt invocation, several commands.** `sbt -batch "compile; Test/compile; test"` starts the JVM once and lets the JIT warm across all three phases. Three separate `sbt` steps pay full startup and warmup three times. For ARM64 targets, swap the label for a size such as `warp-ubuntu-latest-arm64-8x`. Temurin publishes aarch64 Linux builds, so the same build runs natively, with ARM64 rates starting at $0.003 per minute for the 2 vCPU size on the [pricing page](/pricing). ## [Sizing](#sizing)Copy Anchor Link The Linux x64 catalog, with per-minute rates from the [WarpBuild pricing page](/pricing): | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Pick the size by heap budget and test concurrency rather than by repository size. **8 vCPU, 32 GB, at $0.016 per minute.** This is the default for a compile and test job. Put a `.jvmopts` file at the repository root so the sbt launcher picks it up: Copy Text ``` -Xms2g -Xmx6g -Xss8m -XX:MaxMetaspaceSize=1g -XX:ReservedCodeCacheSize=512m ``` `-Xss8m` is the one people miss. Implicit resolution and type inference recurse deeply, and derivation-heavy code overflows the default 1 MB stack with a `StackOverflowError` inside typer rather than a useful compile error. `-XX:MaxMetaspaceSize` matters because scalac loads a large class graph, and macro-heavy builds load more. Then cap test concurrency in `build.sbt` so the forked heaps fit: Copy Text ``` Global / concurrentRestrictions += Tags.limit(Tags.Test, 4) Test / fork := true Test / parallelExecution := true Test / javaOptions ++= Seq("-Xmx2g") ``` Four concurrent test tasks at 2 GB each plus the 6 GB sbt JVM is 14 GB against 32 GB of RAM, leaving the rest for the OS page cache that zinc reads and writes heavily. **16 vCPU, 64 GB, at $0.032 per minute.** Move up when CI observability shows all 8 vCPUs saturated through both compile and test, which in practice means a reactor with enough independent subprojects to keep the scheduler busy. Raise `-Xmx` to 10g, lift `Tags.limit(Tags.Test, 8)`, and give each forked JVM 3 GB: 10 plus 24 is 34 GB against 64 GB. If the CPU chart instead shows one core pinned while the rest idle, the build is in typer and the larger runner will not change the wall clock. Keep scalafmt, scalafix, and doc jobs on `warp-ubuntu-latest-x64-4x` at $0.008 per minute; they reuse the same cache and rarely hold more than a couple of cores. Cross-building against several Scala versions is better sharded across matrix jobs than run as `sbt +test` on one large machine, because `+` walks the versions sequentially. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes list prices for its hosted runners: Linux larger runners meter at $0.022 per minute for 8 vCPU and $0.042 per minute for 16 vCPU. Rates are from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. Take a Scala monorepo consuming 18,000 runner-minutes per month on 8 vCPU machines, holding 6 GB of coursier and ivy cache, running 4,000 cache operations, keeping one snapshot alias alive for a 30-day month, and booting 900 jobs from that snapshot: | Line item | Rate | Volume | Monthly cost | | ---------------------------------------- | ------------------------ | ---------------- | ------------ | | GitHub-hosted Linux 8 vCPU larger runner | $0.022 per minute | 18,000 minutes | $396.00 | | warp-ubuntu-latest-x64-8x | $0.016 per minute | 18,000 minutes | $288.00 | | WarpBuild cache storage | $0.20 per GB-month | 6 GB | $1.20 | | WarpBuild cache operations | $0.0001 per operation | 4,000 operations | $0.40 | | Snapshot storage | $0.025 per snapshot-hour | 720 hours | $18.00 | | Snapshot restore | $0.04 per job | 900 jobs | $36.00 | The WarpBuild total is $343.60 against $396.00 for the same minutes on GitHub-hosted larger runners, a difference of $52.40 per month, with the snapshot and cache line items already included. The shapes match exactly, so the runner gap is pure rate difference: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13\. One size up, warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. Every rate above is on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Cold coursier cache.** sbt resolves through coursier, and a fresh VM starts with nothing: the Scala compiler jars, the standard library, every plugin declared in `project/plugins.sbt`, and the whole transitive dependency graph download before scalac emits a single class file. On a mid-size service that is hundreds of artifacts on every push, and on a monorepo with several Scala versions it is that number multiplied. The fix is the configuration above: `cache: sbt` on the setup action plus the explicit cache step over `~/.cache/coursier/v1`, `~/.ivy2/cache`, and `~/.sbt`. **Incremental compilation state lost between runs.** zinc keeps an analysis file next to the class files under `target/`, recording source hashes, the class-to-source mapping, and the dependency graph it uses to decide what a changed file forces to recompile. Ephemeral runners delete all of it, so every GitHub Actions run is a clean build and a one-line change in a leaf file costs the same as a rewrite. A cache archive is a poor container for that state. `target/` and `project/target/` churn on every commit, so the archive is written fresh each run, and the key can only be built from inputs known before compilation starts, which means the entry you restore rarely matches the tree you are about to build. Uploading and downloading tens of thousands of small class files per run also costs more time than the incremental compile saves. A snapshot runner carries the whole disk instead. Boot the job from a saved image and `target/`, `project/target/`, the coursier cache, and the git working tree are already there, so zinc compares hashes and recompiles only what changed. The label syntax appends to the runner label: Copy Text ``` jobs: build: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=scala-monorepo' }} steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: '21' cache: sbt - run: sbt -batch "compile; Test/compile; test" - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: | rm -rf $HOME/.ssh $HOME/.aws git clean -ffdx -e target -e project/target - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "scala-monorepo" fail-on-error: true wait-timeout-minutes: 60 ``` `snapshot.enabled=true` always boots from the base image, which is what the main-branch job wants before it saves a clean snapshot. `snapshot.key=` boots from the existing snapshot for that alias and falls back to the base image when none exists. Three constraints apply: snapshots are supported on WarpBuild Cloud Ubuntu runners only, and the labels are silently ignored on BYOC, Windows, and macOS runners; every snapshot is deleted after 15 days; and `/tmp` is cleaned on boot, so forked test JVMs that write scratch data there start empty each run. Details are in the [snapshot runners documentation](/docs/ci/features/snapshot-runners) and on the [snapshot runners page](/runners/snapshot). The same technique applied to other build tools is covered in the [guide to incremental builds on GitHub Actions](/guides/incremental-builds-github-actions). **Macro-heavy modules.** Automatic derivation with circe, shapeless, Magnolia, and refined, along with Scala 3 `inline` and `given` derivation, all run inside the typer phase. That phase is single threaded per compilation unit, so a module that spends most of its time expanding derivations gets nothing from a larger runner. Three changes work: switch automatic derivation to semi-automatic so each codec is materialized once at a named val rather than at every use site, move generated codecs into their own subproject so the rest of the reactor compiles alongside it, and keep derivation out of files that every other module depends on. Scala 2.13 accepts `-Vstatistics` to print per-phase timings when you want to confirm which phase owns the time. **JVM warmup.** scalac runs on the JVM, so the first minutes of any job execute interpreted bytecode while the JIT compiles the hot paths. A job that invokes sbt three times pays that cost three times, which is why the workflow above uses a single `sbt -batch` invocation with a command list. Leave full tiered compilation on: `-XX:TieredStopAtLevel=1` shortens startup but denies scalac the C2 output that long compiles depend on. Note that a snapshot boot does not preserve warmup. Snapshots restore the disk, and everything that lived only in memory, including JIT state and any running process, is gone. ## [Proof](#proof)Copy Anchor Link Public JVM projects run production workloads on warp- labels with readable workflow files. [restatedev/sdk-java](https://github.com/restatedev/sdk-java/blob/main/.github/workflows/tests.yml) builds and tests the Restate Java SDK with Gradle on `warp-ubuntu-latest-x64-4x`, compiling on a Temurin JDK and uploading JUnit test results on every pull request and push to main. The same runner labels, cache actions, and heap arithmetic apply to an sbt build on the same images. ## [FAQ](#faq)Copy Anchor Link **Which WarpBuild runner size should an sbt build start with?** Start the compile and test job on warp-ubuntu-latest-x64-8x at $0.016 per minute, with a `.jvmopts` file setting `-Xmx6g` for the sbt JVM and `Tags.limit(Tags.Test, 4)` capping concurrent test tasks. Move to warp-ubuntu-latest-x64-16x at $0.032 per minute when the CPU chart in WarpBuild's CI observability shows all 8 vCPUs saturated through compile and test. Keep scalafmt and scalafix check jobs on warp-ubuntu-latest-x64-4x at $0.008 per minute. **How do I cache coursier and ivy directories on WarpBuild runners?** Replace `actions/setup-java` with `WarpBuilds/setup-java@v5` and set `cache: sbt`, because caching is off by default in the Java setup action. Add a `WarpBuilds/cache@v1` step over `~/.cache/coursier/v1`, `~/.ivy2/cache`, and `~/.sbt`, keyed on a hash of `build.sbt`, `project/build.properties`, and `project/plugins.sbt`. **Do snapshot runners work for sbt builds on macOS, Windows, or BYOC?** No. Snapshot runners are supported on WarpBuild Cloud Ubuntu runners only. A `snapshot.enabled=true` or `snapshot.key=` label on a BYOC, Windows, or macOS runner is silently ignored and the job runs normally without snapshot functionality. **Will a larger runner speed up a macro-heavy Scala module?** On its own, rarely. Macro expansion and implicit derivation run inside the typer phase, which is single threaded per compilation unit, so extra vCPUs help only when sbt has independent subprojects to compile or forked test JVMs to run alongside it. Split derivation-heavy code into its own subproject so the rest of the build compiles in parallel with it. --- # Selenium Test Suites on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/selenium-github-actions Description: Pin the browser and its driver as one version pair, cache what Selenium downloads, then run parallel sessions per shard across a warp- runner matrix. # Selenium Test Suites on GitHub Actions Pin the browser and its driver as one version pair, cache what Selenium downloads, then run parallel sessions per shard across a warp- runner matrix. Last verified: 2026-08-13 To run Selenium tests on GitHub Actions, pin the browser and its driver to one version pair that lives in your repository, then fan the suite out as parallel sessions inside each job and shards across a matrix of jobs. The two failures that make Selenium suites slow and flaky on hosted runners are a driver resolved fresh on every ephemeral machine and a session count set higher than the machine's memory supports. This page covers the version pinning problem and the cache that keeps the pair aligned, a workflow with a session grid per shard and a shard matrix, a sessions-per-machine table with the Linux x64 rates applied, and the arithmetic behind the runner size the table recommends. ## [Overview](#overview)Copy Anchor Link Selenium 4 resolves drivers through Selenium Manager, which ships inside the client bindings. On a machine with no driver on `PATH`, it detects the installed browser, downloads the matching driver, and stores it under `~/.cache/selenium`, documented in the [Selenium Manager reference](https://www.selenium.dev/documentation/selenium%5Fmanager/). The cache location moves with `SE_CACHE_PATH`. That resolution step is convenient on a laptop and expensive on GitHub Actions. Every job runs on a fresh machine, so the folder starts empty, and every shard in a matrix repeats the same download before the first test executes. The alignment problem sits underneath the download. The browser on a hosted runner comes from the runner image, and image releases move browser versions on their own schedule. Ubuntu images ship Chrome, chromedriver, Firefox, and geckodriver preinstalled, listed in the [Ubuntu 24.04 image manifest](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md), and WarpBuild Linux x64 images carry the same tooling as GitHub-hosted runners, described under [preinstalled software](/docs/ci/preinstalled-software). So a browser major version bump reaches your pipeline through an image release rather than through a commit, and the first symptom is `SessionNotCreatedException` with a message stating that the driver supports one Chrome version while the machine has another. There are two honest fixes and they exclude each other: **Pin the pair in the repository.** Keep a version string in a file, install the browser and the driver from that one string, and cache the result. The version now moves when someone edits that file. **Pin a container image tag.** Run the browser inside a Selenium container. The [docker-selenium images](https://github.com/SeleniumHQ/docker-selenium) package the server, the browser, and the driver as one release, so the tag pins all three, and the runner image stops deciding what your tests run against. Selenium work belongs on the Linux x64 sizes, which run from 2 to 32 vCPU at 4GB of memory per core, listed under [cloud runners](/docs/ci/cloud-runners). Moving an existing job across is a one-line change to `runs-on`. One boundary before the configuration. This page covers Selenium: driver and browser version alignment, sessions against a WebDriver endpoint, and the memory each session holds. Browser automation stacks that manage their own browser downloads and their own worker pool behave differently, and that configuration is on the [Playwright test suites on GitHub Actions](/solutions/playwright-github-actions) page. ## [Configuration](#configuration)Copy Anchor Link Start with the pin. A one-line file, `.chrome-version`, holds the full version string, and both installs read it, so the browser and the driver can never drift apart: Copy Text ``` - name: Restore the driver and browser cache uses: WarpBuilds/cache@v1 with: path: ~/.cache/selenium key: selenium-${{ runner.os }}-${{ hashFiles('.chrome-version') }} - name: Install the pinned browser and driver run: | version=$(cat .chrome-version) npx @puppeteer/browsers install "chrome@$version" --path "$HOME/.cache/selenium" npx @puppeteer/browsers install "chromedriver@$version" --path "$HOME/.cache/selenium" ``` That cache entry carries no `restore-keys` ladder on purpose. A partial hit here restores a browser build for a version the job is about to replace, which costs storage and saves nothing, so an exact key and a clean miss is the cheaper miss. The container route removes both the download and the pin file. This workflow runs a Selenium node as a service container per shard, sets the session ceiling on that node, and splits the suite four ways: Copy Text ``` name: selenium on: pull_request: push: branches: [main] jobs: e2e: runs-on: warp-ubuntu-latest-x64-16x strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] services: selenium: image: selenium/standalone-chromium:${{ vars.SELENIUM_IMAGE_TAG }} ports: - 4444:4444 env: SE_NODE_MAX_SESSIONS: 12 SE_SESSION_REQUEST_TIMEOUT: 600 options: --shm-size=2g env: SELENIUM_REMOTE_URL: http://localhost:4444 steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Restore the pip cache uses: WarpBuilds/cache@v1 with: path: ~/.cache/pip key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }} restore-keys: | pip-${{ runner.os }}- - run: pip install -r requirements.txt - name: Wait for the node to register run: | for i in $(seq 1 60); do curl -sf http://localhost:4444/status | grep -q '"ready": *true' && exit 0 sleep 2 done echo "Selenium node did not become ready" >&2 exit 1 - name: Run shard ${{ matrix.shard }} run: | pytest tests/e2e \ -n 12 --dist loadfile \ --splits 4 --group ${{ matrix.shard }} \ --junitxml=results-${{ matrix.shard }}.xml - uses: actions/upload-artifact@v4 if: always() with: name: results-${{ matrix.shard }} path: results-${{ matrix.shard }}.xml ``` Four details in that file carry the weight. The image tag comes from `vars.SELENIUM_IMAGE_TAG`, a repository variable. The `env` context is not available in a `services` block, and `vars` is, so a repository variable is how one pinned tag reaches every workflow that needs it. See the [contexts reference](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts) for what each block can read, and the [service containers guide](https://docs.github.com/en/actions/tutorials/use-containerized-services/use-docker-service-containers) for the lifecycle. `SELENIUM_REMOTE_URL` is what redirects the client. Selenium bindings read it and build a remote session against that address, so the same test code that constructs a local driver on a laptop talks to the node in the service container with no branch in the test suite. `--shm-size=2g` raises shared memory for the container. Docker defaults `/dev/shm` to 64MB, Chromium shares renderer memory through it, and the failure at the default size is a page crash that reads like a flaky test. `SE_NODE_MAX_SESSIONS: 12` is the ceiling, and `-n 12` is the demand. Keep the two equal. A node's default ceiling is derived from the CPUs it sees, and raising it past that number needs `SE_NODE_OVERRIDE_MAX_SESSIONS`, which is a flag worth leaving alone. `SE_SESSION_REQUEST_TIMEOUT` is how long a queued request waits for a free slot before failing, so raise it while you tune the count rather than reading a queue timeout as a broken test. Cache behavior from the [caching documentation](/docs/ci/features/caching) shapes the pip entry above. Entries are scoped to key, version, and branch and expire 7 days after last use, so seed on `main` and let branch builds fall back through `restore-keys`. Cache is metered at $0.20 per GB-month of storage and $0.0001 per write or restore operation. ## [Sizing](#sizing)Copy Anchor Link A Selenium session is a browser process tree, not a thread. Chromium holds a browser process, a renderer per page, its own utility and network processes, and the shared memory those renderers pass frames through. Budget 2 GB per concurrent session and keep a 4 GB reserve for the runner agent, the test process, and anything the suite starts locally. That gives a memory ceiling of `(RAM_GB - 4) / 2`, capped at 0.75 sessions per vCPU, since a session that gets less than one core spends its time waiting on the browser rather than on the application: | Runner label | vCPU | Memory | Sessions that fit | Per minute | Per session-minute | | -------------------------- | ---- | ------ | ----------------- | ---------- | ------------------ | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 1 | $0.004 | $0.0040 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 3 | $0.008 | $0.0027 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 6 | $0.016 | $0.0027 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 12 | $0.032 | $0.0027 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 24 | $0.064 | $0.0027 | Rates come from the [cloud runners documentation](/docs/ci/cloud-runners). The right-hand column is the reason the workflow above sits on 16x rather than on four 4x machines: every Linux x64 size bills $0.002 per vCPU-minute, so the cost per concurrent session is flat from 4x upward, and only the 2x size prices worse because the 4 GB reserve eats half of its memory. Above 4x, larger machines are free in per-session terms and cheaper in wall clock, because each job pays checkout, install, and node startup once instead of once per machine. The cap in that table applies per machine. Raising the shard count is the lever that adds sessions beyond it, and the mechanics of splitting a suite that way are covered in the [guide to sharding test suites across a matrix](/guides/matrix-sharding-github-actions). The per-session memory figure is worth measuring once for your own application, since a page holding large canvases or long DOM histories costs more than 2 GB; the method is in [how to run browser tests headless in GitHub Actions](/answers/how-do-i-run-browser-tests-headless-in-github-actions). ### [Worked cost model](#worked-cost-model)Copy Anchor Link Take 900 Selenium tests averaging 30 seconds of wall time each: 450 minutes of session time per run. Four shards on `warp-ubuntu-latest-x64-16x` at 12 sessions each hold 48 sessions, so the session work lands near 9.4 minutes per shard, and setup adds about 2 minutes. Call it 12 minutes per shard at 500 workflow runs a month. | Line item | Rate | Minutes per month | Monthly cost | | ----------------------------------------------- | ------------------------------ | ----------------- | ------------ | | 4 shards, 12 min each, 16 vCPU | $0.032/min | 24,000 | $768.00 | | Cache storage and operations (1GB, 4,000 ops) | $0.20/GB-month plus $0.0001/op | n/a | $0.60 | | WarpBuild total | 24,000 | $768.60 | | | Same minutes on the 16-core Linux larger runner | $0.042/min | 24,000 | $1,008.00 | The comparison behind that last row, stated as list-price arithmetic: warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB), which is 24 percent lower list price ([GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13). Every size and platform rate sits on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Driver resolution on every shard.** Selenium Manager downloads a driver per cold machine, and a four-shard matrix is four cold machines per run. The tell is a gap between the start of the test command and the first test result, identical on every shard. The pin plus cache above removes it, and the container route removes it by never resolving a driver at all. **Version drift arriving through the image.** A workflow that passed yesterday fails today at session creation with a driver and browser mismatch, and the diff for the day is empty. Nothing in the repository changed because the change was upstream. Both fixes in the overview stop it; what does not stop it is deleting caches, which is the usual first reaction and moves the failure by one run at most. **Shared memory in containers.** Chromium renderers crash when `/dev/shm` is at the 64MB Docker default, and the crash surfaces as a lost session part way through a spec, moving between tests from run to run. Set `--shm-size=2g` on the service container. Where a container is not involved, the runner's own shared memory scales with machine memory, which is another reason session counts and machine size stay tied. **Sessions queued past the node ceiling.** Asking for 16 parallel workers against a node capped at 12 does not fail immediately. Requests queue, wait for the request timeout, then fail in a batch that looks like an application timeout. Read the node status endpoint during a run, or set the worker count and `SE_NODE_MAX_SESSIONS` from one variable so they cannot disagree. When the responsible layer is unclear, WarpBuild CI observability correlates runner system metrics with GitHub Actions job logs, which separates a memory-bound browser from a test genuinely waiting on the application. The Action Debugger pauses the workflow and opens an SSH session on the runner, so you can curl the node status endpoint and read `~/.cache/selenium` on the machine itself. ## [Proof](#proof)Copy Anchor Link Every cost number on this page is list-price arithmetic against published rates, with the source linked and the date stated, so you can re-run it against your own minute counts and session counts. Public repositories running `warp-` labels are citable evidence, and checking one takes as long as reading a `runs-on` line. The Trigger.dev end-to-end matrix runs on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x`, which you can read in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml) (checked on 2026-08-13). ## [FAQ](#faq)Copy Anchor Link ### [Why does chromedriver stop matching Chrome on GitHub Actions with no change to my repository?](#why-does-chromedriver-stop-matching-chrome-on-github-actions-with-no-change-to-my-repository)Copy Anchor Link The runner image supplies both, and image releases move them on their own schedule. An ephemeral machine takes whatever the current image ships, so a browser major version bump arrives between two runs of an unchanged workflow and the session fails at creation. Pin a version string in the repository and install the browser and the driver from that one string, or pin a container image tag that carries both. ### [How many Selenium sessions fit on one GitHub Actions runner?](#how-many-selenium-sessions-fit-on-one-github-actions-runner)Copy Anchor Link Subtract a 4 GB reserve from runner memory, divide by 2 GB per Chromium session, then cap the result at 0.75 sessions per vCPU. On `warp-ubuntu-latest-x64-16x` with 16 vCPU and 64 GB that lands at 12 sessions, and on `warp-ubuntu-latest-x64-8x` with 8 vCPU and 32 GB it lands at 6. ### [Do I need a Selenium Grid to run tests in parallel on GitHub Actions?](#do-i-need-a-selenium-grid-to-run-tests-in-parallel-on-github-actions)Copy Anchor Link No. A local driver with a parallel test runner works when every session runs the same browser. A Grid earns its place when you want the browser pinned by an image tag rather than by the runner image, when sessions need different browsers, or when you want one place to set the session ceiling per machine. --- # Serverless Deployments from GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/serverless-deployments-github-actions Description: One package job builds the deployment artifact and every environment ships those exact bytes. The warp- labels, the OIDC workflow, and the transfer math. # Serverless Deployments from GitHub Actions One package job builds the deployment artifact and every environment ships those exact bytes. The warp- labels, the OIDC workflow, and the transfer math. ## [Overview](#overview)Copy Anchor Link A serverless deployment on GitHub Actions is a package job that builds one deployment artifact and one deploy job per environment that fetches those exact bytes and points a function at them. The split carries more weight here than in most pipelines, because the deploy step is an upload followed by an alias change, so any job that rebuilds before uploading ships bytes that nothing tested. This page covers the package and deploy split, a workflow with an OIDC credential exchange and per-environment deploy jobs, the `warp-` labels and rates for each side, what a large deployment package costs when the deploy jobs pull it, and the failure modes that show up once the pipeline runs on every merge. The package job does the work that consumes cores. It installs dependencies, bundles handlers, prunes development dependencies, produces a zip, and records the checksum of that zip as a job output. The artifact goes to artifact storage once. Each deploy job does something narrower. It downloads the artifact, verifies the checksum against the packaging job's output, exchanges the workflow OIDC token for short-lived cloud credentials, stages the zip in a bucket in the target account, updates the function code, waits for the update to settle, publishes a version, and shifts the alias. No compile step sits behind the approval gate. The checksum verification is what makes promotion real. A package rebuilt for production resolves dependencies again, and a transitive dependency published in the twenty minutes between the staging deploy and the production approval changes the bytes without changing the commit. Comparing a sha256 recorded upstream against the file on the deploy runner turns that into a failed step rather than a production incident. The general shape of that handoff is covered in [build once and deploy many with GitHub Actions](/guides/build-once-deploy-many-github-actions). Serverless packaging and deploy jobs live on the Linux labels, and per-size rates are on the [pricing page](/pricing). These jobs hold cloud credentials, so runner isolation matters: each runner is a fresh virtual machine with its own encrypted storage volume, created on demand and destroyed after the job, as described in the [security documentation](/docs/ci/security). ## [Configuration](#configuration)Copy Anchor Link One package job, two deploy jobs, one artifact between them. The functions in this example run on Lambda's `arm64` architecture, so the package job runs on an ARM64 label and the native modules in the bundle match the target. Copy Text ``` name: deploy-functions on: push: branches: [main] permissions: contents: read jobs: package: runs-on: warp-ubuntu-latest-arm64-4x outputs: checksum: ${{ steps.hash.outputs.sha256 }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - run: npm ci - name: Bundle handlers run: npx esbuild src/handlers/*.ts --bundle --platform=node --target=node22 --outdir=dist - name: Install runtime dependencies only run: npm ci --omit=dev --prefix dist - name: Zip the deployment package run: cd dist && zip -qr ../function.zip . - name: Record the package checksum id: hash run: echo "sha256=$(sha256sum function.zip | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - uses: actions/upload-artifact@v4 with: name: function-package path: function.zip retention-days: 14 deploy-staging: needs: package runs-on: warp-ubuntu-latest-arm64-2x environment: staging permissions: contents: read id-token: write concurrency: group: deploy-functions-staging cancel-in-progress: false steps: - uses: actions/download-artifact@v4 with: name: function-package - name: Verify the package is the one that was built run: echo "${{ needs.package.outputs.checksum }} function.zip" | sha256sum -c - - name: Exchange the GitHub OIDC token for AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::111122223333:role/deploy-functions-staging aws-region: us-east-1 - name: Stage the package in S3 run: aws s3 cp function.zip s3://acme-releases-staging/${{ github.sha }}.zip - name: Update, publish, and shift the alias run: | aws lambda update-function-code \ --function-name checkout-api \ --s3-bucket acme-releases-staging \ --s3-key ${{ github.sha }}.zip \ --architectures arm64 aws lambda wait function-updated --function-name checkout-api VERSION=$(aws lambda publish-version \ --function-name checkout-api \ --query Version --output text) aws lambda update-alias \ --function-name checkout-api \ --name staging \ --function-version "$VERSION" deploy-production: needs: [package, deploy-staging] runs-on: warp-ubuntu-latest-arm64-2x environment: production permissions: contents: read id-token: write concurrency: group: deploy-functions-production cancel-in-progress: false steps: - uses: actions/download-artifact@v4 with: name: function-package - name: Verify the package is the one that was built run: echo "${{ needs.package.outputs.checksum }} function.zip" | sha256sum -c - - name: Exchange the GitHub OIDC token for AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::444455556666:role/deploy-functions-production aws-region: us-east-1 - name: Stage the package in S3 run: aws s3 cp function.zip s3://acme-releases-production/${{ github.sha }}.zip - name: Update, publish, and shift the alias run: | aws lambda update-function-code \ --function-name checkout-api \ --s3-bucket acme-releases-production \ --s3-key ${{ github.sha }}.zip \ --architectures arm64 aws lambda wait function-updated --function-name checkout-api VERSION=$(aws lambda publish-version \ --function-name checkout-api \ --query Version --output text) aws lambda update-alias \ --function-name checkout-api \ --name production \ --function-version "$VERSION" ``` Four details in that file earn their place. **The two deploy jobs assume different roles in different accounts.** Each exchange runs against the [GitHub OIDC provider](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) with `id-token: write` on the job, and the trust policy on each role restricts it to the repository and to the environment named on the job. No long-lived access key sits in repository secrets. **The package goes through S3 rather than into the API call.** AWS caps a package uploaded directly at 50 MB zipped, which a bundled Node or Python service passes quickly, so `--s3-bucket` and `--s3-key` are the shape worth writing from the start. **The wait sits between the update and the publish.** `update-function-code` returns while the update is still in progress, and calling `publish-version` during that window fails with a conflict. `aws lambda wait function-updated` is the step that makes the release deterministic. **`cancel-in-progress` is false on both deploy jobs.** Cancelling a deploy between `update-function-code` and `update-alias` leaves new code published and the alias pointing at the old version, which takes a person to reconcile. Packaging format decides the ceiling, and AWS publishes each limit: | Package shape | AWS quota | How the deploy job ships it | | ------------------- | --------------------------------------------- | -------------------------------------- | | Zip in the API call | 50 MB zipped | \--zip-file fileb://function.zip | | Zip staged in S3 | 250 MB unzipped, function and layers together | \--s3-bucket and \--s3-key | | Container image | 10 GB | \--image-uri pointing at an ECR digest | Quotas from the [AWS Lambda quotas reference](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html), checked on 2026-08-13\. A service that outgrows the zip path moves to the image path, and the deploy job then pushes to ECR instead of a bucket. ## [Sizing](#sizing)Copy Anchor Link Size the two job types separately. Shapes come from the [cloud runners documentation](/docs/ci/cloud-runners) and rates from the [pricing page](/pricing). | Runner label | vCPU | Memory | Storage | Price per minute | | --------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-arm64-2x | 2 | 8GB | 150GB SSD | $0.003 | | warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 | The deploy job runs the AWS CLI, uploads a file, and polls until the function reports updated. Two cores cover that at $0.004 per minute on x64 and $0.003 on ARM64. The package job installs dependencies and bundles, so 4 vCPU at $0.008 per minute is the usual landing spot. Reach for the 8x label when the repository packages a dozen functions in one pass or runs a TypeScript compile before bundling. Architecture is a correctness question before it is a price question. Functions configured for Lambda's `arm64` architecture want packages built on an ARM64 runner, because any dependency with a compiled binary ships the wrong object file when it is installed on x64\. The ARM64 labels are also the cheaper row at every size. ### [Worked cost model](#worked-cost-model)Copy Anchor Link Twelve service repositories, each releasing 40 times a month, so 480 releases. Each package job holds a 4 vCPU runner 5 minutes. Each release runs two deploy jobs of 4 minutes on a 2 vCPU runner. | Line | Arithmetic | Monthly | | --------------- | ------------------------ | ------- | | Package minutes | 480 x 5 min x $0.008 | $19.20 | | Deploy minutes | 480 x 2 x 4 min x $0.004 | $15.36 | | Total | $34.56 | | GitHub publishes per-minute list prices in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the 4-core Linux larger runner is $0.012 per minute and standard `ubuntu-latest` for private repositories is $0.006 per minute. The same 2,400 package minutes and 3,840 deploy minutes come to $28.80 plus $23.04, or $51.84\. The arithmetic at each size: $0.012 minus $0.008 is $0.004, and $0.004 divided by $0.012 is 33 percent lower list price at 4 vCPU, with the same 33 percent at 2 vCPU from $0.006 against $0.004 (GitHub pricing, checked on 2026-08-13). Rebuilding the package inside each deploy job adds two more packaging runs per release: 480 x 10 min x $0.008, or $38.40 a month, which is more than the entire pipeline above. ### [What the deploy jobs pull](#what-the-deploy-jobs-pull)Copy Anchor Link Runner minutes are the visible half. Bytes are the half that lands on the cloud bill, and container-image functions move the most of them, because a job that scans or verifies an image before publishing pulls the whole thing. | Input | Value | Source | | -------------------------------- | ------------------------------ | ----------------------------------------------------------------------------- | | Releases per month | 480 | Your workflow run history | | Environments per release | 2 | This pipeline | | Image pulled per deploy job | 1.1 GB | Your ECR metrics | | Internet data transfer rate | $0.09 per GB | [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/) | | ECR to same-Region compute | $0.00 per GB | [Amazon ECR pricing](https://aws.amazon.com/ecr/pricing/) | | Free internet transfer allowance | 100 GB per month, all services | [Amazon EC2 On-Demand pricing](https://aws.amazon.com/ec2/pricing/on-demand/) | AWS rates checked on 2026-08-13\. Per month that is 480 x 2 x 1.1, or 1,056 GB. | Runner placement | Arithmetic | Monthly | | ---------------------------------------------------- | ----------------------- | ------- | | Outside your account, pulling over the internet path | 956 billable GB x $0.09 | $86.04 | | Inside your account and Region | 1,056 GB x $0.00 | $0.00 | The first row credits the 100 GB monthly allowance once, and that allowance is shared with production traffic, so a busy account has usually spent it before the pipeline runs. On the enterprise tier, runners pull large images and artifacts from ECR, S3, and similar stores with zero egress cost to you, whether your runners live in your cloud or ours; the scope and a worked model are on [zero egress on the enterprise tier](/enterprise/zero-egress). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Cold dependency installs.** The package job spends most of its wall time in `npm ci` or `pip install`. Cache the package manager directory keyed on the lockfile, and keep the cache key off anything that changes every commit. **Packages that outgrow their quota at deploy time.** A bundle that creeps past 250 MB unzipped fails in the deploy job, after the approval, with a quota error rather than a build error. Prune development dependencies during packaging, move shared libraries into layers, and treat the container image path as the destination for a service that keeps growing. **Architecture mismatch in native modules.** An x64 build of a compiled dependency deployed to an `arm64` function passes packaging and fails at the first cold start with an import error. Building on the label that matches the function architecture removes the class of failure. **Conflicts between overlapping releases.** Two runs updating one function collide, and a run that starts while an update is in progress ends with a conflict error. A fixed concurrency group per environment plus `aws lambda wait function-updated` is what serializes them. **Alias drift after a partial deploy.** A cancelled or failed job between publish and alias update leaves a published version nobody routes to. Roll back by shifting the alias to the previous version number rather than by redeploying, and record the previous version in the job before the shift. **Opaque failures.** When a deploy job hangs and the cause is unclear, CI observability correlates system metrics from the runner agent with GitHub Actions job logs, which separates a job waiting on a cloud API from one that is out of disk. The Action Debugger pauses a workflow and opens an SSH session on the runner, so the package on disk and the credentials in the environment can be inspected on the machine itself. The gating side of this pipeline is covered in [how to shape a deployment job on GitHub Actions](/guides/deployment-jobs-github-actions). ## [Proof](#proof)Copy Anchor Link Public OSS repositories running `warp-` labels are citable evidence, so the `runs-on` line can be read directly instead of taken on trust. The Trigger.dev end-to-end suite runs its matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x` in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml), checked on 2026-08-13\. That is the same 4x Linux label the package job above would use on x64. The cheaper check is your own pipeline. [Signup](https://app.warpbuild.com/) includes $10 free credits, which covers 2,500 minutes on `warp-ubuntu-latest-x64-2x` or about 3,300 minutes on `warp-ubuntu-latest-arm64-2x`. Point the package job at a `warp-` label, leave the deploy jobs where they are, and compare packaging time for the same commit. Every rate on this page carries its arithmetic, a source link, and the date it was checked, and the same numbers appear on the [pricing page](/pricing). Cloud list prices move, so re-check the AWS and GitHub links before quoting these totals internally, and substitute your own release count and package size. ## [FAQ](#faq)Copy Anchor Link **How do I deploy a Lambda package larger than 50 MB?** Stage it in S3 first. AWS caps a deployment package uploaded directly in the API call at 50 MB zipped, so a larger package goes to a bucket with `aws s3 cp` and then to the function with `update-function-code --s3-bucket` and `--s3-key`. The function plus its layers still has to fit the 250 MB unzipped quota, and past that the packaging format changes to a container image, which AWS allows up to 10 GB. **Should each environment build its own deployment package?** No. One package job publishes the artifact, records its `sha256`, and every deploy job downloads that artifact and verifies the checksum before it touches a function. Rebuilding per environment pays the packaging minutes once per target and lets a dependency published between two deploys change the bytes production runs. **What runner size does a serverless deploy job need?** The deploy job uploads a package and changes an alias, so `warp-ubuntu-latest-x64-2x` at $0.004 per minute or `warp-ubuntu-latest-arm64-2x` at $0.003 per minute covers it. The packaging job installs dependencies and bundles, which is the part that uses cores, so it belongs on a 4x label at $0.008 and $0.006 per minute. --- # SonarQube Scans on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/sonarqube-scans-github-actions Description: SonarQube scans on GitHub Actions need full git history, a warm analyzer cache, and heap for the scanner JVM. Workflow YAML, runner sizing, and cost math. # SonarQube Scans on GitHub Actions SonarQube scans on GitHub Actions need full git history, a warm analyzer cache, and heap for the scanner JVM. Workflow YAML, runner sizing, and cost math. Last verified: 2026-08-13 A SonarQube scan on GitHub Actions is one job that runs after build and test, checks the repository out with full history, restores the analyzer cache, and calls the scanner with a token held in secrets. On WarpBuild the same job runs on a warp- label with the vCPUs and memory the scanner JVM needs, billed per minute from $0.004, with the analyzer cache kept warm between runs. The parts that decide wall-clock time are the checkout depth, the analysis inputs the scanner has to read, and the heap it gets. The sections below give the job shape for pull requests and the default branch, a working workflow, sizing against the Linux rates, and the failure modes that make scans slow. ## [Overview](#overview)Copy Anchor Link A scan job has three inputs. The first is source plus git history: SonarQube reads blame to attribute issues and to separate new code from old code, so the scan job checks out with `fetch-depth: 0` rather than the default shallow clone. The second is analysis material produced by earlier steps, which differs by language. Java analysis needs compiled classes through `sonar.java.binaries`, C and C++ analysis needs a `compile_commands.json` written by the build, and coverage reporting needs the coverage file the test job produced. The third is credentials: a `SONAR_TOKEN` secret, plus a host URL when analysis targets a SonarQube Server instance. The scan runs in two shapes. **Pull request analysis.** The scanner action reads the GitHub Actions context and sends pull request parameters with the analysis, so the server compares the branch against its base and decorates the pull request with new issues and the quality gate result. This is the incremental shape, and it is the one that runs on every push to a branch. It only analyzes what the pull request touches for reporting purposes, though the scanner still walks the file tree it is pointed at. **Default branch analysis.** A push to the default branch runs the full analysis that sets the baseline every later pull request is measured against. This is the run that populates the project on the server, so it needs the complete input set: coverage from the whole suite, all modules, and the same analyzer versions the pull request runs use. Keeping both shapes in one workflow with the same runner label and the same cache key is what keeps results comparable. When the two runs use different toolchains, the paths recorded in analysis inputs stop resolving and the server reports phantom changes. SonarQube analysis usually sits alongside dependency and secret scanning in the same pipeline. This page covers the SonarQube job specifically; the scheduling, permissions, and result-upload patterns those jobs share are in the [guide to security scanning jobs on GitHub Actions](/guides/security-scanning-jobs-github-actions). Scan jobs belong on Linux: [the runner catalog](/docs/ci/cloud-runners) lists Ubuntu images on x64 and ARM64 in sizes from 2 to 32 vCPUs with 150GB SSDs, and the images carry the same tooling as GitHub-hosted runners, so the Java runtime the scanner needs is already installed. Runners are ephemeral VMs, freshly allocated per job, which is why the analyzer cache step matters. Cache is enabled by default on all Linux runners; the [cache documentation](/docs/ci/features/caching) covers the mechanics. ## [Configuration](#configuration)Copy Anchor Link This workflow runs tests on 8 vCPUs, uploads coverage, then runs the scan on 4 vCPUs with the analyzer cache restored. The scan job is skipped for pull requests opened from forks, because those runs receive no secrets. Copy Text ``` name: sonarqube on: push: branches: [main] pull_request: jobs: test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' - run: npm ci - run: npm test -- --coverage - uses: actions/upload-artifact@v4 with: name: coverage path: coverage/lcov.info sonarqube: needs: test if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository runs-on: warp-ubuntu-latest-x64-4x env: SONAR_SCANNER_JAVA_OPTS: -Xmx6g steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/download-artifact@v4 with: name: coverage path: coverage - uses: WarpBuilds/cache@v1 with: path: ~/.sonar/cache key: ${{ runner.os }}-sonar-${{ hashFiles('sonar-project.properties') }} restore-keys: | ${{ runner.os }}-sonar- - uses: SonarSource/sonarqube-scan-action@v5 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - uses: SonarSource/sonarqube-quality-gate-action@v1 timeout-minutes: 5 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} ``` Four details carry the weight. **The cache path is the analyzer home.** `~/.sonar/cache` holds the analyzer plugin jars the scanner downloads from the server on first use, which on a multi-language project runs to hundreds of megabytes. `WarpBuilds/cache@v1` is a drop-in replacement for `actions/cache@v4` with identical syntax. If the scanner runs inside a container step, set `SONAR_USER_HOME` to a path under the workspace and cache that path instead, since the home directory changes inside the container. **Credentials stay in `env`, never in `with`.** The token is passed as an environment variable so it never appears in an input that gets echoed into the step summary. Keep `SONAR_HOST_URL` pointed at your SonarQube Server instance, or at the SonarQube Cloud endpoint when the project lives there, and keep the organization and project key in `sonar-project.properties` so both run shapes read the same values. **Fork pull requests need a second workflow.** GitHub withholds secrets from workflows triggered by `pull_request` events on forks, so the `if` condition above skips the scan rather than failing it. To analyze fork contributions, have the first workflow upload the analysis inputs as an artifact and run the scan in a second workflow triggered by `workflow_run`, which executes in the base repository context and does receive secrets. **The quality gate wait is its own step with a timeout.** `sonarqube-quality-gate-action` polls the server after analysis is submitted. Server-side processing time is outside the runner's control, so bound it with `timeout-minutes` rather than letting a queued server hold a runner. For ARM64, swap the labels to sizes such as `warp-ubuntu-latest-arm64-4x`, which runs at $0.006 per minute. The scanner is a JVM application and the common analyzers ship as jars, so the workflow above needs no other change. ## [Sizing](#sizing)Copy Anchor Link The Linux x64 catalog, with per-minute rates from the [pricing page](/pricing) and the full list on the [Linux x64 runners page](/runners/linux-x64): | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Size the scan job by memory first. The scanner loads the syntax tree of each file, holds the symbol table for the module under analysis, and keeps issue and measure data in memory until it uploads. On a repository of a few hundred thousand lines that fits inside a 6 GB heap, which `warp-ubuntu-latest-x64-4x` supports with room for the OS file cache. Analysis-heavy repositories, meaning several million lines, many languages in one project, or generated sources that inflate the file count, are where the 4 vCPU size starts hitting garbage collection pressure and `warp-ubuntu-latest-x64-8x` at $0.016 per minute pays for itself in reduced wall clock. CPU matters in fewer places. Most analyzers process files sequentially inside one JVM, so extra cores idle during a JavaScript or Java scan. The exception is the C and C++ analyzer, which takes a thread count and scales across cores; a large C++ codebase is the case where 8 or 16 vCPUs shorten the scan directly. Run the build and test job on the larger size and let the scan job take the smaller one when the scan is single-threaded. Two sizing rules follow from that split. Do not run the scan on the same oversized runner as the build unless the two share a job for artifact reasons, and do not size the scan job by repository size alone: a monorepo split into several SonarQube projects scans each one in its own smaller job, which parallelizes across runners instead of demanding one large machine. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes list prices for its hosted runners: Linux larger runners meter at $0.022 per minute for the 8 vCPU size on private repositories, from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. Take a repository whose scan job runs 6 minutes on 8 vCPU machines across 900 pipeline runs a month, or 5,400 runner-minutes, with 3 GB of analyzer cache and two cache operations per run: | Line item | Rate | Volume | Monthly cost | | ---------------------------------------- | --------------------- | ---------------- | ------------ | | GitHub-hosted Linux 8 vCPU larger runner | $0.022 per minute | 5,400 minutes | $118.80 | | warp-ubuntu-latest-x64-8x | $0.016 per minute | 5,400 minutes | $86.40 | | WarpBuild cache storage | $0.20 per GB-month | 3 GB | $0.60 | | WarpBuild cache operations | $0.0001 per operation | 1,800 operations | $0.18 | The WarpBuild total is $87.18 against $118.80 for the same minutes on GitHub-hosted larger runners, a difference of $31.62 per month. The shapes match: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), 27 percent lower list price, GitHub list price checked on 2026-08-13. Every rate is on the [pricing page](/pricing). To turn that monthly figure into a per-review number, which is the one that moves when the team merges more often, the [guide to GitHub Actions cost per pull request](/guides/ci-cost-per-pull-request) shows the division. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Full-history checkout on a large repository.** `fetch-depth: 0` is required for blame, and on a repository with a long history the clone alone can dominate the job. Partial clone filters look like the fix and are a trap: a `blob:none` filter leaves the scanner unable to read historical blobs, so blame comes back empty and every line looks new. Cache the git objects or accept the clone cost, and keep the fetch in the scan job only rather than in every job of the pipeline. **Cold analyzer cache.** Without the cache step, each scan downloads the analyzer plugin set from the server before it reads a single file, and that transfer sits in the critical path of every pull request. The cache step above removes it. Cache entries expire after 7 days without use, so a rarely built branch pays the download again. **Scanner heap left to defaults.** The JVM caps its default maximum heap at a quarter of physical memory. On a 16 GB runner that is 4 GB, which a mid-size project exceeds quietly: garbage collection consumes the cores while the scan crawls, and the job either runs long or ends with an out-of-memory error. `SONAR_SCANNER_JAVA_OPTS: -Xmx6g` in the workflow above sets it explicitly, and the value should follow the runner size when you change the label. **Missing analysis inputs.** A scan that produces no coverage numbers or reports zero issues in a language is usually reading nothing. Java analysis without `sonar.java.binaries` pointed at compiled classes skips the bytecode rules, and coverage without the report file simply reports none. Both fail as a passing scan rather than an error, which is why the pattern of downloading the test job's artifacts into the scan job matters. On JVM projects the compile step that produces those classes is the expensive half of the pipeline; sizing and caching for it are covered on the [Java solutions page](/solutions/java-github-actions). **Serial tail behind the test matrix.** A scan job with `needs` on every shard of a test matrix starts only when the slowest shard finishes, and then adds its own clone, scan, and quality gate wait. Merging coverage reports in a small job before the scan, as the argent-x workflow cited below does, keeps that tail short. Telling a memory-bound scan apart from a scan waiting on the server is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so a job burning CPU in garbage collection looks visibly different from one idling on a quality gate poll. Snapshot runners, remote Docker builders, an MCP server, and the Action Debugger cover the rest of the pipeline around the scan. ## [Proof](#proof)Copy Anchor Link Public repositories run SonarQube analysis on warp- labels, and the workflow files are open to read. * [libfn/functional](https://github.com/libfn/functional/blob/main/.github/workflows/sonarcloud.yml) runs `SonarSource/sonarqube-scan-action` on `warp-ubuntu-latest-arm64-4x` inside a pinned GCC container, feeding the analyzer a `compile_commands.json` and gcov reports produced in the same job. Its [pull request scan workflow](https://github.com/libfn/functional/blob/main/.github/workflows/sonarcloud-pr-scan.yml) is the `workflow_run` pattern described above: the first workflow packages the analysis inputs, and the second runs the scan on the same runner label with access to the token, so pull requests from forks are analyzed too. * [argentlabs/argent-x](https://github.com/argentlabs/argent-x/blob/develop/.github/workflows/pull-request.yml) merges sharded Vitest coverage reports and runs the SonarQube scan step in a `merge-reports-unit` job on `warp-ubuntu-latest-x64-4x`, downstream of a test matrix that runs on WarpBuild runners. ## [FAQ](#faq)Copy Anchor Link **Which runner size should a SonarQube scan job use?** Start the scan job on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, which gives the scanner JVM 16 GB to work with. Move to `warp-ubuntu-latest-x64-8x` at $0.016 per minute when the scanner runs out of heap on a large codebase or when the C family analyzer is running with several threads. **How do I cache SonarQube analyzer downloads on WarpBuild runners?** Add `WarpBuilds/cache@v1` with `~/.sonar/cache` as the path, or set `SONAR_USER_HOME` to a workspace directory and cache that. Runners are ephemeral VMs, so without the cache step every scan re-downloads the analyzer plugins from the SonarQube server. **Why does the scan job need fetch-depth 0?** SonarQube reads git blame to attribute issues to authors and to decide which lines are new code. The default shallow checkout hides that history, so set `fetch-depth` to `0` on `actions/checkout` in the scan job. --- # Spring Boot Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/spring-boot-github-actions Description: Build and test Spring Boot on GitHub Actions with a warm Maven cache, reused container images, and warp- runners sized to heap per fork. Rates and YAML inside. # Spring Boot Builds on GitHub Actions Build and test Spring Boot on GitHub Actions with a warm Maven cache, reused container images, and warp- runners sized to heap per fork. Rates and YAML inside. Last verified: 2026-08-13 A Spring Boot build on GitHub Actions spends its minutes in four places: resolving dependencies onto a fresh machine, compiling and packaging the jar, starting application contexts under `@SpringBootTest`, and pulling and booting database containers for the integration suite. Point `runs-on` at a warp- label, cache the Maven or Gradle dependency directory, carry the container images between runs, and size the runner against heap per test fork rather than repository size. This page gives the workflow that does all four, the cache and image reuse configuration behind it, the heap arithmetic that decides which runner size to buy, and the monthly bill against GitHub-hosted list prices. ## [Overview](#overview)Copy Anchor Link Spring Boot adds two cost centers on top of a plain Java build. The first is the application context. Every distinct test configuration starts its own context, which runs component scanning, auto-configuration, bean creation, and usually a Flyway or Liquibase migration against a database. The Spring TestContext framework caches contexts and reuses them across test classes, with a default cache size of 32 contexts. A suite that produces more configurations than that evicts and rebuilds contexts inside a single run. The second is the container fleet. Integration tests reach for Testcontainers or the GitHub Actions `services` block to get Postgres, Kafka, Redis, or LocalStack, and every job starts on a fresh virtual machine with an empty image store. The pull happens again on every run, before the first test executes. Neither cost is a CPU problem alone. Memory decides how many test JVMs and containers fit at once, and the image store decides how much of the job is spent waiting on the registry. Spring Boot work belongs on the Linux runners: the [cloud runners documentation](/docs/ci/cloud-runners) lists Ubuntu 22.04, 24.04, and 26.04 images from 2 to 32 vCPUs with 150GB SSDs, carrying the same tooling as GitHub-hosted runners, so `./mvnw` and `./gradlew` run unchanged. Two other parts of the product surface apply here: snapshot runners capture a runner VM mid-workflow so later jobs boot with the image store already populated, and CI observability streams system metrics from the runner alongside the job logs. The surface also includes remote Docker builders, an MCP server, and the Action Debugger. ## [Configuration](#configuration)Copy Anchor Link The workflow below splits unit tests from integration tests. Unit tests stop at the `test` phase on 8 vCPUs. Integration tests run `verify` on 16 vCPUs, on a runner that boots from a snapshot holding the Testcontainers images. Copy Text ``` name: spring-boot on: push: branches: [main] pull_request: env: MAVEN_OPTS: -Xmx2g jobs: unit-tests: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: '21' cache: maven - run: ./mvnw -B -T 1C test integration-tests: runs-on: ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-16x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-16x;snapshot.key=spring-boot-it' }} steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-java@v5 with: distribution: temurin java-version: '21' cache: maven - name: Warm the image store run: | docker pull postgres:16 docker pull redis:7 - run: ./mvnw -B verify -Pintegration-tests - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: spring-boot-it ``` Four details carry the weight. **The dependency cache rides on the setup step.** [WarpBuilds/setup-java@v5](/docs/ci/features/caching/setup-actions) accepts the same inputs as the upstream setup action and routes the Maven, Gradle, or sbt dependency cache to WarpBuild Cache, so `~/.m2/repository` arrives populated without a separate cache step. For Gradle projects, set `cache: gradle`, or use `WarpBuilds/gradle-actions` when the build cache and configuration cache also need to survive between runs. Cache keys and the fallback behavior are covered in [how to cache Maven dependencies in GitHub Actions](/answers/how-do-i-cache-maven-dependencies-in-github-actions). **Container images ride in the snapshot.** The `docker pull` step is separate so the job log times it. On `main`, `snapshot.enabled=true` boots the base image and `WarpBuilds/snapshot-save` captures the VM after the images are resident. Pull requests boot from that alias with `snapshot.key`, so the image store is already warm. Snapshot boot takes 45 to 60 seconds, and the snapshot labels are silently ignored on BYOC, Windows, and macOS runners. Where a single database is enough, the plain `services` block is simpler; the tradeoffs are in [service containers in GitHub Actions](/guides/service-containers-github-actions). **Fork count and heap are set together.** Leaving both to defaults is what turns a 16 vCPU runner into a swap test. The JVM caps its default maximum heap near a quarter of physical memory, and each Surefire or Failsafe fork applies that default independently: Copy Text ``` org.apache.maven.plugins maven-surefire-plugin 1C true -Xmx1500m -XX:MaxMetaspaceSize=512m ``` `reuseForks` keeps the Spring context cache alive across test classes inside a fork. Setting it to `false` throws that cache away between classes and pays context startup again. **Integration tests stay out of the fast job.** `mvn test` stops before the `integration-test` phase, so the unit job never boots a container. The `-Pintegration-tests` profile in the second job is the conventional place to bind the Failsafe plugin. ## [Sizing](#sizing)Copy Anchor Link Linux x64 rates from the [pricing page](/pricing): | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | Size the machine with one sum: forks times the per-fork budget, plus container memory, plus 2 GB for the operating system and its file cache. Budget roughly 1.4 times the configured heap per fork, because metaspace, thread stacks, and the code cache sit outside `-Xmx`, and a Spring context holds a large class graph in metaspace. | Runner label | Forks at 1C | Heap per fork | JVM memory total | Left for containers and OS | | -------------------------- | ----------- | ------------- | ---------------- | -------------------------- | | warp-ubuntu-latest-x64-4x | 4 | 1.5 GB | 8.4 GB | 7.6 GB | | warp-ubuntu-latest-x64-8x | 8 | 1.5 GB | 16.8 GB | 15.2 GB | | warp-ubuntu-latest-x64-16x | 16 | 2 GB | 44.8 GB | 19.2 GB | Read the table against the workload. A unit suite with mocked collaborators and no containers fits the 4x size. A suite that starts a full context per fork lands on the 8x size, where 15.2 GB still covers a Postgres container and the file cache. The integration job is where the 16x size earns its rate, because Postgres, Redis, and sixteen Failsafe forks all want memory at once; when that job runs at 16 forks, drop `forkCount` to `0.5C` rather than pushing per-fork heap down to 1 GB, since eight healthy forks finish sooner than sixteen forks that spend their time in garbage collection. The 32x size pays off only when a 16x run shows every core saturated through the whole test phase. Spring suites usually stop scaling before that, held by context startup and container readiness rather than by cores. ### [Worked cost model](#worked-cost-model)Copy Anchor Link A service running 12,000 minutes per month of unit jobs on 8 vCPUs and 6,000 minutes of integration jobs on 16 vCPUs, with 600 integration jobs restoring one snapshot that is kept for the month, 8 GB of dependency cache, and 5,000 cache operations: | Line item | Rate | Volume | Monthly cost | | ----------------------------------------- | ------------------------ | ---------------- | ------------ | | GitHub-hosted 8-core Linux larger runner | $0.022 per minute | 12,000 minutes | $264.00 | | GitHub-hosted 16-core Linux larger runner | $0.042 per minute | 6,000 minutes | $252.00 | | warp-ubuntu-latest-x64-8x | $0.016 per minute | 12,000 minutes | $192.00 | | warp-ubuntu-latest-x64-16x | $0.032 per minute | 6,000 minutes | $192.00 | | WarpBuild snapshot restore | $0.04 per job | 600 jobs | $24.00 | | WarpBuild snapshot storage | $0.025 per snapshot-hour | 730 hours | $18.25 | | WarpBuild cache storage | $0.20 per GB-month | 8 GB | $1.60 | | WarpBuild cache operations | $0.0001 per operation | 5,000 operations | $0.50 | The WarpBuild side totals $428.35 against $516.00 for the same minutes on GitHub-hosted larger runners, a difference of $87.65 per month. The shapes match on both rows, so the runner gap is rate alone: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB), 27 percent lower list price, and warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB), 24 percent lower list price. GitHub list prices are from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. Every rate above is on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Context churn.** The Spring TestContext framework keys its cache on the full configuration of a test class. Adding one `@MockBean`, one `@TestPropertySource` value, or one extra `@ActiveProfiles` combination produces a new key and a new context. `@DirtiesContext` goes further and evicts, forcing the next class to rebuild. Count the distinct keys in the suite before buying a bigger runner: a suite with 60 configurations against a default cache of 32 rebuilds contexts inside one run no matter how many cores are present. Collapse the variants onto a handful of shared base test classes, and reach for `@DirtiesContext` only where a test genuinely mutates singleton state. **Migrations per context.** Flyway and Liquibase run at context startup, so every rebuilt context replays the whole migration history against the container database. On a schema with hundreds of migrations that dominates the suite. Keep one database container per fork rather than per class, and run the migration set once at fork startup rather than under `@DirtiesContext`. **Image pulls.** A three container Testcontainers stack downloads its layers on every uncached run, before any test code executes. The snapshot pattern above removes the repeat pull. Where a snapshot does not apply, pin digests so the pull is deterministic and authenticate to the registry so anonymous pull limits stop applying. **Packaging on every pull request.** `spring-boot:build-image` runs a buildpack build, which is a container build wearing a Maven goal's name. Running it on each pull request adds a full image build to a job that only needed a jar. Keep `bootJar` or `package` on pull requests and build the image on `main`, or move the image build to a remote Docker builder where the layer cache persists between runs. Telling these apart is a measurement problem. A job idling while contexts rebuild and a job saturating every core through Failsafe look identical in the GitHub Actions timing summary and different in the runner metrics that CI observability streams next to the job logs. Sizing arithmetic for the JVM more broadly is in the [guide to sizing runners for Java builds](/guides/sizing-runners-for-java-builds), and the Maven and Gradle layers underneath Spring Boot are covered in [faster Java builds on GitHub Actions](/solutions/java-github-actions). ## [Proof](#proof)Copy Anchor Link Public JVM projects run production workloads on warp- labels with readable workflow files. * [restatedev/sdk-java](https://github.com/restatedev/sdk-java/blob/main/.github/workflows/tests.yml) builds and tests the Restate Java SDK with Gradle on `warp-ubuntu-latest-x64-4x`, compiling on a Temurin JDK and uploading JUnit test results on every pull request and push to main. * [kintsugi-tax/killbill-kintsugi-plugin](https://github.com/kintsugi-tax/killbill-kintsugi-plugin/blob/main/.github/workflows/release.yml) builds a plugin for the Kill Bill billing platform with `mvn -B clean verify` on `warp-ubuntu-latest-x64-2x` in its release workflow. The runner labels, the cache action, and the heap arithmetic in those workflows are the same ones a Spring Boot service uses; the Spring specific work is the context cache and the container stack described above. ## [FAQ](#faq)Copy Anchor Link **Which runner size fits a Spring Boot build?** Run the unit test job on `warp-ubuntu-latest-x64-8x` at $0.016 per minute, which holds eight Surefire forks at a 1.5 GB heap each inside 32 GB. Move the integration job to `warp-ubuntu-latest-x64-16x` at $0.032 per minute when Testcontainers containers and test JVMs compete for the same 32 GB. **How do I keep Testcontainers images from being pulled on every run?** Pull the images in their own step, then capture the runner VM with `WarpBuilds/snapshot-save` and boot later jobs from that alias with `snapshot.key` on the `runs-on` label. Snapshot boot takes 45 to 60 seconds, and the labels are silently ignored on BYOC, Windows, and macOS runners. **Why is the Spring test suite slower than the number of tests suggests?** Each distinct test configuration builds its own application context, and the Spring TestContext framework caches at most 32 of them by default. `@MockBean`, `@TestPropertySource`, and `@DirtiesContext` each create or evict contexts, so a suite with dozens of variants pays repeated context startup instead of reusing one. --- # Storybook Builds and Visual Tests on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/storybook-github-actions Description: Build a static Storybook on GitHub Actions, cache node_modules/.cache and the browser binaries, run image snapshots, and size warp- runners by memory. # Storybook Builds and Visual Tests on GitHub Actions Build a static Storybook on GitHub Actions, cache node\_modules/.cache and the browser binaries, run image snapshots, and size warp- runners by memory. Last verified: 2026-08-13 To run Storybook on GitHub Actions, build the static Storybook once with `storybook build`, serve `storybook-static` inside the job, and run the story tests against that server so every story renders in a real browser. Cache `node_modules/.cache` and the browser binaries under `~/.cache/ms-playwright` so repeat runs skip the builder work and the browser download, then pick the runner size for the snapshot job from memory rather than from vCPU count. A Storybook pipeline has two phases with different resource shapes: a bundler build that spends most of its time on one thread, and a snapshot sweep that runs a browser per worker. This page covers the cache directories that make repeat runs cheap, a workflow that builds the static Storybook and compares images against committed references, per-phase sizing with rates applied, and the failures that make story snapshots flaky. ## [Overview](#overview)Copy Anchor Link Every GitHub Actions job starts on a fresh virtual machine, so a Storybook repository starts with an empty `node_modules`, an empty builder cache, and no browsers installed. The build phase runs `storybook build`, which writes a static site to `storybook-static`. The snapshot phase serves that directory over HTTP and points the Storybook test runner at it, so each story is visited in a browser, its play function runs, and a screenshot is compared against a committed reference image. Five paths decide whether a repeat run is cheap, and only three of them belong in a cache: | Path | What it holds | Key on | Reused by | | ------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------ | | \~/.npm or the pnpm store | Package tarballs from the registry | Lockfile hash | Every job | | node\_modules/.cache | Storybook builder state, including the webpack filesystem cache and the Vite dependency pre-bundle | Lockfile plus .storybook/main.ts | Build job, and any job that rebuilds | | \~/.cache/ms-playwright | Browser builds the test runner drives | Installed Playwright version | Snapshot job | | storybook-static | The built static Storybook | Not cached | Moved to the snapshot job with actions/upload-artifact | | \_\_image\_snapshots\_\_ | Committed reference images | Not cached | Read from the checkout, diffs uploaded on failure | The builder cache key is the row people get wrong. Hashing the lockfile alone keeps a stale entry after an addon is added, a framework is swapped, or a builder option changes in `.storybook/main.ts`, and the resulting build either rebuilds everything anyway or serves a module graph that no longer matches the config. Adding `.storybook/main.ts` to the hash rolls the entry exactly when the graph changes. The built Storybook moves between jobs as an artifact rather than as a cache entry. A cache entry is keyed content that later runs restore; `storybook-static` is a per-commit result that one downstream job consumes once. Storybook work belongs on Linux x64, and the [caching documentation](/docs/ci/features/caching) makes that concrete: WarpBuild Cache is not supported on Windows runners, which would put both cached rows back on a cold path. ## [Configuration](#configuration)Copy Anchor Link This workflow builds the static Storybook on an 8 vCPU runner, hands it to the snapshot job as an artifact, and runs the story tests against a local server on a 16 vCPU runner. On WarpBuild runners the only edits to a stock Storybook workflow are the `runs-on` labels and the cache action, where [WarpBuilds/cache](https://github.com/WarpBuilds/cache) is a drop-in replacement for `actions/cache@v4`. Copy Text ``` name: storybook on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci - name: Restore the Storybook builder cache uses: WarpBuilds/cache@v1 with: path: node_modules/.cache key: ${{ runner.os }}-storybook-${{ hashFiles('package-lock.json', '.storybook/main.ts') }} restore-keys: | ${{ runner.os }}-storybook- - run: npx storybook build --quiet - uses: actions/upload-artifact@v4 with: name: storybook-static path: storybook-static retention-days: 3 snapshot: needs: build runs-on: warp-ubuntu-latest-x64-16x steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: npm - run: npm ci - name: Read the Playwright version id: pw run: | echo "version=$(node -p "require('playwright/package.json').version")" >> "$GITHUB_OUTPUT" - name: Restore browsers id: browsers uses: WarpBuilds/cache@v1 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ steps.pw.outputs.version }} - name: Install browsers and system libraries if: steps.browsers.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium - name: Install system libraries only if: steps.browsers.outputs.cache-hit == 'true' run: npx playwright install-deps chromium - uses: actions/download-artifact@v4 with: name: storybook-static path: storybook-static - name: Serve the build and run the story tests run: | npx concurrently -k -s first -n SB,TEST \ "npx http-server storybook-static --port 6006 --silent" \ "npx wait-on tcp:127.0.0.1:6006 && npx test-storybook --maxWorkers=6" - uses: actions/upload-artifact@v4 if: failure() with: name: image-diffs path: __image_snapshots__/__diff_output__ retention-days: 7 ``` The test runner visits stories and runs play functions on its own. Image comparison is a hook you add in `.storybook/test-runner.ts`: Copy Text ``` import type { TestRunnerConfig } from "@storybook/test-runner"; import { toMatchImageSnapshot } from "jest-image-snapshot"; const config: TestRunnerConfig = { setup() { expect.extend({ toMatchImageSnapshot }); }, async postVisit(page, context) { const image = await page.screenshot({ fullPage: true }); expect(image).toMatchImageSnapshot({ customSnapshotIdentifier: context.id, customSnapshotsDir: `${process.cwd()}/__image_snapshots__`, }); }, }; export default config; ``` Four details in those files carry the weight. **The build runs once.** The snapshot job downloads `storybook-static` instead of rebuilding it, so the bundler work happens on the machine sized for bundling and the browsers get the machine sized for browsers. A single job that does both pays the larger rate for the whole duration. **A cache hit still needs `install-deps`.** The cache covers browser binaries under `~/.cache/ms-playwright`. It does not cover the apt packages those binaries link against, which live in system directories outside that path, so the hit branch runs `playwright install-deps` and skips the download only. **`--maxWorkers` is set explicitly.** The default worker count comes from the core count, which is the wrong basis for browser-driven snapshots. Set it from the memory budget instead, as the sizing section below explains. **Diffs upload only on failure.** `jest-image-snapshot` writes composite images to `__diff_output__` when a comparison fails, and that is the artifact a reviewer needs. Uploading it unconditionally ships nothing on a green run and costs storage on every one. Two cache behaviors from the [caching documentation](/docs/ci/features/caching) shape what the logs show. Entries are scoped to key, version, and branch, so seed the caches on `main` and let branch builds reach them through `restore-keys`. Entries expire 7 days after last use, so an active repository stays warm and an abandoned branch stops costing storage on its own. Cache storage is metered at $0.20 per GB-month with each write or restore operation at $0.0001. ## [Sizing](#sizing)Copy Anchor Link The Linux x64 rows from the [cloud runner catalog](/docs/ci/cloud-runners) that matter here: | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 | **Build phase: 8x for most libraries.** `storybook build` walks the module graph on one thread and then parallelizes transforms and minification across workers, so the curve flattens past 8 vCPU. Memory is what fails first on a large library: a few thousand stories with MDX docs pages can push Node past its default heap, and 32GB on 8x leaves room to raise `NODE_OPTIONS=--max-old-space-size` on a machine that can back it. A small component library builds fine on 4x at $0.008 per minute. **Snapshot phase: memory sets the worker count.** Each test-runner worker holds a browser context, the rendered story, and every font and image that story loads, and full-page screenshots allocate a bitmap on top of that. Cores go idle waiting on rendering long before they saturate, so the useful question is how many browser contexts fit in RAM rather than how many fit on the vCPUs. Start at half the vCPU count, which is `--maxWorkers=6` on a 16 vCPU runner with 64GB, and raise it while the job still passes. The failure mode when you overshoot is a worker killed mid-story, which surfaces as a page crash rather than as an out-of-memory message, so change one variable at a time. **When 32x earns its rate.** A design system with several thousand stories running a full sweep on every pull request is the case where 128GB carries enough contexts to cut the wall clock. Below that, more workers on 16x is the cheaper move, and a story subset filtered by tag is cheaper still. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices on the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the 16-core Linux larger runner is $0.042 per minute. Take a design system repository running 900 pull-request runs a month, with a 5 minute build and a 12 minute snapshot sweep: | Line item | Rate | Monthly volume | Monthly cost | | -------------------------------------------- | ----------------------------------------- | ---------------- | ------------ | | Build on warp-ubuntu-latest-x64-8x | $0.016/min | 4,500 min | $72.00 | | Snapshot on warp-ubuntu-latest-x64-16x | $0.032/min | 10,800 min | $345.60 | | Same snapshot minutes, GitHub-hosted 16-core | $0.042/min | 10,800 min | $453.60 | | Cache storage and operations | $0.20 per GB-month, $0.0001 per operation | 1.5GB, 5,400 ops | $0.84 | The snapshot job is the line that moves: $345.60 against $453.60 for the same minutes, a difference of $108.00 a month before any change to how long the sweep takes. Stated as list-price arithmetic: warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13. Rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Rebuilding Storybook in the snapshot job.** A workflow that runs `storybook build` inside the same job as the tests pays bundler time at the snapshot job's rate and blocks the browsers until the build finishes. Split the phases and pass the output as an artifact. **Cold browser downloads.** Without a restored `~/.cache/ms-playwright`, the snapshot job downloads a browser build before the first story renders. Confirm the restore from `steps.browsers.outputs.cache-hit` in the log rather than assuming it, since a mistyped path falls through to a full download in silence. **Too many workers.** A `--maxWorkers` value copied from a unit test config puts one browser per core on a machine that cannot hold them. The job dies partway through the sweep and the log points at whichever story was unlucky. **Small `/dev/shm` in container jobs.** A job that runs inside a container gets a 64MB `/dev/shm` by default, and Chromium crashes with a closed target once a page exceeds it. Either raise it with `--shm-size` on the container or pass `--disable-dev-shm-usage` to the browser launch options. **Fonts and animations producing diffs.** A story that starts a CSS transition or renders before a web font loads produces a different bitmap on every run, and every one of those is a failed comparison a human has to look at. Wait for `document.fonts.ready` in the `postVisit` hook and disable animations globally in `.storybook/preview.ts` before adding retries. Observability separates a memory-bound worker pool from a sweep waiting on the local server, and the Action Debugger pauses the workflow and opens an SSH session on the runner so you can serve `storybook-static` and open the failing story on the machine that failed. ## [Proof](#proof)Copy Anchor Link Every number on this page is reproducible without taking our word for it. WarpBuild rates come from the [pricing page](/pricing) and the [cloud runner catalog](/docs/ci/cloud-runners), the GitHub list price links to GitHub's own billing reference with the date it was checked, and the cost model is arithmetic you can repeat after substituting your own run count and sweep duration. Cost and performance statements on WarpBuild pages carry a number, a source link, and a checked-on date, and the same number appears on every surface. Moving an existing Storybook workflow over is a one-line change per job to `runs-on`. Two adjacent pages carry the parts this one compresses. The [Playwright on GitHub Actions](/solutions/playwright-github-actions) page covers sharding, blob reports, and browser caching in depth, and the guide on [sizing runners for Node test suites](/guides/sizing-runners-for-node-test-suites) covers the worker-count-against-memory question for suites that are not browser driven. The [Node.js on GitHub Actions](/solutions/nodejs-github-actions) page covers the install phase that runs before either. ## [FAQ](#faq)Copy Anchor Link **What should a Storybook cache key hash?** Hash the lockfile plus `.storybook/main.ts` for `node_modules/.cache`, since an addon, framework, or builder change invalidates the cached module graph while a routine dependency bump does not. Key the browser cache on the installed Playwright version instead, because browser builds are pinned to that release. **Should the snapshot job rebuild Storybook or download it?** Download it. Build once with `storybook build`, upload `storybook-static` with `actions/upload-artifact`, and have the snapshot job download it. Rebuilding repeats the bundler work in a job whose runner size was chosen for browsers rather than for bundling. **How many test-runner workers fit on a runner?** Fewer than the vCPU count on most component libraries. Each worker holds a browser context plus the rendered story and its assets, so memory fills before the cores do. Start at half the vCPU count with `--maxWorkers`, then raise it while the job still passes. --- # Svelte Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/svelte-github-actions Description: Cache the package manager store, node_modules/.vite, and .svelte-kit so svelte-check and vite build share one restore, then size warp- runners per phase. # Svelte Builds on GitHub Actions Cache the package manager store, node\_modules/.vite, and .svelte-kit so svelte-check and vite build share one restore, then size warp- runners per phase. Last verified: 2026-08-13 To build a Svelte or SvelteKit app on GitHub Actions, run `svelte-kit sync` and `svelte-check` in a type check job, run `vite build` in a build job, and let both jobs restore the same cache entries so neither one reinstalls dependencies or re-optimizes Vite's dependency graph. On WarpBuild runners the [WarpBuilds/cache](https://github.com/WarpBuilds/cache) action is a drop-in replacement for `actions/cache@v4` and stores those entries in WarpBuild's cache backend, which is enabled by default on Linux runners. The rest of a Svelte pipeline splits into three phases with different resource shapes: a single-process type check, a Rollup production bundle, and a Vitest suite that scales across cores. This page covers the cache paths, a two-job workflow that shares them, per-phase sizing with rates applied, and the bottlenecks that dominate Svelte pipelines. ## [Overview](#overview)Copy Anchor Link Every GitHub Actions job starts on a fresh virtual machine, so a Svelte repository has three directories worth restoring and one that belongs in an artifact rather than a cache. | Path | What it holds | Key on | Reused by | | ------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------- | | $(pnpm store path) or \~/.npm | Package tarballs from the registry | Lockfile hash | Every job | | node\_modules/.vite | Vite's esbuild pre-bundled dependency chunks | Lockfile plus vite.config.ts | Type check, build, Vitest | | .svelte-kit | Generated route types from svelte-kit sync plus build intermediates | Lockfile plus svelte.config.js and vite.config.ts | Type check, build | | build or the adapter output dir | The deployable output | Not cached | Uploaded with actions/upload-artifact | The `.svelte-kit` directory is the piece that catches people moving from a plain Vite project. `svelte-kit sync` generates the `$types` modules that every `+page.ts` and `+page.server.ts` imports, and `svelte-check` reports those imports as errors when the directory is missing. Running sync once and carrying `.svelte-kit` into the build job removes a repeat of that generation step. The adapter output stays out of the cache. A cache entry is keyed content that later runs restore, while the deployable bundle is a per-commit result that a deploy job consumes, which is what `actions/upload-artifact` and `actions/download-artifact` are for. Svelte work belongs on Linux x64, and one constraint from the [caching documentation](/docs/ci/features/caching) makes that concrete: WarpBuild Cache is not supported on Windows runners, so keep the cached phases on Linux jobs. ## [Configuration](#configuration)Copy Anchor Link This workflow runs the type check and the build as separate jobs on `warp-ubuntu-latest-x64-4x`, with both restoring the same two cache entries. Copy Text ``` name: svelte-ci on: push: branches: [main] pull_request: jobs: typecheck: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Restore Vite and SvelteKit state uses: WarpBuilds/cache@v1 with: path: | node_modules/.vite .svelte-kit key: ${{ runner.os }}-svelte-${{ hashFiles('pnpm-lock.yaml', 'svelte.config.js', 'vite.config.ts') }} restore-keys: | ${{ runner.os }}-svelte- - run: pnpm exec svelte-kit sync - run: pnpm exec svelte-check --tsconfig ./tsconfig.json build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Restore Vite and SvelteKit state uses: WarpBuilds/cache@v1 with: path: | node_modules/.vite .svelte-kit key: ${{ runner.os }}-svelte-${{ hashFiles('pnpm-lock.yaml', 'svelte.config.js', 'vite.config.ts') }} restore-keys: | ${{ runner.os }}-svelte- - run: pnpm run build - uses: actions/upload-artifact@v4 with: name: svelte-build path: build ``` Both jobs write the same key, so whichever finishes first saves the entry and the other restores it on the next run. The `restore-keys` prefix is what keeps a dependency bump cheap: when the exact key misses, the action restores the most recent entry with the same prefix, and Vite re-optimizes only the packages that changed. `WarpBuilds/setup-node` handles the package manager store on its own. It is a drop-in replacement for `actions/setup-node` whose `cache` input accepts `npm`, `yarn`, or `pnpm`, and `cache-dependency-path` points the key at a lockfile outside the repository root. The full input list is in the [setup actions documentation](/docs/ci/features/caching/setup-actions). Two cache behaviors shape what you see in the logs. Entries are scoped to key, version, and branch, so seed the cache on `main` and let branch builds reach it through `restore-keys`. Entries expire 7 days after last use, so an active repository stays warm and an abandoned branch stops costing storage on its own. Cache storage is metered at $0.20 per GB-month with each write or restore operation at $0.0001, so a Svelte repository holding a 900MB store and running 3,000 operations a month adds about $0.48 to the bill. ## [Sizing](#sizing)Copy Anchor Link The Linux x64 sizes in the [cloud runner catalog](/docs/ci/cloud-runners) that matter for Svelte work: | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | **Bundling.** A production `vite build` walks the module graph in Rollup on one thread, then parallelizes minification across workers. The single-threaded graph walk sets the floor, so the curve flattens past 4 vCPU for most applications and past 8 vCPU for large route trees. Memory is the constraint that bites first: a SvelteKit app with hundreds of routes and heavy prerendering can push Node past the default heap, and 16GB on 4x covers that before `NODE_OPTIONS=--max-old-space-size` becomes a workaround on a machine that cannot back it. The dependency pre-bundle phase is the part that does scale, since Vite runs it through esbuild, and a warm `node_modules/.vite` skips it entirely. **Test suite.** Vitest sizes its worker pool from the core count, so a suite with many independent component test files keeps 8 cores busy and earns 8x at $0.016 per minute. Component tests that mount into jsdom or happy-dom are memory hungry per worker, which is the other reason 8x with 32GB beats 16x with a worker pool that starves. A Playwright end-to-end job is different again: workers are bounded by browser instances rather than by test files, so 4x with a `workers: 4` setting is usually the right shape. **Type check.** `svelte-check` runs as one process over the whole program, so extra vCPUs do nothing for it. Keep it on 4x and give it memory rather than cores. GitHub publishes its own per-minute list prices on the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the 4-core Linux larger runner is $0.012 per minute. A worked model for a Svelte application running 1,500 pull request jobs a month, averaging 4 minutes each on a 4 vCPU machine with a warm cache, which is 6,000 runner minutes: | Scenario | Rate | Monthly minutes | Monthly cost | | ----------------------------------------------- | ---------- | --------------- | ------------ | | GitHub-hosted Linux larger runner, 4 vCPU | $0.012/min | 6,000 | $72.00 | | warp-ubuntu-latest-x64-4x, 4 vCPU | $0.008/min | 6,000 | $48.00 | | Cache storage and operations (900MB, 3,000 ops) | see above | n/a | $0.48 | Stated as list-price arithmetic: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13. Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Cold dependency installs.** Without a restored store, every job pulls the full dependency tree from the registry before Vite starts. The fix is the `setup-node` cache input above; verify it from the install log, where a warm pnpm install reports most packages as reused from the store. **Vite dependency re-optimization.** With `node_modules/.vite` empty, Vite pre-bundles every CommonJS and deep-import dependency through esbuild at the start of the run. That work repeats in the type check job, the build job, and every Vitest shard. One shared cache entry removes three copies of it. **Missing svelte-kit sync.** A type check job that runs `svelte-check` without sync fails on `$types` imports that no file generated yet, and the error text points at application code rather than at the missing step. Run sync as its own step so the failure is unambiguous. **Playwright browser downloads.** A SvelteKit end-to-end job downloads browser binaries on every cold run. Cache `~/.cache/ms-playwright` keyed on the Playwright version from the lockfile rather than on the lockfile hash alone, so a routine dependency bump does not throw away several hundred megabytes of browsers. **Single-shard test suites.** A Vitest suite that runs as one job has a wall time equal to its total test time whatever the runner size. Vitest accepts a `--shard` flag, so the suite splits across a matrix where each shard restores the same cache entries and the wall time drops to the slowest shard. Observability separates a CPU-bound Vitest phase from an install stuck on the network, and the Action Debugger pauses a workflow and opens an SSH session on the runner so you can inspect `node_modules/.vite` and `.svelte-kit` on the machine itself. ## [Proof](#proof)Copy Anchor Link Public OSS repositories running `warp-` labels are citable evidence, and the check takes one click: open the workflow file and read the `runs-on` line. The Trigger.dev end-to-end suite runs its matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x` in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml), checked on 2026-08-13\. That repository is a TypeScript monorepo rather than a Svelte application, and the toolchain shape is the same one this page describes: a pnpm install, a Vite-era build step, and a test matrix on 4 vCPU Linux runners. Moving a Svelte workflow over is a one-line change per job to `runs-on`. Rates for every size are on the [pricing page](/pricing). The same cache-then-size approach applies across the JavaScript toolchain. The [Node.js on GitHub Actions](/solutions/nodejs-github-actions) page covers package manager store caching in depth, [Vue on GitHub Actions](/solutions/vue-github-actions) covers the same Vite phases with a different compiler front end, and [Jest cache configuration](/guides/jest-cache-github-actions) covers the transform cache when a Svelte repository still runs Jest. ## [FAQ](#faq)Copy Anchor Link **Do I have to run svelte-kit sync before svelte-check in GitHub Actions?** Yes for a SvelteKit project. `svelte-kit sync` writes the generated route types under `.svelte-kit/types`, and `svelte-check` reports missing `$types` imports as errors when that directory is absent. A plain Svelte library with no SvelteKit router does not need the sync step. **What should the SvelteKit cache key hash?** Use two entries. Hash the lockfile for the package manager store, and hash the lockfile plus `svelte.config.js` and `vite.config.ts` for `node_modules/.vite`, since Vite re-optimizes dependencies when either config changes. Add a `restore-keys` prefix to both so a dependency bump restores the previous entry instead of starting cold. **What runner size does a SvelteKit build need?** `warp-ubuntu-latest-x64-4x` at $0.008 per minute covers `svelte-check` and `vite build` for most applications. Move the Vitest job to `warp-ubuntu-latest-x64-8x` when the suite has enough independent files to keep 8 workers busy, and leave the type check job on 4x since `svelte-check` runs in one process. --- # Swift Package Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/swift-github-actions Description: Run swift build and swift test on GitHub Actions with WarpBuild macOS runners. Workflow YAML, 6x vs 12x sizing, SwiftPM cache keys, and per-minute costs. # Swift Package Builds on GitHub Actions Run swift build and swift test on GitHub Actions with WarpBuild macOS runners. Workflow YAML, 6x vs 12x sizing, SwiftPM cache keys, and per-minute costs. Last verified: 2026-08-13 A Swift package build on GitHub Actions runs `swift build` and `swift test` on a macOS or Linux runner, and the label in `runs-on` decides the machine, the memory, and the per-minute rate. WarpBuild runs those jobs on labels such as `warp-macos-15-arm64-6x` at $0.08 per minute and `warp-ubuntu-latest-x64-8x` at $0.016 per minute, so moving a Swift package pipeline is a one-line label change in the workflow file. ## [Overview](#overview)Copy Anchor Link This page covers Swift Package Manager builds: libraries, command line tools, and server-side Swift services that compile with `swift build` and test with `swift test`. Application archiving, simulator runs, code signing, and fastlane lanes live on the [iOS builds on GitHub Actions page](/solutions/ios-github-actions), which owns the app side of the same toolchain. Two facts shape every decision below. The Swift compiler on a macOS runner is whichever compiler the selected Xcode carries, so toolchain selection is Xcode selection. And GitHub Actions runners are ephemeral, so every run starts with an empty `.build` directory unless a cache step puts one back. For Swift work, the macOS labels build and test for Apple platforms, and the Linux labels build the same package for a server-side deployment target. The runner catalog lists five macOS labels across macOS 14, macOS 15, and macOS 26, and the macOS 26 image adds Xcode 27.0 (build 27A266a) on top of the upstream GitHub image. That image matrix is what determines which Swift toolchains a workflow can reach, which is why the coverage question and the toolchain question are the same question. The macOS versions available on the fleet, with sizes and rates, come straight from the [WarpBuild cloud runners documentation](/docs/ci/cloud-runners): | Runner label | macOS | vCPU | Memory | Storage | Per-minute (USD) | | ----------------------- | -------- | ---- | ------ | --------- | ---------------- | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28GB | 270GB SSD | $0.16 | | warp-macos-15-arm64-6x | macOS 15 | 6 | 14GB | 120GB SSD | $0.08 | | warp-macos-15-arm64-12x | macOS 15 | 12 | 28GB | 270GB SSD | $0.16 | | warp-macos-14-arm64-6x | macOS 14 | 6 | 14GB | 120GB SSD | $0.08 | The `warp-macos-latest-arm64-6x` and `warp-macos-latest-arm64-12x` aliases point at the macOS 15 labels. macOS 14 ships a 6 vCPU size only, so a package that must build against the older Xcode series has one size available and no larger option to escalate to. For the Linux half of a Swift matrix, the relevant rows are the mid-sized Ubuntu labels: | Runner label | OS | vCPU | Memory | Storage | Per-minute (USD) | | ---------------------------- | ------------ | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-arm64-8x | Ubuntu 24.04 | 8 | 32GB | 150GB SSD | $0.012 | | warp-ubuntu-latest-arm64-16x | Ubuntu 24.04 | 16 | 64GB | 150GB SSD | $0.024 | Server-side Swift images that must be produced inside your own cloud account have a second path: BYOC runs on AWS, GCP, and Azure, with the runners registering the same way. The macOS fleet stays on WarpBuild-hosted machines. ## [Configuration](#configuration)Copy Anchor Link The macOS workflow below builds and tests a Swift package, records the toolchain in the log, and caches both the package build directory and the shared SwiftPM dependency cache. Copy Text ``` name: swift-package on: push: branches: [main] pull_request: jobs: macos: runs-on: warp-macos-15-arm64-6x timeout-minutes: 30 env: DEVELOPER_DIR: ${{ vars.XCODE_DEVELOPER_DIR }} steps: - uses: actions/checkout@v4 - name: Select and record the toolchain run: | ls /Applications | grep -i '^Xcode' sudo xcode-select -s "$DEVELOPER_DIR" xcodebuild -version swift --version - name: Restore SwiftPM caches uses: actions/cache@v4 with: path: | .build ~/Library/Caches/org.swift.swiftpm key: spm-macos15-${{ hashFiles('Package.resolved') }} restore-keys: | spm-macos15- - name: Resolve dependencies run: | swift package resolve git diff --exit-code Package.resolved - name: Build run: swift build --build-tests - name: Test run: swift test --skip-build --parallel ``` Set the `XCODE_DEVELOPER_DIR` repository variable to a path the image actually carries, such as a bundle listed in the macOS image readme linked from the [preinstalled software documentation](/docs/ci/preinstalled-software). The `ls /Applications` line prints the bundles present on the image, so a stale value fails in the first ten seconds of the job instead of ten minutes into a build. Leave `DEVELOPER_DIR` unset and the image default applies; on macOS 14 that default is Xcode 15.4, matching the GitHub-hosted image. `swift package resolve` runs as its own step so resolution time shows up as its own line in the GitHub Actions timing view. The `git diff --exit-code Package.resolved` check fails the job when resolution moved a dependency that the committed lockfile did not expect, which is the difference between a reproducible build and a build that silently picked up a new minor version. Splitting `swift build --build-tests` from `swift test --skip-build --parallel` keeps compile time and test time separate in the log. Without the split, a slow test suite and a slow compile look identical from the outside. The macOS job uses `actions/cache@v4` because the runner catalog scopes the WarpBuild cache to Linux runners. On Linux labels, `WarpBuilds/cache@v1` is documented as a drop-in replacement for `actions/cache@v4`, so the same `path`, `key`, and `restore-keys` inputs apply. Server-side Swift builds belong on Linux labels, since macOS runners do not support nested virtualization and cannot run Docker. A matrix over both Linux architectures catches architecture-specific failures before a deployment does: Copy Text ``` name: swift-linux on: pull_request: jobs: linux: strategy: fail-fast: false matrix: runner: - warp-ubuntu-latest-x64-8x - warp-ubuntu-latest-arm64-8x runs-on: ${{ matrix.runner }} container: swift:6.1 timeout-minutes: 25 steps: - uses: actions/checkout@v4 - name: Restore SwiftPM caches uses: WarpBuilds/cache@v1 with: path: | .build /root/.cache/org.swift.swiftpm key: spm-${{ matrix.runner }}-${{ hashFiles('Package.resolved') }} restore-keys: | spm-${{ matrix.runner }}- - run: swift --version - run: swift build --build-tests - run: swift test --skip-build --parallel ``` The cache path differs by platform. SwiftPM keeps its shared dependency cache under `~/Library/Caches/org.swift.swiftpm` on macOS and `~/.cache/org.swift.swiftpm` on Linux, and inside a container job `HOME` is `/root`, so the Linux path is written out in full. The runner label belongs in the cache key. Ubuntu 24.04 ARM64 runners set the work directory to `/runner/_work`, while GitHub uses `/home/runner/work/` for the same instance, and SwiftPM records absolute paths in its build database. A cache shared between two labels with different work directories restores files that the next build ignores, and you pay the storage for the privilege. Migrating an existing Swift workflow is a label edit. Replace `macos-latest` with `warp-macos-15-arm64-6x` and `ubuntu-latest` with `warp-ubuntu-latest-x64-8x`, leave every step alone, and the runner images carry the same tooling as their GitHub-hosted counterparts. ## [Sizing](#sizing)Copy Anchor Link macOS runners come in multiple sizes and configurations per chip. For Swift package work the choice is between the 6 vCPU label at $0.08 per minute with 14GB of memory and 120GB of storage, and the 12 vCPU label at $0.16 per minute with 28GB of memory and 270GB of storage. Start on the 6x label. A Swift package build parallelizes across compilation units, so more cores help when the dependency graph is wide, and help much less when one large module sits on the critical path. Memory is the other trigger: a release build with whole-module optimization on a large module holds the entire module in memory at once, and test suites that spin up in-process servers add to it. Watch for the job hitting the 14GB ceiling before spending on cores you cannot use. The arithmetic sets a clean rule. A run that takes 11 minutes on the 6x label costs 11 x $0.08 = $0.88\. The same run on the 12x label costs $0.16 per minute, so it reaches $0.88 at 5.5 minutes. The 12x label lowers the bill only when it brings the run under 5.5 minutes, and above that it buys wall clock at a premium. That premium is often worth paying. If the 12x label finishes the same run in 7 minutes, each run costs 7 x $0.16 = $1.12, which is $0.24 more than the 6x run. Across 600 runs per month that is $144.00 more, and it returns 4 minutes per run, or 2,400 minutes of wall clock every month. Pull request feedback time and engineer waiting time are the budget that spend comes out of. Move to the 12x label for the merge queue and release jobs, where wall clock gates a deploy, and keep pull request jobs on the 6x label, where a queue of parallel jobs matters more than any single job finishing early. Storage is the third trigger: 120GB on the 6x label is generous for a package build, and a repository with large binary fixtures or many cached toolchains can want the 270GB on the 12x label. Concurrency behaves differently on macOS than on Linux and Windows. If a Swift pipeline needs high macOS concurrency, raise it before the release crunch rather than during it. ### [Cost model against GitHub public list prices](#cost-model-against-github-public-list-prices)Copy Anchor Link GitHub publishes per-minute rates for its hosted runners. These rows come from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13: | GitHub-hosted tier | Per-minute (USD) | | ---------------------- | ---------------- | | macOS 3-core or 4-core | $0.062 | | macOS 5-core | $0.102 | | macOS 12-core | $0.077 | | Linux 8-core x64 | $0.022 | | Linux 8-core arm64 | $0.014 | Take a Swift package repository with 600 workflow runs per month and an 11 minute macOS job, which is 6,600 macOS minutes. * `warp-macos-15-arm64-6x` at $0.08 per minute: 6,600 x $0.08 = $528.00 per month. * GitHub-hosted macOS 5-core tier at $0.102 per minute: 6,600 x $0.102 = $673.20 per month. * Difference: $145.20 per month, or $1,742.40 over twelve months. Stated as list-price arithmetic: warp-macos-latest-arm64-6x (6 vCPU, 14 GB) costs $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price. GitHub list price checked on 2026-08-13\. The `warp-macos-15-arm64-6x` label used above is the same machine behind that alias. The model holds minutes constant across both runners so the arithmetic stays about rate. Confirm the image and architecture behind any GitHub label on GitHub's own runner list before treating a tier as a substitute for an arm64 Swift build, since the 12-core row is a different machine shape from the smaller macOS tiers. The Linux half of the same pipeline: a 9 minute server-side Swift build, 600 times per month, is 5,400 minutes. On `warp-ubuntu-latest-x64-8x` at $0.016 per minute that is $86.40, and on `warp-ubuntu-latest-arm64-8x` at $0.012 per minute it is $64.80, a $21.60 difference for running the same package on the ARM64 label. Against GitHub's own ARM64 row: warp-ubuntu-latest-arm64-8x (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB): 14 percent lower list price. GitHub list price checked on 2026-08-13. Cache is billed separately and stays small for a package build. Cache storage is $0.20 per GB-month, so a 3GB `.build` cache costs $0.60 per month, and cache operations are $0.0001 each, so two operations per run across 600 runs cost $0.12\. Current rates for every label are on the [WarpBuild pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four things dominate Swift build time on GitHub Actions, and each has a specific fix. **Whole-module optimization on release builds.** In release configuration the compiler treats a module as a single unit, which produces better code and destroys file-level incrementality. Changing one file recompiles the module. Keep pull request jobs in the default debug configuration where the compiler works file by file, and reserve `swift build -c release` for tagged builds and nightly jobs. Teams that need release-configuration testing on every pull request usually get further by splitting the largest module than by adding cores, because whole-module work on one module does not spread across 12 vCPUs. **Cold SwiftPM resolution.** A package with a deep dependency graph clones every dependency on a cold runner before a single line compiles. Cache `.build` (which holds `checkouts` and the build database) together with the shared SwiftPM cache directory, and key on `hashFiles('Package.resolved')` so the entry is invalidated exactly when the dependency set changes. The `restore-keys` prefix keeps a near-miss useful: a new dependency invalidates the exact key, and the older entry still saves the other clones. Running `swift package resolve` as its own step makes the cost visible, so you can tell a slow resolve from a slow compile without guessing. **Toolchain selection against the image's Xcode versions.** Each macOS image carries a set of Xcode bundles and a default. The macOS 26 image ships Xcode 27.0 alongside the versions in the upstream GitHub image, macOS 14 defaults to Xcode 15.4, and the exact list per image lives in the readmes linked from the [preinstalled software documentation](/docs/ci/preinstalled-software). Pin `DEVELOPER_DIR` at the job level rather than depending on the default, and print `swift --version` in the log. Image updates are published in the WarpBuild changelog, and a pinned `DEVELOPER_DIR` turns a toolchain change into a deliberate pull request rather than a Monday morning surprise. When Xcode 27.0 is a strict requirement, the macOS 26 labels are the ones that carry it, which in practice means five labels, three macOS versions, and an Xcode version added on top of the upstream GitHub image. **Incremental state lost between runs.** Runners are ephemeral, so nothing survives a job except what a cache step writes. A restored `.build` directory is a dependency cache first and an incremental build second, because SwiftPM invalidates incremental state when the compiler version or the absolute paths change. Put the toolchain identifier in the cache key alongside the `Package.resolved` hash so a new Xcode does not silently reuse artifacts built by the old one. Expect the first run after a toolchain bump to be a full build. Two features help when a Swift job behaves differently on the runner than on a laptop. CI observability reports system metrics from the runner agent next to the GitHub Actions job logs, which is how you separate a memory-bound link step from a CPU-bound compile. The Action Debugger pauses a workflow and opens an SSH session on the runner, so a failing `swift test` can be reproduced on the machine that failed rather than reconstructed from a log. ## [Proof](#proof)Copy Anchor Link WarpBuild runners register with GitHub the same way any self-hosted runner does, so the evidence a team should ask for is operational rather than architectural. Public OSS repositories running `warp-` labels are citable evidence for the macOS half. [manaflow-ai/cmux](https://github.com/manaflow-ai/cmux/blob/main/.github/workflows/ci.yml) shards its app-host XCTest suite across `warp-macos-15-arm64-6x`, the same label the workflow above uses (checked on 2026-08-13). The cheapest way to test a Swift pipeline is to run it. Point one branch's workflow at a WarpBuild macOS label, leave the rest on the existing runners, and compare the timing view for the same commit. Continue with the [WarpBuild macOS runner catalog](/runners/macos) for the full label list, the [Xcode versions on each macOS image](/runners/macos/xcode-images) when a specific Xcode is a hard requirement, and the [macOS runner cost guide](/guides/github-actions-macos-runner-cost) to model a full month against your own run counts. ## [FAQ](#faq)Copy Anchor Link **Which runner label should a Swift package build use?** Start on `warp-macos-15-arm64-6x` for Apple platform packages and on `warp-ubuntu-latest-x64-8x` or `warp-ubuntu-latest-arm64-8x` for server-side Swift. Move to `warp-macos-15-arm64-12x` only when a job needs more than 14GB of memory or when the run finishes in under half the minutes the 6x label takes. **How do I pin the Swift toolchain on a macOS runner?** The Swift compiler on a macOS runner comes from the selected Xcode, so set `DEVELOPER_DIR` at the job level and run `sudo xcode-select -s` on it. Print `swift --version` and `xcodebuild -version` in the first step so the log records the toolchain that produced the build. **Should I cache .build between GitHub Actions runs?** Cache `.build` and the shared SwiftPM cache directory, keyed on a hash of `Package.resolved` plus the runner label and the toolchain version. Treat the restore as a dependency cache rather than a working incremental build, because SwiftPM invalidates incremental state when paths or compiler versions change. **Can macOS runners build Docker images for a server-side Swift service?** No. macOS runners do not support nested virtualization and cannot run Docker, so container builds belong on Linux labels such as `warp-ubuntu-latest-x64-8x`. Keep the macOS job for the Apple platform build and the Linux job for the container image. --- # Tauri Desktop Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/tauri-github-actions Description: Build Tauri v2 installers on GitHub Actions with a three-platform matrix, cached Rust and front-end state, and warp- runner sizes with per-minute rates. # Tauri Desktop Builds on GitHub Actions Build Tauri v2 installers on GitHub Actions with a three-platform matrix, cached Rust and front-end state, and warp- runner sizes with per-minute rates. Last verified: 2026-08-13 A Tauri app that ships installers for Linux, Windows, and macOS needs a GitHub Actions matrix with one job per platform, because each platform links against its own system WebView and produces its own bundle formats. On WarpBuild that matrix is three `runs-on` labels, `warp-ubuntu-latest-x64-8x`, `warp-windows-latest-x64-8x`, and `warp-macos-26-arm64-6x`, with a Rust cache and a front-end cache on each leg and a separate cache action on Windows. This page gives the per-platform dependency table, a matrix workflow with both caches and per-platform bundling, the runner sizes and rates for all three legs, the signing and notarization pointers per platform, and the list-price arithmetic against GitHub-hosted runners of the same shape. ## [Overview](#overview)Copy Anchor Link WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so all three legs of a Tauri release run on one account with one billing model. Moving an existing workflow across is a change to the `runs-on` line in each leg plus the cache steps. A Tauri build is two builds stacked. The front end compiles to static assets through whatever bundler the project uses, and the Rust binary in `src-tauri` compiles against the platform's WebView bindings and then wraps those assets. The second half is what forces the matrix: `webkit2gtk` on Linux, WebView2 on Windows, and WKWebView on macOS are three different link targets, and the bundlers that produce the installers only run on their own platform. The runner images carry the same tooling as GitHub-hosted runners, which covers the MSVC toolchain on Windows and the Xcode command line tools on macOS. Linux is the leg that needs an explicit install step, because the WebKitGTK development packages are not in the base image. | Platform | Runner label | What the job installs | Bundle targets | | -------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------ | | Linux | warp-ubuntu-latest-x64-8x | libwebkit2gtk-4.1-dev, libayatana-appindicator3-dev, librsvg2-dev, libxdo-dev, libssl-dev, patchelf, build-essential | deb, rpm, AppImage | | Windows | warp-windows-latest-x64-8x | Nothing at the OS level; the WiX and NSIS toolchains download into %LOCALAPPDATA%\\tauri on first run | msi, nsis | | macOS | warp-macos-26-arm64-6x | rustup target add aarch64-apple-darwin x86\_64-apple-darwin for a universal binary | app, dmg | Two version details are worth pinning. Tauri v2 links against `webkit2gtk-4.1`, while v1 linked against `4.0`, so an Ubuntu 24.04 or 26.04 image and the `4.1` package name go together. And the macOS 26 labels ship the Xcode 27.0 SDKs and simulator runtimes while GitHub's upstream macOS 27 runner image is in beta, with a dedicated macOS 27 image to follow once that image is released, so pin `DEVELOPER_DIR` if the Rust build depends on a specific SDK. One caching constraint shapes the workflow. The [caching documentation](/docs/ci/features/caching) states that WarpBuild Cache is not supported on Windows runners, and cache entries are versioned by paths and compression tool, so a macOS entry cannot restore on Linux anyway. Both facts push the same way: one cache step and one key per leg. ## [Configuration](#configuration)Copy Anchor Link The workflow below builds and bundles all three platforms from one matrix. The Linux and macOS legs use `WarpBuilds/rust-cache`, the Windows leg uses `actions/cache` over the same cargo directories, and every leg restores the front-end package manager store. Copy Text ``` name: tauri-release on: push: branches: [main] pull_request: jobs: bundle: name: bundle-${{ matrix.platform }} runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: - platform: linux runner: warp-ubuntu-latest-x64-8x args: "" - platform: windows runner: warp-windows-latest-x64-8x args: "" - platform: macos runner: warp-macos-26-arm64-6x args: "--target universal-apple-darwin" steps: - uses: actions/checkout@v4 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: pnpm - run: rustup toolchain install stable --profile minimal - name: Add Apple targets if: matrix.platform == 'macos' run: rustup target add aarch64-apple-darwin x86_64-apple-darwin - name: Install WebKitGTK and bundler dependencies if: matrix.platform == 'linux' run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \ librsvg2-dev libxdo-dev libssl-dev patchelf build-essential file - name: Cache cargo state (Linux and macOS) if: matrix.platform != 'windows' uses: WarpBuilds/rust-cache@v2 with: cache-provider: warpbuild workspaces: "src-tauri -> target" shared-key: tauri-${{ matrix.platform }} save-if: ${{ github.ref == 'refs/heads/main' }} - name: Cache cargo state (Windows) if: matrix.platform == 'windows' uses: actions/cache@v4 with: path: | ~\.cargo\registry ~\.cargo\git src-tauri\target ~\AppData\Local\tauri key: windows-tauri-${{ hashFiles('src-tauri/Cargo.lock') }} restore-keys: | windows-tauri- - run: pnpm install --frozen-lockfile - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} with: args: ${{ matrix.args }} - uses: actions/upload-artifact@v4 with: name: ${{ matrix.platform }}-bundles path: src-tauri/target/**/release/bundle/** if-no-files-found: error ``` Four details in that file are worth stating explicitly. `workspaces: "src-tauri -> target"` points the cache action at the Cargo project, which lives one directory down in a Tauri repository rather than at the repository root. Without it the action finds no `Cargo.toml` and caches nothing, and the failure is silent apart from a full rebuild every run. `fail-fast: false` keeps a Windows bundling failure from cancelling a macOS notarization that is already waiting in Apple's queue. One flaky leg otherwise costs the whole matrix run. `save-if` limits cache writes to `main`, so pull request branches restore from the main-branch entry and skip the save. Combined with `shared-key`, that gives each platform one stable cache instead of one per run. `--target universal-apple-darwin` is what produces a binary that runs on both Apple architectures, and it is the reason the macOS leg adds both rustup targets. Drop the flag and the bundle carries the runner's native architecture only. ### [Signing and notarization per platform](#signing-and-notarization-per-platform)Copy Anchor Link Signing is per platform and each half has its own guide. On macOS, `APPLE_CERTIFICATE` and `APPLE_CERTIFICATE_PASSWORD` import the Developer ID certificate into a keychain the job creates, and the `APPLE_ID`, `APPLE_PASSWORD`, and `APPLE_TEAM_ID` trio drives notarization through `notarytool`. The wait for Apple's verdict is wall-clock time on someone else's queue, and it is billed like any other minute: at $0.08 per minute on `warp-macos-26-arm64-6x`, a five minute notarization wait costs $0.40 per release run. The full keychain and credential sequence is in [notarizing macOS apps in GitHub Actions](/guides/macos-notarization-github-actions). On Windows, the msi and nsis bundles are signed through the `signCommand` entry in `tauri.conf.json`, which lets the certificate live in a cloud signing service rather than on the ephemeral runner. The certificate handling and timestamping steps are in [code signing Windows binaries in GitHub Actions](/guides/windows-code-signing-github-actions). Linux bundles carry no OS-level signature. The key that matters there is `TAURI_SIGNING_PRIVATE_KEY`, which signs the updater manifest on every platform and has to be identical across the three legs, or clients reject the update. ## [Sizing](#sizing)Copy Anchor Link The catalog rows and per-minute rates for the three labels in the matrix, plus the sizes to move to, from the [cloud runner catalog](/docs/ci/cloud-runners): | Runner label | OS | vCPU | Memory | Storage | Price per minute | | --------------------------- | ------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64 GB | 256GB SSD | $0.064 | | warp-macos-26-arm64-6x | macOS 26 | 6 | 14 GB | 120GB SSD | $0.08 | | warp-macos-26-arm64-12x | macOS 26 | 12 | 28 GB | 270GB SSD | $0.16 | Size each leg for the phase that dominates it. The Rust compile in `src-tauri` is the part that scales with cores, because cargo compiles independent crates in parallel, so a cold dependency graph rewards 16 vCPU on Linux and Windows. The front-end bundle walks its module graph on one thread before minifying in parallel, so it flattens out around 8 vCPU. Installer compression at the end is disk-bound with a mostly single-threaded compressor, and extra cores stop helping there. The macOS leg is the one to watch for disk before speed. A universal build compiles the Rust side twice, once per architecture, and keeps both target directories plus the app, the dmg, and the notarization staging copy on the same volume. 120GB gets tight on a large workspace, and `warp-macos-26-arm64-12x` carries 270GB. ### [List prices against GitHub-hosted runners of the same shape](#list-prices-against-github-hosted-runners-of-the-same-shape)Copy Anchor Link GitHub publishes per-minute list prices on the [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and runner shapes on the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Both were checked on 2026-08-13. `warp-windows-latest-x64-8x` (8 vCPU, 32 GB) costs $0.032 per minute against $0.042 per minute for the 8-core Windows larger runner (8 vCPU, 32 GB): 24 percent lower list price. The 6 vCPU macOS labels, including `warp-macos-26-arm64-6x`, cost $0.08 per minute against $0.102 per minute for the largest GitHub-hosted macOS ARM64 runner (5 vCPU, 14 GB): 22 percent lower list price, with one more vCPU and the same memory. GitHub list prices checked on 2026-08-13. ### [Worked monthly model](#worked-monthly-model)Copy Anchor Link Take a desktop team whose release matrix runs 200 times a month across pull requests and tagged releases. Each run takes 11 minutes on the Linux leg, 15 minutes on the Windows leg, and 18 minutes on the macOS leg, with warm caches on all three and a universal build on macOS. | Leg | Monthly minutes | WarpBuild rate | WarpBuild cost | | --------------- | --------------- | -------------- | -------------- | | Linux, 8 vCPU | 2,200 | $0.016 | $35.20 | | Windows, 8 vCPU | 3,000 | $0.032 | $96.00 | | macOS, 6 vCPU | 3,600 | $0.08 | $288.00 | | Total | 8,800 | $419.20 | | On the two legs where GitHub publishes a same-shape list price, the same minutes come to $126.00 on the 8-core Windows larger runner and $367.20 on the largest GitHub-hosted macOS ARM64 runner, against $96.00 and $288.00 above: a $109.20 difference a month on this workload at list prices. Cache metering adds a little on top, since storage is $0.20 per GB-month and each cache write or restore is $0.0001, so a 6GB Tauri cache footprint with 4,000 operations a month comes to about $1.60. Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **The cold Rust dependency graph.** A Tauri app pulls in several hundred crates through the `tauri` and `wry` trees before any of your own code compiles. Cold, that is the single largest block of wall time in the pipeline, and it repeats on every leg. Key the cache on `src-tauri/Cargo.lock` so it refreshes exactly when dependencies change, and let `restore-keys` fall back to the newest previous entry so a lockfile bump starts from a mostly warm `target`. **The front end rebuilt three times.** Every leg installs dependencies and runs the front-end build before the Rust side starts, so the same static assets are produced three times per release. Restoring the package manager store on each leg removes the install half. Where the front-end build takes long enough to matter, build it once in a separate job, upload the output with `actions/upload-artifact`, and have the three bundle legs download it. **The universal macOS build.** Compiling both Apple architectures doubles the Rust half of the macOS leg, which is why that leg carries the longest minutes in the model above. Building only the native architecture on pull requests and reserving the universal build for tagged releases cuts the routine cost. **Notarization wait.** Apple's verdict arrives when it arrives, and a larger runner does nothing for it. Keep notarization on the release path rather than on every pull request, and keep the whole signing sequence inside one step so keychain state cannot drift between steps. **The Windows leg with no WarpBuild Cache.** That leg falls back to `actions/cache`, which has its own size limits, so cache only the cargo directories and the `%LOCALAPPDATA%\tauri` toolchain downloads rather than the whole workspace. CI observability separates a CPU-bound cargo compile from a bundling step stuck on disk, and the Action Debugger pauses the workflow and opens an SSH session on the runner, which is the shortest path to inspecting a half-written bundle directory or a keychain in place. ## [Proof](#proof)Copy Anchor Link Public OSS repositories running `warp-` labels are citable evidence, and the check is to open the workflow file and read the `runs-on` line. [near/nearcore](https://github.com/near/nearcore/blob/master/.github/workflows/ci.yml) compiles the NEAR protocol node and runs its cargo-nextest suites on `warp-ubuntu-2404-x64-16x` and `warp-ubuntu-2404-x64-8x`, which is the same cargo-bound shape the Linux leg above has (checked on 2026-08-13). [manaflow-ai/cmux](https://github.com/manaflow-ai/cmux/blob/main/.github/workflows/ci.yml) shards a desktop app test suite across `warp-macos-15-arm64-6x`, the 6 vCPU macOS shape the macOS leg uses (checked on 2026-08-13). Every cost number on this page carries its source and a checked-on date, so you can re-derive the arithmetic rather than take it. Rates for every size are on the [pricing page](/pricing). Related reading: [Electron app builds on GitHub Actions](/solutions/electron-github-actions) for the same three-platform shape with a Node runtime instead of a Rust one, [fast Rust builds on GitHub Actions](/solutions/rust-github-actions) for cargo caching and sizing in depth, and the [macOS notarization](/guides/macos-notarization-github-actions) and [Windows code signing](/guides/windows-code-signing-github-actions) guides for the signing halves. ## [FAQ](#faq)Copy Anchor Link **Does a Tauri release need one runner per platform?** Yes. Each platform links against its own system WebView and produces its own bundle formats, and the signing tools are per platform as well. The working shape is a three-leg matrix on `warp-ubuntu-latest-x64-8x`, `warp-windows-latest-x64-8x`, and `warp-macos-26-arm64-6x`. **What should a Tauri workflow cache?** Three things: the cargo registry and git checkouts, the `src-tauri/target` directory keyed on `Cargo.lock`, and the front-end package manager store keyed on the lockfile. Keep the Rust cache on `WarpBuilds/rust-cache` with `cache-provider: warpbuild` on the Linux and macOS legs, and use `actions/cache` on the Windows leg because WarpBuild Cache is not supported on Windows runners. **What runner sizes should each Tauri leg use?** Start at `warp-ubuntu-latest-x64-8x` at $0.016 per minute, `warp-windows-latest-x64-8x` at $0.032 per minute, and `warp-macos-26-arm64-6x` at $0.08 per minute. Move the macOS leg to `warp-macos-26-arm64-12x` at $0.16 per minute when a universal build fills the 120GB disk, since that label carries 270GB. --- # Terraform Pipelines on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/terraform-github-actions Description: Run terraform plan and apply as GitHub Actions jobs on warp- runners: cache the provider plugin directory, size 2x or 4x labels, reach private state backends. # Terraform Pipelines on GitHub Actions Run terraform plan and apply as GitHub Actions jobs on warp- runners: cache the provider plugin directory, size 2x or 4x labels, reach private state backends. Last verified: 2026-08-13 ## [Overview](#overview)Copy Anchor Link A Terraform pipeline on GitHub Actions is two jobs: a plan job that runs on every pull request and writes a saved plan file, and an apply job that runs on merge and consumes that exact file. Moving that pipeline to WarpBuild is a one-line `runs-on` change plus a cache step for the provider plugin directory, because `terraform init` spends most of its wall time pulling provider binaries rather than computing anything. The four phases of a Terraform job have very different resource profiles, and knowing which is which decides both the runner label and the cache configuration. `terraform init` downloads provider plugins and modules and configures the backend. This is network and disk work. A workspace pinning the AWS provider plus a Kubernetes provider and a Helm provider can pull several hundred megabytes of compressed plugin archives and expand them into `.terraform/providers` on every fresh machine. `terraform fmt` and `terraform validate` are cheap. They parse configuration and never leave the machine. `terraform plan` refreshes state by calling the provider API once per managed resource, then diffs the result against configuration. The `-parallelism` flag defaults to 10 concurrent operations, and those operations are mostly waiting on HTTPS responses from a cloud control plane. A workspace with 800 resources spends the refresh phase almost entirely idle on CPU. `terraform apply` has the same shape plus create, update, and delete calls that block on the provider. An RDS instance or a node group upgrade can hold a job open for many minutes while the runner does nothing at all. Two phases still reward a faster machine. Extracting provider archives into the working directory is disk-bound, and checkout plus plan-artifact upload move real bytes. Per-size rates for every label are on the [pricing page](/pricing). Terraform pipelines live on the Linux labels, and the rest of this page uses them. There is a second relationship between Terraform and WarpBuild worth separating out. Terraform support exists for BYOC on AWS, which means the runners themselves can be declared as code alongside the infrastructure they build. That path is covered at the end of the configuration section and in depth on [Terraform for WarpBuild BYOC on AWS](/byoc/aws/terraform). ## [Configuration](#configuration)Copy Anchor Link This workflow runs a plan on pull requests and an apply on merge, with the provider plugin directory cached through [WarpBuilds/cache](https://github.com/WarpBuilds/cache), a drop-in replacement for `actions/cache@v4` that writes to the WarpBuild cache backend enabled by default on Linux runners. Copy Text ``` name: terraform on: pull_request: paths: - "infra/**" push: branches: [main] paths: - "infra/**" concurrency: group: terraform-infra-${{ github.ref }} cancel-in-progress: false env: TF_IN_AUTOMATION: "1" TF_PLUGIN_CACHE_DIR: /home/runner/.terraform.d/plugin-cache jobs: plan: runs-on: warp-ubuntu-latest-x64-2x permissions: contents: read id-token: write defaults: run: working-directory: infra steps: - uses: actions/checkout@v4 - run: mkdir -p "$TF_PLUGIN_CACHE_DIR" working-directory: . - name: Restore Terraform provider plugin cache uses: WarpBuilds/cache@v1 with: path: /home/runner/.terraform.d/plugin-cache key: ${{ runner.os }}-tfplugins-${{ hashFiles('infra/.terraform.lock.hcl') }} restore-keys: | ${{ runner.os }}-tfplugins- - uses: hashicorp/setup-terraform@v3 with: terraform_version: ${{ vars.TERRAFORM_VERSION }} terraform_wrapper: false - run: terraform init -input=false - run: terraform fmt -check -recursive - run: terraform validate - run: terraform plan -input=false -lock-timeout=5m -out=tfplan - uses: actions/upload-artifact@v4 with: name: tfplan-${{ github.sha }} path: infra/tfplan retention-days: 5 apply: if: github.ref == 'refs/heads/main' needs: plan runs-on: warp-ubuntu-latest-x64-4x environment: production permissions: contents: read id-token: write defaults: run: working-directory: infra steps: - uses: actions/checkout@v4 - run: mkdir -p "$TF_PLUGIN_CACHE_DIR" working-directory: . - name: Restore Terraform provider plugin cache uses: WarpBuilds/cache@v1 with: path: /home/runner/.terraform.d/plugin-cache key: ${{ runner.os }}-tfplugins-${{ hashFiles('infra/.terraform.lock.hcl') }} restore-keys: | ${{ runner.os }}-tfplugins- - uses: hashicorp/setup-terraform@v3 with: terraform_version: ${{ vars.TERRAFORM_VERSION }} terraform_wrapper: false - uses: actions/download-artifact@v4 with: name: tfplan-${{ github.sha }} path: infra - run: terraform init -input=false - run: terraform apply -input=false -lock-timeout=5m -auto-approve tfplan ``` Four details in that file earn their place. `TF_PLUGIN_CACHE_DIR` has to exist before `terraform init` runs. Terraform skips the plugin cache without an error when the directory is missing, so the `mkdir -p` step is what makes the cache step matter. The key hashes `.terraform.lock.hcl` because that file records the exact provider versions and their hashes, so the cache rolls when a provider version moves and holds steady otherwise. `terraform_wrapper: false` keeps the real binary on `PATH`. The wrapper script captures stdout and stderr to expose them as step outputs, which interferes with `plan -detailed-exitcode` and with any script that reads Terraform's own exit code. `-lock-timeout=5m` stops a job failing instantly when another pull request holds the state lock. The `concurrency` group with `cancel-in-progress: false` does the heavier lifting by serializing runs against the same ref, so plans queue rather than collide. The apply job reads the artifact the plan job wrote. Applying a saved plan file is what makes the merge deterministic, because the apply performs the operations that were reviewed instead of recomputing a fresh diff against whatever the world looks like at merge time. ### [Reaching a private state backend](#reaching-a-private-state-backend)Copy Anchor Link Most Terraform pipelines that fail at the network layer fail in the same place: the state backend, a self-hosted Vault, or an internal API sits behind a private network that a public runner cannot reach. WarpBuild handles this with the networking addon, which joins the runner to your Tailscale tailnet at job start using OIDC and ephemeral node keys, then removes the node when the job ends. Configuration lives in the [networking documentation](/docs/ci/features/networking). You create a Network Addon Configuration in the dashboard with your Tailscale OIDC Client ID, then append it to the label: Copy Text ``` jobs: plan: runs-on: warp-ubuntu-latest-x64-2x;network.name=production-tailnet steps: - uses: actions/checkout@v4 - name: Reach the private state backend run: | terraform init -input=false \ -backend-config="address=https://tfstate.myorg.tailnet.ts.net/state/infra" \ -backend-config="lock_address=https://tfstate.myorg.tailnet.ts.net/state/infra" - run: terraform plan -input=false -out=tfplan ``` Extra `tailscale up` flags go in the Additional Arguments field of the addon configuration, and `--advertise-tags=tag:ci --accept-routes` is the common pair. Five flags are reserved and rejected if you pass them, because WarpBuild sets them itself: `--hostname`, `--auth-key`, `--client-id`, `--id-token`, and `--state`. The addon works on Linux x64, Linux ARM64, macOS, and Windows runners, and the same label syntax composes with snapshot runners. For the wider version of this question, see [running GitHub Actions runners in your VPC](/guides/github-actions-runners-in-your-vpc) and the short answer at [can GitHub Actions runners access my private network](/answers/can-github-actions-runners-access-my-private-network). ### [Declaring the runners themselves](#declaring-the-runners-themselves)Copy Anchor Link Terraform support exists for BYOC on AWS. The [WarpBuild Terraform provider](/docs/ci/api-keys/terraform) is in beta and covers AWS BYOC runner images and custom runner sets through typed resources. Beta releases are prereleases, so a range constraint such as `~> 0.2` never matches one and the version has to be pinned exactly. Copy Text ``` terraform { required_providers { warpbuild = { source = "WarpBuilds/warpbuild" # Prerelease version: pin it exactly version = "0.2.0-beta" } } } # Reads WARPBUILD_API_KEY from the environment provider "warpbuild" {} ``` The API key needs the `ci` scope and is created at [app.warpbuild.com/settings/api-keys](https://app.warpbuild.com/settings/api-keys). BYOC runs on AWS, GCP, and Azure, and the Terraform path covers AWS today. GCP and Azure runner sets are configured in the dashboard or through the automation API. ## [Sizing](#sizing)Copy Anchor Link Plan and apply are network bound rather than CPU bound, so the small labels are usually correct. These are the WarpBuild Linux runners worth considering for Terraform, with rates from the [pricing page](/pricing). | Runner label | vCPU | Memory | Storage | Price per minute | | --------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-arm64-2x | 2 | 8GB | 150GB SSD | $0.003 | | warp-ubuntu-latest-arm64-4x | 4 | 16GB | 150GB SSD | $0.006 | **warp-ubuntu-latest-x64-2x** at $0.004 per minute is the default for a plan job. A 2 vCPU machine keeps up with provider extraction and holds a state file of a few thousand resources in memory without trouble. **warp-ubuntu-latest-x64-4x** at $0.008 per minute is the right step up for an apply job, for workspaces with four or more providers to extract, and for repositories where a single `terraform plan` reads a state file large enough that JSON decoding becomes visible in the timing view. **warp-ubuntu-latest-x64-8x** rarely pays for itself on Terraform alone. Reach for it when the job does more than Terraform, such as building a container image or running a policy engine over the plan JSON in the same job. ARM64 labels shave the rate further, and Terraform and the major providers publish `linux_arm64` builds. One prerequisite matters: `.terraform.lock.hcl` records hashes per platform, so a lock file generated only on `linux_amd64` fails `terraform init` on an ARM64 runner. Regenerate it for both before switching, with `terraform providers lock -platform=linux_amd64 -platform=linux_arm64`, and commit the result. Cache metering is small enough to state exactly: storage is $0.20 per GB-month and each write or restore operation is $0.0001\. A plugin cache of 1.5GB with 8,000 operations a month adds about $1.10. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers) and on the [GitHub pricing page](https://github.com/pricing). Checked on 2026-08-13, the standard `ubuntu-latest` runner for private repositories is $0.006 per minute at 2 vCPU, and the 4-core Linux larger runner is $0.012 per minute. Take a platform team with 60 Terraform workspaces running 4,000 Terraform jobs a month at an average of 3.5 minutes each. That is 14,000 runner minutes. | Scenario | Rate | Monthly minutes | Monthly cost | | ------------------------------------ | ---------- | --------------- | ------------ | | GitHub-hosted standard Linux, 2 vCPU | $0.006/min | 14,000 | $84.00 | | warp-ubuntu-latest-x64-2x, 2 vCPU | $0.004/min | 14,000 | $56.00 | | warp-ubuntu-latest-arm64-2x, 2 vCPU | $0.003/min | 14,000 | $42.00 | | Plugin cache storage and operations | see above | n/a | $1.10 | At the 4 vCPU size used by the apply job, the same arithmetic runs against $0.012 per minute for the GitHub 4-core Linux larger runner and $0.008 per minute for `warp-ubuntu-latest-x64-4x`. Every rate above carries its source and the date it was checked, and the same numbers appear on the [pricing page](/pricing) and in the evidence data behind it. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Cold provider downloads.** Without a restored plugin cache, every job re-downloads and re-extracts every provider in the lock file. This is the single largest fixed cost in a Terraform pipeline and the reason the cache step above exists. Verify it worked by reading the `terraform init` output: a warm job reports that it is using a cached provider rather than downloading one. **Lock file platform mismatch.** Moving a pipeline between x64 and ARM64 labels, or between a Linux runner and a macOS laptop, fails `terraform init` with a checksum error until the lock file carries hashes for every platform in play. Regenerate with `terraform providers lock` and commit it. **Refresh against provider rate limits.** A large workspace makes one API call per resource during refresh, and cloud providers throttle. Raising `-parallelism` past the default of 10 usually makes throttling worse rather than better. Splitting a monolithic workspace into several smaller ones is the durable fix, and `-refresh=false` on pull request plans is the cheap one when state was refreshed recently by the last apply. **State lock contention.** Several pull requests planning against the same workspace serialize on the backend lock. The `concurrency` group in the workflow above queues them at the GitHub Actions layer instead, which turns a hard failure into a wait, and `-lock-timeout` covers the rest. **Long applies holding a job open.** Resources that take ten or twenty minutes to converge keep a runner allocated the whole time. Sizing up does not help here, so the lever is the label rate rather than the label size, which is another argument for the 2x and 4x labels. **Opaque failures.** When a job is slow or fails and the cause is unclear, WarpBuild's CI observability correlates system metrics from the runner agent with GitHub Actions job logs, which separates a job waiting on a provider API from a job that ran out of disk. The Action Debugger pauses a workflow and opens an SSH session on the runner, so you can inspect `.terraform/providers`, the plugin cache directory, and backend reachability on the machine itself. Snapshot runners, remote Docker builders, an MCP server, and the Action Debugger are all part of the same product surface. Deployment pipelines that end in a cluster rather than a cloud API have a matching set of failure modes; those are covered in [Kubernetes deployments on GitHub Actions](/solutions/kubernetes-deployments-github-actions). ## [Proof](#proof)Copy Anchor Link Public OSS repositories running `warp-` labels are citable evidence, and you can read the `runs-on` line yourself instead of taking a claim on trust. The Trigger.dev end-to-end suite runs its matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x` in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml), checked on 2026-08-13\. That is the same 4x Linux label the apply job above uses. The cheapest check is your own state. SSO is available for a flat $250 per month, whatever the user count, which is the number to bring to the review where someone asks how apply permissions are governed. Once the pipeline is stable, the next step for most teams is declaring the runners the same way they declare everything else. [Terraform for WarpBuild BYOC on AWS](/byoc/aws/terraform) covers the provider resources, the two credentials involved, and which objects Terraform owns. ## [FAQ](#faq)Copy Anchor Link **Where should TF\_PLUGIN\_CACHE\_DIR point on a WarpBuild runner?** Point it at a directory under the runner home, such as `/home/runner/.terraform.d/plugin-cache`, create it before `terraform init` runs, and restore it with `WarpBuilds/cache@v1` keyed on the hash of `.terraform.lock.hcl`. Terraform silently skips the cache when the directory does not exist. **What runner size do terraform plan and apply need?** Plan and apply are network bound rather than CPU bound, so `warp-ubuntu-latest-x64-2x` at $0.004 per minute is usually correct, and `warp-ubuntu-latest-x64-4x` at $0.008 per minute covers large state files and workspaces with many providers. **How does a Terraform job reach a private state backend?** Append the networking addon to the `runs-on` label as `network.name=`, where the config is a Network Addon Configuration you create in the WarpBuild dashboard. The runner joins your tailnet as an ephemeral node at job start and is removed when the job finishes. **Can Terraform manage the WarpBuild runners themselves?** Terraform support exists for BYOC on AWS. The `WarpBuilds/warpbuild` provider is in beta, covers AWS BYOC runner images and custom runner sets, and authenticates with a `ci`\-scoped `WARPBUILD_API_KEY`. Pin the prerelease version exactly, for example `0.2.0-beta`. --- # Turborepo on GitHub Actions: Cache Configuration | WarpBuild URL: https://www.warpbuild.com/solutions/turborepo-github-actions Description: Turborepo caches task outputs by hash. Configure the remote cache, persist .turbo with WarpBuilds/cache, and size warp- runners for turbo run concurrency. # Turborepo on GitHub Actions: Cache Configuration Turborepo caches task outputs by hash. Configure the remote cache, persist .turbo with WarpBuilds/cache, and size warp- runners for turbo run concurrency. Last verified: 2026-08-13 To make a Turborepo cache work on GitHub Actions, give `turbo` a remote cache through `TURBO_API`, `TURBO_TOKEN`, and `TURBO_TEAM` so task outputs are shared across every job and every branch, then persist the runner-local `.turbo` directory between runs so a hit costs a local file copy instead of a download. On WarpBuild runners the second half is a one-line swap: [WarpBuilds/cache](https://github.com/WarpBuilds/cache) is a drop-in replacement for `actions/cache@v4`, and snapshot runners can carry `node_modules` and `.turbo` across jobs on the runner disk itself. This page covers the `turbo.json` and workflow configuration for both cache layers, how the remote cache and the runner-local cache interact, what produces a miss in each, the sizing call for `turbo run --concurrency` against 8 and 16 vCPU runners, and a cost model against GitHub-hosted runner list prices. ## [Overview](#overview)Copy Anchor Link Turborepo runs a task graph. For every task in every package it computes a hash, looks for a cached result under that hash, and either replays the stored outputs and logs or executes the task and stores the result. The speed of a monorepo pipeline on GitHub Actions comes down to how often that lookup hits and how expensive a hit is. A hash covers more than the package source. Turborepo folds in the files matched by the task's `inputs` (defaulting to everything git tracks in that package), the hashes of the packages it depends on through `dependsOn`, the task's own definition in `turbo.json`, the values of the environment variables declared in `env` and `globalEnv`, and the files listed in `globalDependencies` along with the lockfile. Change any one of them and the hash moves. There are two places a result can live. **The remote cache** is a server that stores artifacts by hash and serves them to any machine holding a valid token. It is what makes a cache useful on GitHub Actions at all, because every job starts on a machine that has never seen your repository. A pull request job can replay a `build` produced an hour earlier by a `main` job on a different runner. **The filesystem cache** lives on the runner itself, at `.turbo/cache` in the repository root by default. The `cacheDir` key in `turbo.json` and the `TURBO_CACHE_DIR` environment variable both move it. On a fresh virtual machine this directory is empty, so the filesystem cache does nothing for the first run of a job. The two layers are ordered. Turborepo checks the filesystem cache first. On a miss it asks the remote cache, and when the remote cache answers it writes the artifact into the filesystem cache on the way through, so a second task in the same run that resolves to the same hash reads it locally. A hit from local disk is a file copy. A hit from the remote cache is a network round trip plus a download, repeated for every package that hits. That difference is the whole reason to persist `.turbo` between GitHub Actions runs. A monorepo with 80 packages and a warm remote cache still pays 80 downloads on a fully cached run. With `.turbo` restored from the previous run, most of those become local reads and only the packages whose hashes actually moved touch the network. What produces a miss differs by layer. **Remote cache.** Entries are immutable and addressed by hash, so nothing is ever invalidated in place. A changed input produces a hash that has never been uploaded, which reads as a miss. Entries disappear only when the cache server evicts them under its own retention policy. One exception is worth knowing: with `remoteCache.signature` enabled, artifacts are signed with `TURBO_REMOTE_CACHE_SIGNATURE_KEY` and Turborepo rejects any artifact whose signature does not verify, so rotating that key makes every existing entry unusable at once. **Filesystem cache.** It is a directory on a disk. On an ordinary GitHub Actions job it is destroyed with the virtual machine. It survives only as far as you carry it: restored by a cache action under a key you choose, or left in place on a snapshot runner. It also has no bound of its own, since every distinct hash adds another archive to it, which becomes a cost question further down this page. Turborepo work belongs on the Linux runners, both because the sizes go up to 32 vCPU and because [WarpBuild Cache](/docs/ci/features/caching) is enabled by default on Linux runners and is not supported on Windows runners. ## [Configuration](#configuration)Copy Anchor Link Start with `turbo.json`. The parts that decide cache behavior are `inputs`, `outputs`, `env`, and the global entries: Copy Text ``` { "$schema": "https://turbo.build/schema.json", "globalDependencies": ["tsconfig.base.json", ".nvmrc"], "globalEnv": ["NODE_ENV"], "remoteCache": { "signature": true }, "tasks": { "build": { "dependsOn": ["^build"], "inputs": ["src/**", "package.json", "tsconfig.json"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"], "env": ["NEXT_PUBLIC_API_URL"] }, "test": { "dependsOn": ["build"], "inputs": ["src/**", "test/**", "vitest.config.ts"], "outputs": ["coverage/**"] }, "lint": { "dependsOn": ["^build"], "outputs": [] }, "dev": { "cache": false, "persistent": true } } } ``` Three details in that file earn their place. `outputs` with `"!.next/cache/**"` keeps a framework's own incremental cache out of the artifact, which otherwise makes every `build` artifact enormous. `"outputs": []` on `lint` says the task produces no files, so a hit replays only the logs. `env` declares which environment variables the task is allowed to read; Turborepo's strict environment mode means an undeclared variable is invisible to the task rather than silently baked into the result. Now the workflow. This job restores the pnpm store and the Turborepo filesystem cache, then runs the graph on an 8 vCPU WarpBuild runner: Copy Text ``` name: monorepo on: push: branches: [main] pull_request: env: TURBO_API: ${{ vars.TURBO_API }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_SIGNATURE_KEY }} jobs: build-and-test: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 with: fetch-depth: 2 - uses: pnpm/action-setup@v4 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 22 - name: Point turbo at a cache dir outside the workspace run: | echo "TURBO_CACHE_DIR=$HOME/.cache/turbo" >> "$GITHUB_ENV" echo "PNPM_STORE=$(pnpm store path)" >> "$GITHUB_ENV" - name: Restore pnpm store uses: WarpBuilds/cache@v1 with: path: ${{ env.PNPM_STORE }} key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm- - name: Restore turbo filesystem cache uses: WarpBuilds/cache@v1 with: path: ~/.cache/turbo key: ${{ runner.os }}-turbo-${{ github.sha }} restore-keys: | ${{ runner.os }}-turbo- - run: pnpm install --frozen-lockfile - name: Run the graph run: pnpm turbo run build lint test --concurrency=8 --summarize ``` The two cache steps carry different keys on purpose. The pnpm store key hashes the lockfile, because the store only changes when dependencies change. The turbo key uses the commit SHA with a `restore-keys` prefix, because the filesystem cache changes on every commit and you want the most recent one rather than an exact match. The prefix fallback is what turns a per-commit key into a rolling warm cache. `TURBO_CACHE_DIR` moves the filesystem cache to `$HOME/.cache/turbo`, outside the checkout. That placement is deliberate and the reason is covered under bottlenecks below. `--summarize` writes a run summary to `.turbo/runs`, listing every task, its hash, and whether it hit. When a pipeline stops hitting the cache and nobody knows why, that file names the task and the hash that moved. ### [Keeping node\_modules and .turbo warm with snapshot runners](#keeping-node%5Fmodules-and-turbo-warm-with-snapshot-runners)Copy Anchor Link A cache action moves bytes over the network on every job. [Snapshot runners](/docs/ci/features/snapshot-runners) take the other approach: they capture the runner VM's disk mid-workflow and boot later jobs from that image, so `node_modules`, the pnpm store, and `.turbo` are already on disk when the job starts. The label carries the configuration. `snapshot.enabled=true` boots from the base image and lets you save a snapshot, while `snapshot.key=` boots from an existing snapshot for that alias when one exists. The usual arrangement builds the snapshot on `main` and reads it everywhere else: Copy Text ``` jobs: build-and-test: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-8x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-8x;snapshot.key=monorepo-warm' }} steps: - uses: actions/checkout@v4 with: clean: false - name: Point turbo at a cache dir outside the workspace run: echo "TURBO_CACHE_DIR=$HOME/.cache/turbo" >> "$GITHUB_ENV" - run: pnpm install --frozen-lockfile - run: pnpm turbo run build lint test --concurrency=8 - name: Cleanup credentials if: github.ref == 'refs/heads/main' run: rm -rf $HOME/.ssh $HOME/.aws - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "monorepo-warm" fail-on-error: true wait-timeout-minutes: 60 ``` Four constraints from the snapshot documentation shape where this works. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. BYOC runners, Windows runners, and macOS runners are unsupported, and a snapshot label on any of them is silently ignored while the job runs normally. A monorepo whose iOS packages build on macOS runners keeps the cache action there. `/tmp` does not persist, because the directory is cleaned on reboot. Any warm state has to live somewhere else, which is another reason `$HOME/.cache/turbo` is the right target for `TURBO_CACHE_DIR`. Snapshots are deleted after 15 days. A repository that only builds occasionally will find the alias gone and boot from the base image, which is correct behavior rather than a failure. Booting from a snapshot takes 45 to 60 seconds and can be slower than starting a default runner. The snapshot pays off when the state it carries saves more time than the boot costs, which the cost model below puts a number on. ## [Sizing](#sizing)Copy Anchor Link WarpBuild Linux x64 runners hold 4GB of memory per core across the range. These are the sizes a monorepo pipeline actually uses: | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-x64-32x | 32 | 128GB | 150GB SSD | $0.064 | `turbo run` defaults to `--concurrency=10`, which is a fixed number unrelated to the machine it lands on. On a 2 vCPU runner that oversubscribes badly; on a 16 vCPU runner it leaves cores idle. The flag also accepts a percentage of cores, so `--concurrency=50%` resolves to 8 on a 16x runner. Pick the number from what the tasks themselves do. **Single-threaded tasks.** `tsc`, `eslint`, and most codegen run on one core each. Set `--concurrency` to the vCPU count: 8 on `warp-ubuntu-latest-x64-8x`, 16 on `warp-ubuntu-latest-x64-16x`. Each task gets a core and 4GB, which covers a `tsc` process on all but the largest programs. **Tasks with their own worker pools.** Vitest, Jest, and bundlers such as esbuild fan out internally. Ten of those at `--concurrency=10` on an 8 vCPU runner produce dozens of worker processes fighting over 8 cores and 32GB. Drop `--concurrency` to 4 or 6 and cap the inner tool instead, with `vitest run --maxWorkers=2` or `jest --maxWorkers=2`, so the total worker count stays near the core count. **Mixed graphs.** Split the invocation. Run `turbo run build lint --concurrency=16` on a 16x runner where the work is mostly single-threaded, then `turbo run test --concurrency=4` where each task manages its own pool. Memory is the constraint that decides 8x against 16x more often than cores do. Sixteen concurrent `tsc` processes on a large TypeScript graph can hold several gigabytes each, and 64GB on the 16x runner is what keeps them alive. If a run dies with heap errors at `--concurrency=16` on 8x, the fix is either the larger runner or a lower concurrency, and the cheaper of the two depends on how long the run takes. Sharding is the other lever. `turbo run test --filter=...[origin/main]` restricts the graph to packages affected since a base ref, and a matrix can split the remaining work across several smaller runners. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, so a 20-way matrix on `warp-ubuntu-latest-x64-4x` is a scheduling decision rather than a quota question. ### [What the cache layers cost](#what-the-cache-layers-cost)Copy Anchor Link Cache and snapshot usage are metered separately from runner minutes: | Metric | Rate | | ----------------------------- | ------------------------ | | Cache storage | $0.20 per GB-month | | Cache write, restore, or list | $0.0001 per operation | | Snapshot restore | $0.04 per job | | Snapshot storage | $0.025 per snapshot-hour | Those rates make the snapshot decision arithmetic rather than opinion. A snapshot restore costs $0.04 per job. On `warp-ubuntu-latest-x64-8x` at $0.016 per minute, $0.04 buys 2.5 minutes of runner time, so a snapshot has to save more than 2.5 minutes per job to pay for itself. On `warp-ubuntu-latest-x64-16x` at $0.032 per minute the break-even drops to 1.25 minutes. Subtract the 45 to 60 second boot from whatever the snapshot saves before comparing. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices for its hosted runners at [github.com/pricing](https://github.com/pricing) and on the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the standard `ubuntu-latest` runner for private repositories (2 vCPU) is $0.006 per minute, and larger Linux runners are $0.012 for 4 vCPU, $0.022 for 8 vCPU, and $0.042 for 16 vCPU. | Machine | vCPU | Per-minute rate | Source | | --------------------------------- | ---- | --------------- | ------------------------------------- | | GitHub-hosted Linux larger runner | 8 | $0.022 | GitHub list price, checked 2026-08-13 | | GitHub-hosted Linux larger runner | 16 | $0.042 | GitHub list price, checked 2026-08-13 | | warp-ubuntu-latest-x64-8x | 8 | $0.016 | WarpBuild pricing | | warp-ubuntu-latest-x64-16x | 16 | $0.032 | WarpBuild pricing | Take a monorepo with 80 packages running 1,200 pull request and push workflows a month on an 8 vCPU machine. With no cache reuse, `turbo run build lint test` takes 14 minutes. With a warm remote cache and a restored filesystem cache, the average run rebuilds only the packages that changed and finishes in 5 minutes. | Scenario | Rate | Monthly minutes | Monthly cost | | ----------------------------------------- | ---------- | --------------- | ------------ | | GitHub-hosted 8 vCPU, no cache reuse | $0.022/min | 16,800 | $369.60 | | GitHub-hosted 8 vCPU, warm cache | $0.022/min | 6,000 | $132.00 | | warp-ubuntu-latest-x64-8x, no cache reuse | $0.016/min | 16,800 | $268.80 | | warp-ubuntu-latest-x64-8x, warm cache | $0.016/min | 6,000 | $96.00 | Stated as list-price arithmetic: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13. Add the cache layer itself. An 8GB combined pnpm store and Turborepo archive costs 8 x $0.20 = $1.60 per month in storage. The two cache steps in the workflow above, each doing a restore and a save across 1,200 runs, come to 4,800 operations at $0.0001, or $0.48\. The cache layer for this workload lands at $2.08 a month against $96.00 of runner time. Now price the snapshot alternative for the same 1,200 runs: 1,200 restores at $0.04 is $48.00, plus one live snapshot held for a 730-hour month at $0.025 per snapshot-hour, or $18.25\. That is $66.25 a month, which only beats the cache action if the snapshot saves more than 2.5 minutes per job. Snapshots win when installs are slow and the working set is large, and the cache action wins when the store restores quickly. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Checkout wiping the warm state.** `actions/checkout` defaults `clean` to true, which runs `git clean -ffdx` before fetching and removes every ignored and untracked file in the workspace. `node_modules` and a `.turbo` directory at the repository root are both ignored files, so the default checkout deletes exactly the state a snapshot runner just booted with. Two fixes work together: set `clean: false` on checkout in snapshot jobs, and move the Turborepo cache out of the workspace with `TURBO_CACHE_DIR`. The same trap applies in reverse when following the snapshot documentation's cleanup advice, since a blanket `git clean -ffdx` before `snapshot-save` throws away the warm workspace you were trying to capture. Scope the cleanup to credentials instead. **Environment variables in the hash.** Every variable named in `env` or `globalEnv` contributes its value to the hash. A variable that changes per run, a commit SHA, a build ID, a timestamp, produces a unique hash for every single run and a permanent miss. Audit the declared list, move genuinely per-run values into `passThroughEnv` so they reach the task without entering the hash, and confirm with `--summarize` that two consecutive runs on an unchanged package produce the same hash. **Overly broad inputs.** The default `inputs` is every git-tracked file in the package, so editing a README invalidates the `build`. Declaring `inputs` explicitly, as in the `turbo.json` above, keeps documentation and configuration churn out of the hash. The opposite mistake also happens: an `inputs` list that omits a file the task actually reads produces a hit that replays stale output, which is worse than a miss. **Framework caches inside outputs.** Next.js writes an incremental compiler cache into `.next/cache`, and Vite and similar tools have equivalents. Capturing those directories in `outputs` inflates every artifact, slows both upload and download, and drives cache storage cost. The `"!.next/cache/**"` exclusion is the fix. **Unbounded filesystem cache growth.** Every distinct hash adds another archive under `TURBO_CACHE_DIR`. On a snapshot runner that directory grows across the snapshot chain until the disk fills; behind a cache action it grows the archive you upload and restore on every job, which shows up as both slower cache steps and higher storage cost at $0.20 per GB-month. Prune it on a schedule, deleting entries older than a chosen age, or rotate the cache key on a cadence so a fresh key starts from a smaller base. **Remote cache misses that look like network problems.** When `turbo` cannot reach the remote cache it logs a warning and executes the task, so the pipeline stays correct and quietly gets slower. Check that `TURBO_TOKEN` is present on pull request events, since a token stored as a repository secret is not available to workflows triggered by `pull_request` from a fork. Runs from forks fall back to the filesystem cache alone. For diagnosing the runs where none of this is obvious, WarpBuild ships CI observability, which correlates OpenTelemetry-based system metrics from the runner agent with GitHub Actions job logs, so a `turbo run` that is memory-starved at high concurrency looks different from one stalled on cache downloads. The Action Debugger pauses a workflow and opens an SSH session on the runner, which is the fastest way to inspect `TURBO_CACHE_DIR` and the `.turbo/runs` summary on the machine that produced them. Both are described in the [WarpBuild caching documentation](/docs/ci/features/caching) and its neighbors, and the broader pattern of carrying state between runs is covered in the guide to [persistent caches for GitHub Actions](/guides/persistent-caches-github-actions). ## [Proof](#proof)Copy Anchor Link For a monorepo the change is confined to the `runs-on` label plus the two cache steps above. Nothing in `turbo.json` needs to move. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps, which is the property a task-graph monorepo leans on hardest, because the natural shape of `turbo run --filter` sharding is a wide matrix that all starts at once. If the monorepo has to build inside your own cloud account for data residency or policy reasons, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. Note the tradeoff for this page specifically: snapshot runners are unavailable on BYOC, so a BYOC monorepo keeps the remote cache plus a cache action and skips the snapshot layer entirely. Two neighboring pages cover adjacent decisions. [Nx task caching on GitHub Actions](/solutions/nx-github-actions) walks the same layering for an Nx graph, and [Node.js dependency caching on GitHub Actions](/solutions/nodejs-github-actions) covers the single-package case where the package manager store is the only cache worth keeping. The runner-side detail behind the snapshot workflow above lives on the [snapshot runners](/runners/snapshot) page. ## [FAQ](#faq)Copy Anchor Link **Does the Turborepo remote cache replace actions/cache?** No. The remote cache stores task outputs keyed by Turborepo's hash. A cache action stores the runner-local `.turbo` directory and the package manager store keyed by a key you write. Most monorepos configure both, because the remote cache still costs a download on every hit. **What invalidates a Turborepo cache entry?** Nothing invalidates an entry in place. Turborepo computes a new hash when a package input, a dependency hash, the task definition in `turbo.json`, a declared env value, or a `globalDependencies` file changes, and a new hash is simply a miss. Old entries stay until the cache server evicts them. **What concurrency should turbo run use on a warp- runner?** Match `--concurrency` to the vCPU count when tasks are single threaded, so 8 on `warp-ubuntu-latest-x64-8x` and 16 on `warp-ubuntu-latest-x64-16x`. Drop it to 4 or 6 when each task spawns its own worker pool, and cap the inner tool instead. **Do snapshot runners work with Turborepo on macOS or Windows jobs?** No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. Snapshot labels on macOS, Windows, or BYOC runners are silently ignored and the job runs normally without snapshot behavior. --- # Unity Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/unity-github-actions Description: Run Unity builds on GitHub Actions with WarpBuild Windows runners from 4 to 32 vCPU, 256GB SSDs, $0.016 to $0.128 per minute, and a cached Library folder. # Unity Builds on GitHub Actions Run Unity builds on GitHub Actions with WarpBuild Windows runners from 4 to 32 vCPU, 256GB SSDs, $0.016 to $0.128 per minute, and a cached Library folder. Last verified: 2026-08-13 A Unity build on GitHub Actions needs a Windows runner with cores for shader compilation and the IL2CPP native compile, a disk large enough to hold the Editor plus the project's Library folder, and a cache that keeps the asset import from running cold on every job. WarpBuild provides Windows runners from 4 to 32 vCPUs, every size on a 256GB SSD, at $0.016 to $0.128 per minute, selected by pointing `runs-on` at a label such as `warp-windows-latest-x64-16x`. This page covers the workflow YAML for a Windows player build with the Library folder cached, the macOS path for iOS player builds, the Windows catalog rows against GitHub-hosted list prices, the four bottlenecks that dominate Unity wall clock time, and how to size storage before a job runs out of room. ## [Overview](#overview)Copy Anchor Link A Unity repository usually settles into three job shapes. The first runs on every pull request: edit-mode tests, static analysis, and often a fast development player build for one target. The second is the platform matrix on merge to the main branch: a player build per shipping target, each producing an artifact for QA. The third is the release job, which adds symbol upload, addressable bundle publishing, and store submission. Most of that work lives on Windows. The Windows Editor covers Windows, Android, WebGL, and console targets, and it hosts the MSVC toolchain that IL2CPP invokes for the native compile step. The iOS player is the exception: Unity exports an Xcode project and `xcodebuild` finishes the archive, so that job has to run on macOS. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so both jobs live in one workflow file and differ only in the `warp-` label on `runs-on`. Every runner is an ephemeral VM, freshly allocated for the job and destroyed afterward, and the Windows images carry the same tooling as GitHub-hosted Windows images. The full matrix with sizes and prices is in the [cloud runner documentation](/docs/ci/cloud-runners) and on the [Windows runner hub](/runners/windows). No hosted runner image ships the Unity Editor, so the Editor install and its activation are steps your workflow owns. This page stays on runner sizing, storage, and caching, which is where the recoverable minutes are. Studios shipping on a second engine hit the same Windows sizing questions from a different direction; the [Unreal Engine builds page](/solutions/unreal-engine-github-actions) covers that pipeline. ## [Configuration](#configuration)Copy Anchor Link Here is a Windows player build on `warp-windows-latest-x64-16x` with the Library folder cached, followed by the macOS job that produces the iOS archive. Copy Text ``` name: unity on: pull_request: push: branches: [main] env: UNITY_VERSION: "6000.0.42f1" jobs: windows-player: runs-on: warp-windows-latest-x64-16x timeout-minutes: 90 steps: - uses: actions/checkout@v4 with: lfs: true - name: Install the Unity Editor shell: pwsh run: ./ci/install-unity.ps1 -Version $env:UNITY_VERSION - uses: actions/cache@v4 with: path: | Library/Artifacts Library/ArtifactDB Library/SourceAssetDB Library/ShaderCache Library/PackageCache key: unity-win64-${{ env.UNITY_VERSION }}-${{ hashFiles('Packages/packages-lock.json', 'ProjectSettings/ProjectVersion.txt') }} restore-keys: | unity-win64-${{ env.UNITY_VERSION }}- - name: Build the Windows player shell: pwsh run: | & "$env:UNITY_PATH" ` -batchmode -nographics -quit ` -projectPath . ` -buildTarget StandaloneWindows64 ` -executeMethod BuildScript.BuildWindowsPlayer ` -logFile - - uses: actions/upload-artifact@v4 with: name: windows-player path: Build/StandaloneWindows64 retention-days: 7 compression-level: 0 ios-player: runs-on: warp-macos-15-arm64-12x timeout-minutes: 120 steps: - uses: actions/checkout@v4 with: lfs: true - name: Install the Unity Editor run: ./ci/install-unity.sh "$UNITY_VERSION" - uses: actions/cache@v4 with: path: | Library/Artifacts Library/ArtifactDB Library/SourceAssetDB Library/ShaderCache Library/PackageCache key: unity-ios-${{ env.UNITY_VERSION }}-${{ hashFiles('Packages/packages-lock.json', 'ProjectSettings/ProjectVersion.txt') }} restore-keys: | unity-ios-${{ env.UNITY_VERSION }}- - name: Export the Xcode project run: | "$UNITY_PATH" \ -batchmode -nographics -quit \ -projectPath . \ -buildTarget iOS \ -executeMethod BuildScript.ExportXcodeProject \ -logFile - - name: Build the archive run: | xcodebuild -project Build/iOS/Unity-iPhone.xcodeproj \ -scheme Unity-iPhone \ -configuration Release \ -archivePath "$RUNNER_TEMP/Unity-iPhone.xcarchive" \ archive ``` Five details matter here. **Cache Library with `actions/cache`, and keep the path narrow.** The WarpBuild cache is available on Linux runners and is not supported on Windows runners, so Windows jobs use GitHub's cache service. That service caps a repository at 10 GB across all entries, per [GitHub's caching documentation](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows) checked on 2026-08-13, and a full Library folder on a mid-size project passes that on its own. The five subtrees above are the ones that pay back the import step. `Library/Bee` and the IL2CPP intermediate directories are large and rebuild quickly, so leave them out when space is tight. **Give every build target its own cache key.** Texture compression, audio format, and shader variants all differ per platform, so a Library folder imported for `StandaloneWindows64` is wrong for `iOS`. The `unity-win64-` and `unity-ios-` prefixes keep the two jobs from overwriting each other, and the `restore-keys` prefix lets a job start from the newest entry for that target when the lock file changes. **Hash the lock file and the Editor version.** `Packages/packages-lock.json` changes when a package dependency moves, `ProjectSettings/ProjectVersion.txt` changes when the Editor version moves, and either one invalidates imported artifacts. Keying on both means the cache turns over exactly when the imported output stops being valid. **Check out LFS.** Most Unity repositories keep textures, audio, and models in Git LFS. Without `lfs: true` the working tree holds pointer files, the import produces broken artifacts, and the build fails late with an error that points at the asset rather than the checkout. **Take one target per job.** Each job in the matrix restores its own cache and stays on one build target for its whole life. Per-minute billing means four 20 minute jobs in parallel cost the same as one 80 minute job, while returning results four times sooner. On the Windows side, the IL2CPP native compile step calls the Visual Studio toolchain that ships in the image. The image families and the toolset each one carries are listed on the [Visual Studio images page](/runners/windows/visual-studio-images), and the same toolchain caching tactics that apply to native code apply here, covered in the [MSBuild caching guide](/guides/msbuild-caching-github-actions). ## [Sizing](#sizing)Copy Anchor Link The Windows catalog, with per-minute rates from the [pricing page](/pricing) and GitHub's published rates for the same shapes: | Runner label | vCPU | Memory | Storage | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | Lower by | | --------------------------- | ---- | ------ | --------- | -------------------- | ----------------------------- | ----------------- | ---------- | | warp-windows-latest-x64-4x | 4 | 16GB | 256GB SSD | $0.016 | 4-core Windows larger runner | $0.022 | 27 percent | | warp-windows-latest-x64-8x | 8 | 32GB | 256GB SSD | $0.032 | 8-core Windows larger runner | $0.042 | 24 percent | | warp-windows-latest-x64-16x | 16 | 64GB | 256GB SSD | $0.064 | 16-core Windows larger runner | $0.082 | 22 percent | | warp-windows-latest-x64-32x | 32 | 128GB | 256GB SSD | $0.128 | 32-core Windows larger runner | $0.162 | 21 percent | Each row compares the same vCPU and memory shape on both sides. The 16 vCPU row is the arithmetic most Unity teams care about: ($0.082 - $0.064) / $0.082 = 22 percent lower list price. GitHub rates come from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. The `warp-windows-2025-x64-` and `warp-windows-2025-vs2026-x64-` label families come in the same four sizes at the same rates. Windows 2 vCPU runners were removed on June 8, 2026, so 4 vCPU is the smallest Windows size. ### [Worked cost model](#worked-cost-model)Copy Anchor Link Take a studio running 500 pull request pipelines a month, each one a 22 minute Windows player build on 16 vCPU, plus a nightly multi-platform build of 40 minutes on 32 vCPU across 60 nights. | Job | Minutes per month | WarpBuild rate | WarpBuild cost | GitHub-hosted rate | GitHub-hosted cost | | ----------------------------------- | ----------------- | -------------- | -------------- | ------------------ | ------------------ | | Pull request player build (16 vCPU) | 11,000 | $0.064/min | $704.00 | $0.082/min | $902.00 | | Nightly platform matrix (32 vCPU) | 2,400 | $0.128/min | $307.20 | $0.162/min | $388.80 | | **Total** | 13,400 | **$1,011.20** | **$1,290.80** | | | The gap is $279.60 a month, about 22 percent, holding minutes equal on both sides. ### [Sizing the disk](#sizing-the-disk)Copy Anchor Link Storage binds before memory does on Unity jobs. A single Windows job holds the Editor install, the checkout with every source asset pulled through LFS, the Library folder of imported artifacts, the shader cache, the IL2CPP and Burst intermediate output, the built player, and any addressable bundles. The Library folder alone frequently exceeds the size of the source assets it was imported from, because each asset is stored again in a target-specific format. Every WarpBuild Windows size carries a 256GB SSD, against 150GB on the Linux x64 labels, which is the practical reason Unity work sits on Windows machines beyond toolchain compatibility. Runner storage is ephemeral and is deleted when the runner terminates, so the disk holds one job's working set and nothing else. When the working set passes 256GB, the job fails partway through, and the message comes from whichever tool ran out of room, usually the asset importer, the native compile, or the artifact upload step, rather than naming the disk. Four things bring the working set back down: build one target per job so only one Library variant exists on disk, delete intermediate output between the build and upload steps, narrow the artifact path to what downstream jobs consume, and drop `Library/Bee` from the cached paths so a restore does not land data the job would rebuild anyway. Projects that stay above 256GB after all four, which usually means very large source asset libraries, can run on BYOC, where runners live in your own AWS, GCP, or Azure account and you configure the instance type and disk yourself. The [BYOC documentation](/docs/ci/byoc) covers that setup. ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four things dominate Unity wall clock time on GitHub Actions. **1\. Cold Library import.** On a fresh VM with no cache, the Editor imports every asset in the project before it builds anything: textures recompressed for the target, meshes converted, audio transcoded, shaders parsed. On a large project this single phase can outweigh the build itself. A warm Library cache removes almost all of it, which is why the cache key and the target-specific prefixes above matter more than any other tuning on this page. Watch the cache hit rate in the job log; a key that turns over on every commit gives you a cold import every run. **2\. Shader compilation.** Shader variants multiply across every keyword combination the project enables, and the Editor compiles them during the player build. This is the phase that actually uses cores, so it drives the choice between the 16x and 32x labels. On the project side, the levers are keyword stripping in the graphics settings, shader variant collections that limit what gets built, and keeping `Library/ShaderCache` in the cached paths so a run recompiles only what changed. **3\. Platform switching.** Switching the build target inside one job forces a reimport of every asset that has a target-specific representation, which means paying most of the cold import again in the middle of a job that already looked warm. A matrix with one job per target avoids it entirely, at the cost of one cache restore per job. Under per-minute billing the parallel shape costs the same total and finishes sooner, so it wins on both counts. **4\. Artifact upload.** A player build plus symbol files and addressable bundles runs to several gigabytes, and `actions/upload-artifact` sits at the end of the job where everyone is waiting on it. Upload only what a downstream job or a QA build actually consumes, set `compression-level: 0` for payloads that are already compressed so the step stops burning CPU for no size reduction, put symbols in a separate artifact with a shorter retention window, and push bundles straight to your own storage instead of routing them through the artifact service. Telling these apart is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so a job stuck on single-threaded import looks visibly different from one saturating 16 vCPUs through shader compilation. The Action Debugger pauses a workflow and opens a session on the live runner, which is the quickest way to check what is actually on disk when a job dies during import. ## [Proof](#proof)Copy Anchor Link The migration cost is the label. The Windows images carry the same tooling as GitHub-hosted Windows images, so the Editor install script, the MSVC toolchain that IL2CPP calls, and every marketplace action behave the same way after the `runs-on` line changes. ## [FAQ](#faq)Copy Anchor Link **Which WarpBuild Windows runner size should a Unity project use?** Start player builds on `warp-windows-latest-x64-16x` (16 vCPU, 64GB, 256GB SSD, $0.064 per minute). Keep edit-mode test jobs on `warp-windows-latest-x64-8x` at $0.032 per minute. Move to `warp-windows-latest-x64-32x` at $0.128 per minute when shader compilation and the IL2CPP native compile keep all 16 vCPUs busy for most of the build. **Can the WarpBuild cache hold the Unity Library folder on Windows?** No. The WarpBuild cache is available on Linux runners and is not supported on Windows runners. Windows jobs cache `Library` with `actions/cache` against GitHub's cache service, which caps a repository at 10 GB. Android, WebGL, and Linux player jobs can move to Linux labels where the WarpBuild cache applies. **How much disk does a Unity job need on a runner?** Every WarpBuild Windows size carries a 256GB SSD, against 150GB on the Linux x64 labels. A Unity job holds the Editor install, the checkout with its source assets, a Library folder that is often larger than the source assets, the shader cache, IL2CPP and Burst intermediate output, and the built player. Runner storage is ephemeral and is deleted when the runner terminates. **What does a Unity pipeline cost against GitHub-hosted Windows runners?** `warp-windows-latest-x64-16x` costs $0.064 per minute against $0.082 per minute for the 16-core Windows larger runner at the same 16 vCPU and 64GB shape, which is 22 percent lower list price (GitHub pricing, checked on 2026-08-13). --- # Unreal Engine Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/unreal-engine-github-actions Description: Unreal Engine builds on GitHub Actions run on WarpBuild Windows runners at 16 and 32 vCPU with 256GB SSDs, priced at $0.064 and $0.128 per minute. # Unreal Engine Builds on GitHub Actions Unreal Engine builds on GitHub Actions run on WarpBuild Windows runners at 16 and 32 vCPU with 256GB SSDs, priced at $0.064 and $0.128 per minute. Last verified: 2026-08-13 An Unreal Engine build on GitHub Actions needs a Windows runner with enough cores to feed the shader compile workers and the cook commandlet, enough disk for the engine plus the content tree, and a derived data cache that survives between jobs. WarpBuild provides Windows runners from 4 to 32 vCPU, every size carrying a 256GB SSD, at $0.016 to $0.128 per minute, selected by pointing `runs-on` at a label such as `warp-windows-latest-x64-32x`. The sections below give a working pipeline with the DDC directory pinned and UnrealBuildTool invoked directly, the per-minute arithmetic against GitHub-hosted Windows larger runners, a disk budget for the 256GB working set, and the four bottlenecks that dominate Unreal pipelines. ## [Overview](#overview)Copy Anchor Link An Unreal pipeline on GitHub Actions spends its minutes in five places: syncing the project and its binary content, compiling the engine and game targets through UnrealBuildTool, compiling shaders, cooking content for the target platform, and staging or packaging the result. The first is network bound. Compilation and shader work scale across cores. Cook is bound by the derived data cache and by disk. Staging is large sequential I/O. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. Unreal editor and Win64 targets need the Windows fleet, which comes in three image families at four sizes each, from 4 to 32 vCPU, all with 256GB SSDs: Windows Server 2022 under `warp-windows-latest-x64-`, Windows Server 2025 under `warp-windows-2025-x64-`, and Windows Server 2025 with Visual Studio 2026 under `warp-windows-2025-vs2026-x64-`. The full matrix lives in the [cloud runners documentation](/docs/ci/cloud-runners) and on the [Windows runner hub](/runners/windows). Every runner is an ephemeral VM, freshly allocated for the job and destroyed afterward. The images carry the same tooling as GitHub-hosted Windows images, so the MSVC toolchain and the Windows SDK are present without setup steps. The engine itself is not part of any hosted image, so the job either fetches a prebuilt engine from your own artifact storage or builds the engine from source once and reuses that artifact. Two platform limits shape every design decision on this page. WarpBuild caching is not supported on Windows runners, so the derived data cache moves through `actions/cache` or through a DDC endpoint you host. Snapshot runners are supported on Ubuntu cloud runners only, so a warm-disk snapshot of an engine build is unavailable on the Windows path. ## [Configuration](#configuration)Copy Anchor Link Here is a Windows pipeline on `warp-windows-latest-x64-32x`. It fetches a prebuilt engine, checks out the project with Git LFS content, keeps the local DDC on the workspace drive, and runs UnrealBuildTool and BuildCookRun in the same job so the DDC stays warm between the two. Copy Text ``` name: unreal on: push: branches: [main] pull_request: env: UE_ROOT: C:\UnrealEngine UPROJECT: ${{ github.workspace }}\Game\Game.uproject DDC_DIR: ${{ github.workspace }}\DerivedDataCache jobs: build-cook-stage: runs-on: warp-windows-latest-x64-32x timeout-minutes: 240 steps: - uses: actions/checkout@v4 with: lfs: true - name: Fetch the prebuilt engine shell: pwsh run: | Invoke-WebRequest -Uri "$env:ENGINE_ARTIFACT_URL" -OutFile engine.zip Expand-Archive engine.zip -DestinationPath "$env:UE_ROOT" env: ENGINE_ARTIFACT_URL: ${{ secrets.ENGINE_ARTIFACT_URL }} - uses: actions/cache@v4 with: path: ${{ env.DDC_DIR }} key: ddc-win64-${{ github.sha }} restore-keys: | ddc-win64- - name: Build the editor and game targets shell: pwsh run: | New-Item -ItemType Directory -Force -Path "$env:DDC_DIR" | Out-Null ${env:UE-LocalDataCachePath} = $env:DDC_DIR & "$env:UE_ROOT\Engine\Build\BatchFiles\Build.bat" ` GameEditor Win64 Development -Project="$env:UPROJECT" -WaitMutex & "$env:UE_ROOT\Engine\Build\BatchFiles\Build.bat" ` Game Win64 Development -Project="$env:UPROJECT" -WaitMutex - name: Cook, stage, and package shell: pwsh run: | ${env:UE-LocalDataCachePath} = $env:DDC_DIR & "$env:UE_ROOT\Engine\Build\BatchFiles\RunUAT.bat" BuildCookRun ` -project="$env:UPROJECT" ` -platform=Win64 -clientconfig=Development ` -nop4 -utf8output -nocompileeditor ` -cook -stage -pak -archive ` -archivedirectory="${{ github.workspace }}\Archive" ``` Four details carry the build. **Pin the local DDC inside the workspace.** By default Unreal writes its local derived data cache under the user profile, which puts it outside the paths `actions/cache` is told about and can put it on a different volume from the checkout. Setting `UE-LocalDataCachePath` to a workspace directory gives the cache step one deterministic path and keeps DDC reads and writes on the same 256GB SSD as the project. PowerShell needs the brace form, `${env:UE-LocalDataCachePath}`, because the variable name contains a hyphen. **Cache the DDC with `actions/cache`, and size it against the 10 GB limit.** WarpBuild caching covers Linux runners, so Windows jobs use GitHub's cache service. Entries count against GitHub's 10 GB per-repository cache limit, and a full-project DDC for a content-heavy title exceeds that ceiling. Cache the DDC for the subset of platforms a pull request actually cooks, evict aggressively with a per-commit key and a broad `restore-keys` prefix, and move the whole-project DDC to a shared endpoint once it stops fitting. **Run build and cook in one job.** UnrealBuildTool output under `Intermediate` and `Binaries`, and the DDC entries the editor produces while compiling shaders, are all local to the VM. Splitting build and cook into two GitHub Actions jobs pays VM boot, engine fetch, and DDC restore twice, and the second job starts with a cold DDC that the first job just populated. Keep them in one job, as above, and reserve separate jobs for genuinely parallel work such as per-platform cooks. **Check out LFS content deliberately.** `lfs: true` pulls every LFS object in the repository. On large content trees, restrict what a pull request job pulls with sparse checkout on the content directories the build needs, and let the nightly full-cook job pull everything. The default shell on Windows runners is PowerShell, so backslash paths and backtick line continuations work as written. Pin the image family with `warp-windows-2025-vs2026-x64-32x` when the engine build requires the Visual Studio 2026 toolset. ## [Sizing](#sizing)Copy Anchor Link The Windows catalog, with per-minute rates from the [WarpBuild pricing page](/pricing): | Runner label | OS | vCPU | Memory | Storage | Price per minute | | --------------------------- | ------------------- | ---- | ------ | --------- | ---------------- | | warp-windows-latest-x64-4x | Windows Server 2022 | 4 | 16 GB | 256GB SSD | $0.016 | | warp-windows-latest-x64-8x | Windows Server 2022 | 8 | 32 GB | 256GB SSD | $0.032 | | warp-windows-latest-x64-16x | Windows Server 2022 | 16 | 64 GB | 256GB SSD | $0.064 | | warp-windows-latest-x64-32x | Windows Server 2022 | 32 | 128 GB | 256GB SSD | $0.128 | The `warp-windows-2025-x64-` and `warp-windows-2025-vs2026-x64-` labels come in the same four sizes at the same rates. Windows 2 vCPU runners were removed on June 8, 2026, so 4 vCPU is the smallest Windows size. Unreal work sorts into three sizing tiers. **Editor and game target compiles** keep every core busy while UnrealBuildTool schedules translation units, then narrow to link steps that use one core each. `warp-windows-latest-x64-16x` is the working default for pull request compile checks. **Cook plus shader compilation** is where 32 vCPU pays. The cook commandlet fans work out to shader compile workers, one process group per available core by default, and a cold DDC turns the cook into a shader compilation job. `warp-windows-latest-x64-32x` with 128 GB of memory holds the worker set without paging. **Packaging and archive steps** are sequential I/O over pak files and staged content. Cores sit idle here, so a workflow that separates archive from cook can drop the archive job to `warp-windows-latest-x64-8x`, provided the artifacts move through storage rather than through the local disk of a second machine. ### [List-price arithmetic against GitHub-hosted Windows runners](#list-price-arithmetic-against-github-hosted-windows-runners)Copy Anchor Link GitHub publishes per-minute list prices for its Windows larger runners. The two sizes that matter for Unreal: | WarpBuild runner | Shape | WarpBuild per minute | GitHub-hosted equivalent | GitHub per minute | Arithmetic | Lower list price | | --------------------------- | --------------- | -------------------- | ----------------------------- | ----------------- | ----------------------- | ---------------- | | warp-windows-latest-x64-16x | 16 vCPU, 64 GB | $0.064 | 16-core Windows larger runner | $0.082 | (0.082 - 0.064) / 0.082 | 22 percent | | warp-windows-latest-x64-32x | 32 vCPU, 128 GB | $0.128 | 32-core Windows larger runner | $0.162 | (0.162 - 0.128) / 0.162 | 21 percent | GitHub rates are from the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. Both sides carry the same vCPU and memory shape. Take a title whose main-branch cook and package pipeline consumes 12,000 Windows runner-minutes per month on 32 vCPU machines: | Line item | Rate | Volume | Monthly cost | | ------------------------------------------- | ----------------- | -------------- | ------------ | | GitHub-hosted 32-core Windows larger runner | $0.162 per minute | 12,000 minutes | $1,944.00 | | warp-windows-latest-x64-32x | $0.128 per minute | 12,000 minutes | $1,536.00 | The WarpBuild total is $1,536.00 against $1,944.00 for the same minutes, a difference of $408.00 per month. The model holds minutes equal on both sides. `actions/cache` uses GitHub's cache service, which carries no per-operation charge, so runner minutes are the whole WarpBuild bill in this shape. Full rates for every size and platform are on the [pricing page](/pricing). ### [The 256GB working set](#the-256gb-working-set)Copy Anchor Link Every Windows size ships the same 256GB SSD, so the budget is fixed and the question is what shares it. | What lands on the runner disk | Where it comes from | Can it move off the runner | | ------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------- | | Engine binaries and engine DDC | Fetched per job from your artifact storage | Partly, by fetching only the target platforms you cook | | Project checkout and Git LFS content | actions/checkout with lfs: true | Yes, with sparse checkout scoped to the directories the job builds | | Intermediate and Binaries | Produced by UnrealBuildTool | No, the toolchain writes them locally | | Local DDC | Produced by the editor and the cook commandlet | Yes, with a shared DDC endpoint in place of the local path | | Saved/Cooked, Saved/StagedBuilds, pak files | Produced by BuildCookRun | Yes, archive to storage inside the job and delete before the next stage | A single-platform Win64 cook of a mid-size project fits this budget with room to spare. A run that cooks several platforms in one job, keeps every staged build on disk, and holds a whole-project DDC alongside them is the shape that runs the disk out. Two fixes work: archive and delete cooked output between platforms in the same job, and move the DDC off the local disk. Teams whose content tree needs local NVMe throughput beyond what the shared budget allows run BYOC instead. BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. Windows BYOC runner sets are available on AWS and Azure at $0.002 per minute of orchestration, so the instance type and its disk configuration are yours to pick. One caveat matters here: automatic local NVMe detection and mounting is documented for Linux images only, and the [local SSD documentation](/docs/ci/features/local-ssd) lists Windows and macOS as unsupported. A Windows BYOC instance with instance-store NVMe therefore needs the volume prepared in your own image. The [hosted and BYOC comparison](/guides/hosted-vs-byoc-runners) covers the tradeoff and cost model in full. ## [Bottlenecks](#bottlenecks)Copy Anchor Link Four bottlenecks account for most slow Unreal pipelines on GitHub Actions. **Derived data cache misses.** The DDC holds the compiled shaders, cooked textures, and processed meshes that the editor and the cook commandlet would otherwise regenerate. On an ephemeral runner with an empty DDC, every cook regenerates the whole set. A cache key tied to the commit gives an exact hit only for a rerun of the same commit, so the `restore-keys` prefix matters more than the key: it pulls the nearest previous DDC and leaves the cook regenerating only what changed. When the DDC outgrows GitHub's 10 GB per-repository cache limit, a hosted shared DDC endpoint configured through the backend graph is the next step, and it removes the restore and save minutes from the job as well. **Shader compilation.** Shader permutations are the largest single generator of derived data in most projects. Material changes, engine upgrades, and new target platforms all invalidate wide swaths of the permutation set at once, which is why the first build after an engine bump costs far more than the diff suggests. Shader compile workers scale across cores, so this is the phase that justifies 32 vCPU, and it is also the phase that a warm DDC removes almost entirely. **Cook time on large content trees.** The cook commandlet walks every referenced asset, converts it for the target platform, and writes the result. Its cost tracks the number of referenced assets, so asset registry hygiene shows up directly in the bill: unreferenced content pulled in by a stray hard reference gets cooked with everything else. Cooking one platform per job, with chunked pak output, keeps each job's asset set bounded. **Disk throughput.** Unreal's build and cook phases are heavy on small-file I/O against `Intermediate`, the DDC, and `Saved/Cooked`, all on the same 256GB SSD. When a job stalls with cores idle and disk queues deep, the fix is to reduce what shares the volume rather than to add cores: archive staged output early, scope the LFS checkout, and keep the DDC either warm or remote. Telling these apart is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so a cook stuck on single-threaded asset iteration looks visibly different from a shader compile saturating all 32 cores. The Action Debugger opens a shell on a failing job, which is the fastest way to inspect a cook log and a DDC directory in place instead of guessing across reruns. ## [Proof](#proof)Copy Anchor Link The runner-side agent is open source at [github.com/WarpBuilds/warpbuild-agent](https://github.com/WarpBuilds/warpbuild-agent), and the [cloud runners documentation](/docs/ci/cloud-runners) publishes the full Windows catalog with prices, image contents, and change history for independent verification. Sibling coverage for the other major engine is on the [Unity builds page](/solutions/unity-github-actions), and the caching mechanics behind the MSVC side of an engine build are in the [MSBuild caching guide](/guides/msbuild-caching-github-actions). ## [FAQ](#faq)Copy Anchor Link **Which WarpBuild Windows runner size fits an Unreal Engine build?** Compile-only jobs that run UnrealBuildTool and nothing else fit `warp-windows-latest-x64-16x` at $0.064 per minute. Jobs that cook content and compile shaders in the same run belong on `warp-windows-latest-x64-32x` at $0.128 per minute, because the cook commandlet and the shader compile workers both scale across cores. **Can the WarpBuild cache hold the Unreal derived data cache on Windows?** No. WarpBuild caching is not supported on Windows runners. Cache the local DDC directory with `actions/cache`, which stores entries in GitHub's cache service under the 10 GB per-repository limit, or point the DDC backend graph at a shared DDC endpoint you host. **Is 256GB of runner disk enough for an Unreal project?** Every WarpBuild Windows size ships a 256GB SSD. The engine build, the Git LFS checkout, `Intermediate` and `Binaries`, the local DDC, and the staged output all land on that one disk, so archive cooked output to storage inside the job and delete it before the next stage. **Can Unreal builds run on BYOC runners in our own cloud account?** Yes. BYOC runs on AWS, GCP, and Azure, and Windows BYOC runner sets are available on AWS and Azure at $0.002 per minute of orchestration. Automatic local NVMe mounting is documented for Linux images only, so Windows BYOC instances do not get it. --- # Vue Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/vue-github-actions Description: Cache the package manager store beside the Vite cache directory, run type checks and component tests as separate steps, and size warp- runners for vite build. # Vue Builds on GitHub Actions Cache the package manager store beside the Vite cache directory, run type checks and component tests as separate steps, and size warp- runners for vite build. Last verified: 2026-08-13 To build a Vue application on GitHub Actions, cache the package manager store and the Vite cache directory, run type checking and component tests as their own steps, and put the job on a 4 to 8 vCPU runner label. A default workflow reinstalls every dependency on a fresh machine and then runs a single-threaded Rollup build on the smallest available box, which is where most of the wall time goes. This page covers the cache configuration for npm, pnpm, and yarn alongside the bundler cache directory, a workflow file that runs component tests and a production build on a `warp-` label, the sizing call between 4, 8, and 16 vCPU, and the three bottlenecks that dominate Vue pipelines. ## [Overview](#overview)Copy Anchor Link Every GitHub Actions job starts on a fresh virtual machine, so nothing written by the previous run is on disk. For a Vue repository that means three directories start empty: the package manager store that the install reads from, the Vite cache directory that holds pre-bundled dependencies, and the webpack filesystem cache in Vue CLI projects. The build itself has three distinct phases, and they respond to different levers. `vue-tsc` type checks the single-file components on one thread. `vite build` hands the module graph to Rollup, which walks and transforms it on one thread, then minifies the output chunks with esbuild across every core it sees. Component tests written with `@vue/test-utils` run through Vitest, which spawns a worker pool sized from the reported CPU count. Vue work belongs on the Linux x64 sizes, and moving an existing job across is a one-line change to `runs-on`. WarpBuild Cache is available on all WarpBuild runners and is enabled by default, per the [caching documentation](/docs/ci/features/caching), so the cache steps below need no extra setup. One boundary before the configuration. This page covers the build and component test path for a single-package Vue application. A full browser suite that boots the built application and drives it end to end has a different worker model and a different browser install path, and that configuration lives on the [Playwright test suites on GitHub Actions](/solutions/playwright-github-actions) page. ## [Configuration](#configuration)Copy Anchor Link Three directories are worth carrying between runs, and they are written by different tools: | Directory | Written by | Key inputs | | ------------------------------------------- | ---------------------------------------------- | ---------------------------- | | \~/.npm, pnpm store path, or yarn cache dir | the install command | lockfile hash | | node\_modules/.vite | Vite dependency pre-bundling, read by Vitest | lockfile plus vite.config.\* | | node\_modules/.cache | webpack 5 filesystem cache in Vue CLI projects | lockfile plus vue.config.\* | The store is handled by [WarpBuilds/setup-node](https://github.com/WarpBuilds/setup-node), a drop-in replacement for `actions/setup-node` whose `cache` input accepts `npm`, `yarn`, or `pnpm` and routes the entries through WarpBuild Cache. The full input list for that action and its siblings is in the [setup actions documentation](/docs/ci/features/caching/setup-actions). The bundler directory needs its own cache step: Copy Text ``` name: vue on: push: branches: [main] pull_request: jobs: build: runs-on: warp-ubuntu-latest-x64-4x steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: WarpBuilds/setup-node@v6 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Restore Vite cache directory uses: WarpBuilds/cache@v1 with: path: node_modules/.vite key: ${{ runner.os }}-vite-${{ hashFiles('**/pnpm-lock.yaml', 'vite.config.*') }} restore-keys: | ${{ runner.os }}-vite- - run: pnpm run type-check - run: pnpm vitest run --environment jsdom --maxWorkers=4 - run: pnpm vite build - uses: actions/upload-artifact@v4 with: name: dist path: dist ``` Two details in that file decide whether the cache earns its keep. The key hashes the lockfile and the Vite config together, because a config edit changes what gets pre-bundled as surely as a dependency bump does. The `restore-keys` prefix underneath is what keeps an automated dependency update cheap: when the exact key misses, the action restores the most recent entry with the same prefix and the pre-bundle step only redoes the packages that changed. `vite build` runs Rollup and resolves dependencies from `node_modules` directly, so the restored `node_modules/.vite` directory pays off in the Vitest step rather than the build step. Keep that in mind when reading timings: a warm bundler cache shortens component tests, and a warm store shortens the install. Vue CLI projects sit on webpack 5, which writes its filesystem cache under `node_modules/.cache`. Swap the path and the key inputs, and the rest of the step is identical: Copy Text ``` - name: Restore webpack filesystem cache uses: WarpBuilds/cache@v1 with: path: node_modules/.cache key: ${{ runner.os }}-vue-cli-${{ hashFiles('**/pnpm-lock.yaml', 'vue.config.*') }} restore-keys: | ${{ runner.os }}-vue-cli- ``` Two behaviors from the caching documentation matter before you rely on either entry. The cache is scoped to key, version, and branch, so seed it on `main` and let branch builds reach it through `restore-keys`. Entries expire 7 days after last use, so an active repository stays warm while an abandoned branch stops consuming storage on its own. Cache usage is metered at $0.20 per GB-month of storage and $0.0001 per write or restore operation. A Vue repository with a 1.2GB store plus bundler directory and 3,000 operations a month adds about $0.54 to the bill. ## [Sizing](#sizing)Copy Anchor Link WarpBuild Linux x64 runners carry 4GB of memory per core across the range documented under [cloud runners](/docs/ci/cloud-runners). Four sizes cover Vue work: | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64GB | 150GB SSD | $0.032 | Size each phase for what it can use. **Install** is disk and network bound, and extra cores change little past 4 vCPU once the store is warm. **Type checking** runs `vue-tsc` on one core, so memory is the constraint that bites first; the 16GB on 4x covers most applications, and a heap failure is the signal to move that job to 8x. **Production build** splits: the Rollup graph walk holds one thread, and esbuild minification uses the rest, so a large application with many chunks sees a return on 8x while a small one does not. **Component tests** scale with the worker pool, which is the phase where 8x and 16x pay for themselves on a wide suite. Set `--maxWorkers` to the vCPU count of the label rather than leaving it to the default, so the worker count and the machine agree. ### [Worked cost model](#worked-cost-model)Copy Anchor Link GitHub publishes per-minute list prices in its [Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the 4-core Linux larger runner is $0.012 per minute. Take a Vue application running 1,200 pull-request runs a month at 6 minutes each with a warm store, which is 7,200 runner minutes: | Scenario | Rate | Monthly minutes | Monthly cost | | ----------------------------------------------- | ---------- | --------------- | ------------ | | GitHub-hosted Linux larger runner, 4 vCPU | $0.012/min | 7,200 | $86.40 | | warp-ubuntu-latest-x64-4x, 4 vCPU | $0.008/min | 7,200 | $57.60 | | Cache storage and operations (1.2GB, 3,000 ops) | see above | n/a | $0.54 | Stated as list-price arithmetic: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13. Rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link **Cold installs.** Without a restored store, every job pulls the full dependency tree from the registry before the first component test starts, and a Vue application with a component library and a test stack carries a few thousand entries. The fix is the cache configuration above. Verify it from the install log: a warm pnpm install reports most packages as reused from the store instead of fetched. **Single-threaded bundling.** Rollup walks and transforms the module graph on one thread, so a 16 vCPU runner does not shorten that phase. Buying cores to fix a slow `vite build` works only when minification dominates, which shows up as a build that gets faster on 8x and then flattens. The lever that does move the graph walk is graph size: trim barrel files that pull whole libraries into the entry, and check whether a heavy dependency is being bundled when it could be external. **Browser test memory.** Component tests that drive a real browser instead of jsdom start a browser context per worker, and each context holds its own renderer process. With the pool sized from the CPU count, a 16 vCPU runner opens enough contexts to exhaust memory before it exhausts cores. Pin `--maxWorkers` below the core count on browser-backed suites, or keep those specs on 8x where 32GB gives each worker room. When a job is slow and the cause is unclear, WarpBuild CI observability shows system metrics from the runner agent correlated with GitHub Actions job logs, which separates a CPU-bound minify phase from an install stuck on the network. The Action Debugger pauses a workflow and opens an SSH session on the runner, so you can list `node_modules/.vite` and confirm the restore landed. ## [Proof](#proof)Copy Anchor Link Every cost and performance number on this page carries a source link and a checked-on date, and the same numbers appear on the [pricing page](/pricing), so the arithmetic is re-runnable rather than asserted. Public repositories running `warp-` labels are citable evidence, and reading a `runs-on` line takes a few seconds. The Trigger.dev TypeScript monorepo runs its end-to-end matrix on `warp-ubuntu-latest-x64-4x` and `warp-windows-latest-x64-8x`, which you can read in [triggerdotdev/trigger.dev's e2e.yml](https://github.com/triggerdotdev/trigger.dev/blob/main/.github/workflows/e2e.yml) (checked on 2026-08-13). If the same repository installs dependencies for other jobs, the store configuration is covered on the [Node.js on GitHub Actions](/solutions/nodejs-github-actions) page, and the equivalent decisions for a different single-file component toolchain are on the [Angular on GitHub Actions](/solutions/angular-github-actions) page. For splitting a wide suite across labels, see the guide to [sizing runners for Node test suites](/guides/sizing-runners-for-node-test-suites). ## [FAQ](#faq)Copy Anchor Link **Should I cache node\_modules or the Vite cache directory?** Neither replaces the other. Cache the package manager store so the install reads tarballs from local disk, and cache `node_modules/.vite` so the component test run reuses the pre-bundled dependencies. Skip `node_modules` itself, because the install command rebuilds it and a restored copy breaks when the Node version changes. **What runner size does a Vue production build need?** warp-ubuntu-latest-x64-4x covers most single-package Vue applications, since the Rollup module graph walk runs on one thread and only minification spreads across cores. Move the component test job to 8x when the tests drive a real browser, and keep type checking on 4x. **What changes when a Vue workflow moves to WarpBuild?** The `runs-on` label on each job, and `actions/cache` becomes `WarpBuilds/cache`. --- # WebAssembly Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/webassembly-github-actions Description: WebAssembly builds on GitHub Actions need the wasm target installed, a cached target directory, and a runtime for tests. Workflow YAML and runner rates inside. # WebAssembly Builds on GitHub Actions WebAssembly builds on GitHub Actions need the wasm target installed, a cached target directory, and a runtime for tests. Workflow YAML and runner rates inside. Last verified: 2026-08-13 A WebAssembly build on GitHub Actions is a cross-compile: the workflow installs a wasm target next to the host toolchain, compiles the dependency graph a second time into a separate target directory, and then runs the test suite through a wasm runtime instead of executing a native binary. Wall-clock time is set by two things, the core count during that second compile and whether the wasm target directory and the tool downloads survive between jobs. WarpBuild runs both halves on `warp-` labeled Linux runners billed per minute, with cache enabled by default. This page covers the toolchain setup and the exact cache paths for the wasm target, a workflow that builds a wasm artifact and tests it, sizing for the compile step with rates applied, and the failure modes that dominate wasm pipelines. ## [Overview](#overview)Copy Anchor Link A wasm pipeline has four phases, and each one wastes time differently on a cold machine. 1. Toolchain install. `rustup target add`, an `emsdk install`, or a Go toolchain that already ships the wasm targets. 2. Compile. The full dependency graph builds again for the wasm triple, into `target/` rather than the host directory. 3. Post-process. `wasm-bindgen` generates the JavaScript glue, `wasm-opt` from binaryen shrinks the module. 4. Test. A runtime executes the module, either `wasmtime` for WASI targets or a headless browser for `wasm32-unknown-unknown` with `wasm-bindgen-test`. Every GitHub Actions job starts on a fresh virtual machine, so each of those phases repeats from zero unless something restores its state. These are the directories worth carrying between runs: | Path | What it holds | Key on | Reused by | | --------------------------------------------------- | ---------------------------------------------------------- | --------------------------------- | ----------------------------- | | \~/.cargo/registry and \~/.cargo/git | Crate sources and the registry index | Cargo.lock hash | Every Rust job | | target/wasm32-unknown-unknown, target/wasm32-wasip1 | rlibs and wasm objects for the wasm triples | Cargo.lock plus toolchain version | Compile, test | | \~/.cargo/bin | Installed tool binaries: wasm-pack, wasm-bindgen, wasm-opt | Pinned tool versions | Compile, post-process | | \~/.cache/.wasm-pack | Binaries wasm-pack downloads on first use | wasm-pack version | Compile | | \~/emsdk and $EM\_CACHE | The Emscripten SDK plus its compiled sysroot ports | emsdk version | Compile (Emscripten projects) | | \~/.cache/go-build, \~/go/pkg/mod | Go build cache for GOOS=wasip1 and module downloads | go.sum hash | Compile, test (Go projects) | The wasm target directory is the entry people leave out. Cargo keys build artifacts by target triple, so a repository that runs host tests and a wasm build gets two independent artifact trees under `target/`, and restoring only the host one leaves the wasm compile completely cold. The background on why the second tree exists at all is on the [cross-compilation glossary page](/glossary/cross-compilation). Wasm work belongs on Linux x64: the toolchains are all Linux-native, and the [cloud runner catalog](/docs/ci/cloud-runners) lists Ubuntu 24.04 and 26.04 images from 2 to 32 vCPUs with 150GB SSDs, carrying the same tooling as GitHub-hosted images so a preinstalled headless Firefox is available for browser tests. ## [Configuration](#configuration)Copy Anchor Link This workflow builds a Rust crate to `wasm32-unknown-unknown`, optimizes the module, uploads it as an artifact, and runs both a browser suite and a WASI suite against the same cache entry. Copy Text ``` name: wasm on: push: branches: [main] pull_request: env: CARGO_TERM_COLOR: always jobs: build: runs-on: warp-ubuntu-latest-x64-8x steps: - uses: actions/checkout@v4 - run: rustup toolchain install stable --profile minimal - run: rustup target add wasm32-unknown-unknown wasm32-wasip1 - uses: WarpBuilds/rust-cache@v2 with: cache-provider: warpbuild shared-key: wasm save-if: ${{ github.ref == 'refs/heads/main' }} - name: Restore wasm tooling uses: WarpBuilds/cache@v1 with: path: | ~/.cargo/bin/wasm-pack ~/.cargo/bin/wasm-bindgen ~/.cache/.wasm-pack key: ${{ runner.os }}-wasm-tools-${{ hashFiles('rust-toolchain.toml') }} restore-keys: | ${{ runner.os }}-wasm-tools- - run: cargo install wasm-pack --locked --version 0.13.1 - run: wasm-pack build --release --target web --out-dir pkg - run: sudo apt-get install -y binaryen - run: wasm-opt -Oz pkg/*_bg.wasm -o pkg/module.wasm - uses: actions/upload-artifact@v4 with: name: wasm-pkg path: pkg test: runs-on: warp-ubuntu-latest-x64-4x env: CARGO_TARGET_WASM32_WASIP1_RUNNER: wasmtime steps: - uses: actions/checkout@v4 - run: rustup toolchain install stable --profile minimal - run: rustup target add wasm32-unknown-unknown wasm32-wasip1 - uses: WarpBuilds/rust-cache@v2 with: cache-provider: warpbuild shared-key: wasm - run: cargo install wasm-pack --locked --version 0.13.1 - run: wasm-pack test --headless --firefox - name: Install wasmtime run: | curl https://wasmtime.dev/install.sh -sSf | bash echo "$HOME/.wasmtime/bin" >> "$GITHUB_PATH" - run: cargo test --target wasm32-wasip1 ``` Four details carry most of the value here. **Add the targets before the cache step.** `WarpBuilds/rust-cache` folds the rustc version and the installed target list into its key, so `rustup target add` has to run first or the restore keys against a toolchain that does not match what compiles. **`shared-key: wasm` gives both jobs one entry.** The build job saves on `main`, the test job restores, and pull request branches restore from the main entry without writing their own. Without the shared key each job keys separately and compiles the wasm dependency graph twice per run. **Pin the tool version and cache the binary.** `cargo install wasm-pack` compiles the tool from source, which takes minutes on a cold machine. Caching `~/.cargo/bin/wasm-pack` keyed on the pinned version turns the second run into a no-op, and `~/.cache/.wasm-pack` holds what `wasm-pack` itself downloads. **`CARGO_TARGET_WASM32_WASIP1_RUNNER` makes `cargo test` work at all.** Cargo builds a wasm test binary that the runner cannot execute directly; the runner variable tells it to hand each binary to `wasmtime`. Browser targets take the other route through `wasm-pack test --headless`. For an Emscripten project, replace the tooling cache with the SDK and its port cache, and point `EM_CACHE` at a path inside the cached directory: Copy Text ``` - uses: WarpBuilds/cache@v1 with: path: | ~/emsdk ~/.emscripten_cache key: ${{ runner.os }}-emsdk-3.1.64 ``` The generated `pkg` directory belongs in an artifact rather than a cache, since it is a per-commit result a publish job consumes; the [build artifacts guide](/guides/build-artifacts-publishing-github-actions) covers retention and download on the consuming side. Both cache actions above are drop-in replacements for their upstream counterparts, and the full list of cache-enabled `setup-*` forks is in the [setup actions documentation](/docs/ci/features/caching/setup-actions). Cache storage meters at $0.20 per GB-month with each write or restore at $0.0001, so a wasm repository holding 6 GB across host and wasm target trees and running 4,000 operations a month adds about $1.60. ## [Sizing](#sizing)Copy Anchor Link The Linux x64 sizes that matter for wasm work, with per-minute rates from the [pricing page](/pricing): | Runner label | vCPU | Memory | Storage | Price per minute | | -------------------------- | ---- | ------ | --------- | ---------------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | **The compile step scales with cores, and it is the phase to size for.** Compiling to wasm is still rustc compiling crates, so the dependency graph fans out across vCPUs exactly as a host build does. The difference is volume: a repository that builds for both the host and a wasm triple runs the graph twice, which is why 8 vCPUs earn their rate on a wasm job in repositories where 4 was enough for the host build alone. Move to 16x when the CPU chart stays saturated through the whole compile rather than tailing off into the final few crates. **Post-processing is a smaller, partly parallel phase.** `wasm-opt` spreads its passes across functions, so it uses more than one core on a module with many functions, but a single very large function or a `-Oz` run on one module still leaves cores idle. Size for the compile and let the optimizer take what it takes. **Test jobs want fewer cores.** A headless browser suite is bounded by how many browser instances the job starts, and a `wasmtime` suite runs one module per test binary. 4x at $0.008 per minute is usually the right shape, which is why the workflow above splits the two jobs onto different labels rather than paying compile-sized rates for browser startup. GitHub publishes its own per-minute list prices on the [GitHub Actions minute multipliers reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers). Checked on 2026-08-13, the 8-core Linux larger runner is $0.022 per minute. A worked model for a wasm project running 900 pull request jobs a month at an average of 7 minutes on 8 vCPUs, which is 6,300 runner minutes: | Line item | Rate | Volume | Monthly cost | | ---------------------------------------- | --------------------- | ---------------- | ------------ | | GitHub-hosted Linux 8 vCPU larger runner | $0.022 per minute | 6,300 minutes | $138.60 | | warp-ubuntu-latest-x64-8x | $0.016 per minute | 6,300 minutes | $100.80 | | WarpBuild cache storage | $0.20 per GB-month | 6 GB | $1.20 | | WarpBuild cache operations | $0.0001 per operation | 4,000 operations | $0.40 | That is $102.40 against $138.60 for the same minutes, a difference of $36.20 per month. Stated as list-price arithmetic: warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **A cold wasm target tree.** This is the single largest cost in most wasm pipelines. Without a restored `target/wasm32-unknown-unknown`, every push recompiles every dependency for the wasm triple from source, including crates that have not changed in months. The `shared-key` configuration above is the fix; verify it by checking that the second run's compile log shows a small number of crates rather than the whole graph. **Tools rebuilt from source every run.** `cargo install wasm-pack` and `cargo install wasm-bindgen-cli` are full compiles. Pin the versions, cache the resulting binaries under `~/.cargo/bin`, and key on the pinned version rather than on the lockfile so an unrelated dependency bump does not throw the binaries away. **An empty Emscripten port cache.** The first `emcc` invocation on a fresh machine compiles the libc and libc++ ports it needs before touching your code. Caching `$EM_CACHE` alongside `~/emsdk` removes that from every run after the first, and pinning the emsdk version in the cache key keeps the two in step. **`wasm-opt` on large modules in pull requests.** Optimization cost grows with module size, and `-Oz` on a multi-megabyte module can rival the compile itself. Run the aggressive level on the main branch and release tags, and let pull requests build with the default `wasm-pack` optimization so review feedback stays quick. **Browser test startup and flake.** Headless browser suites pay a fixed startup cost per instance and fail differently from native tests, usually with a timeout rather than an assertion. Keeping them in their own job on a smaller label means a browser flake reruns a 4x job instead of an 8x compile. **Artifacts that grow quietly.** A `pkg` directory with the wasm module, JavaScript glue, and source maps can reach tens of megabytes per commit. Set an explicit retention on the upload step rather than accepting the default. Observability separates a compile saturating all cores from a job stalled downloading a toolchain, and the Action Debugger pauses the workflow and opens an SSH session on the runner so you can inspect the target directory on the machine itself. ## [Proof](#proof)Copy Anchor Link Public repositories running `warp-` labels are checkable evidence, and the check takes one click: open the workflow file and read the `runs-on` line. * [near/nearcore](https://github.com/near/nearcore/blob/master/.github/workflows/ci.yml) runs the NEAR protocol node's GitHub Actions pipeline on `warp-ubuntu-2404-x64-16x` for the heavy compile and test legs and `warp-ubuntu-2404-x64-8x` for the lighter ones, checked on 2026-08-13\. That is the same split this page recommends: compile-sized labels for the graph, smaller labels for everything else. * [FuelLabs/sway](https://github.com/FuelLabs/sway/blob/master/.github/workflows/ci.yml) builds and tests the Sway compiler toolchain on `warp-ubuntu-latest-x64-4x` jobs, checked on 2026-08-13\. A compiler repository that emits bytecode for a virtual machine target has the same two-tree cargo layout a wasm repository has. Moving a wasm workflow over is a one-line change per job to `runs-on`, plus the cache configuration above. Rates for every size and platform are on the [pricing page](/pricing), and the full Linux x64 catalog with images and aliases is on the [Linux x64 runner page](/runners/linux-x64). The compile-side detail behind the sizing here, including codegen units and link behavior, is covered in depth on the [Rust builds on GitHub Actions](/solutions/rust-github-actions) page. ## [FAQ](#faq)Copy Anchor Link **Which runner size should a WebAssembly build start on?** Start the compile job on `warp-ubuntu-latest-x64-8x` at $0.016 per minute. A wasm build compiles the whole dependency graph a second time for the wasm target, so the crate-level parallelism that fills 8 vCPUs on a host build fills them again here. Keep headless browser test jobs on `warp-ubuntu-latest-x64-4x` at $0.008 per minute, since those are bounded by browser instances rather than by cores. **What has to be cached for a Rust WebAssembly build?** Four things: the cargo registry and git checkouts under `~/.cargo`, the target directory including the `target/wasm32-unknown-unknown` subtree, the installed tool binaries under `~/.cargo/bin` such as `wasm-pack` and `wasm-bindgen`, and wasm-pack's own download directory at `~/.cache/.wasm-pack`. Emscripten projects add `~/emsdk` and the `EM_CACHE` directory instead of the last two. **Can a wasm test suite run on a GitHub Actions runner?** Yes, through a runtime. Set `CARGO_TARGET_WASM32_WASIP1_RUNNER` to `wasmtime` and `cargo test` executes each wasm test binary through wasmtime; for browser targets, `wasm-pack test --headless --firefox` drives the preinstalled headless browser on the Ubuntu image. Both run on a standard Linux x64 runner with no extra hardware. --- # Yocto and Embedded Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/yocto-github-actions Description: Yocto builds on GitHub Actions need 32 vCPUs plus a persistent sstate-cache and downloads directory. Snapshot runners carry both between runs on warp- labels. # Yocto and Embedded Builds on GitHub Actions Yocto builds on GitHub Actions need 32 vCPUs plus a persistent sstate-cache and downloads directory. Snapshot runners carry both between runs on warp- labels. Last verified: 2026-08-13 A Yocto build on GitHub Actions is bound by three directories that a fresh runner throws away every time: the sstate-cache, the downloads directory, and the local build tree. Run bitbake on `warp-ubuntu-latest-x64-32x` with a snapshot runner and all three are already on disk when the job starts, so the second build restores most tasks from shared state instead of rebuilding a cross toolchain from source. The switch is two changes. Point `runs-on` at a warp- label carrying a snapshot key, and move `DL_DIR`, `SSTATE_DIR`, and `TMPDIR` to a path outside `/tmp` so the snapshot captures them. The sections below give the working workflow, the sizing arithmetic against the 150GB disk, and the monthly cost model. ## [Overview](#overview)Copy Anchor Link A bitbake run for an ordinary embedded image walks several thousand tasks across several hundred recipes. It fetches upstream source, configures and compiles a cross toolchain, builds a kernel and a userspace, then packages and assembles a root filesystem image. A cold run does all of that work. A warm run does very little of it, because bitbake hashes the inputs of every task and restores the output from the shared state cache when the hash already exists there. That restore path is the whole game. The sstate-cache is what turns a multi-hour image build into a short one, and it lives on disk in `SSTATE_DIR`. Alongside it sits `DL_DIR`, the download cache holding upstream tarballs and git mirrors that bitbake would otherwise refetch from dozens of independent upstream hosts. GitHub-hosted runners work against both. The standard hosted Linux runner gives a private repository 2 vCPU and 8GB of memory, which is well under what a parallel bitbake run can use, and every job starts on a machine with an empty disk. Yocto work belongs on the Linux x64 line, where [the runner catalog](/docs/ci/cloud-runners) lists sizes from 2 to 32 vCPUs, each with a 150GB SSD, on Ubuntu 22.04, 24.04, and 26.04 images that carry the same tooling as GitHub-hosted runners. The host packages bitbake wants, gawk through zstd, install from apt exactly as they do on a developer workstation. Runners are ephemeral VMs, allocated per job and destroyed afterward. Snapshot runners are the answer to that for Yocto: capture the VM disk mid-workflow and boot later jobs from that image. Snapshot runners are one part of a product surface that also includes remote Docker builders, CI observability, an MCP server, and the Action Debugger. One boundary to know before you design around it. BYOC runs on AWS, GCP, and Azure, and snapshots are unsupported on all three. Snapshot labels apply to WarpBuild Cloud Ubuntu runners only. ## [Configuration](#configuration)Copy Anchor Link ### [Why a snapshot beats a cache archive here](#why-a-snapshot-beats-a-cache-archive-here)Copy Anchor Link A warm sstate-cache for a single machine target holds hundreds of thousands of small files. A cache archive has to tar that tree, compress it, upload it, then download and untar it on the next run, and it repeats that round trip for the downloads directory as well. Small-file tar throughput, and not network bandwidth, is what sets the floor on that step. A snapshot skips the round trip. The runner boots from the saved disk image in 45 to 60 seconds and the directories are simply there, along with the apt packages you installed, the poky checkout, and whatever survived in `build/tmp`. The label form is the runner label with the snapshot key appended after a semicolon: Copy Text ``` warp-ubuntu-latest-x64-32x;snapshot.key= ``` `snapshot.key=` boots from the existing snapshot for that alias when one exists and falls back to the base image when it does not. `snapshot.enabled=true` turns the feature on and always boots from the base image, which is the label you want on the job that produces a clean snapshot. Both forms are documented on the [snapshot runners page](/docs/ci/features/snapshot-runners). ### [The workflow](#the-workflow)Copy Anchor Link Copy Text ``` name: yocto on: push: branches: [main] pull_request: env: BUILD_ROOT: /home/runner/yocto MACHINE: qemuarm64 SNAPSHOT_ALIAS: yocto-qemuarm64 jobs: image: runs-on: >- ${{ github.ref == 'refs/heads/main' && 'warp-ubuntu-latest-x64-32x;snapshot.enabled=true' || 'warp-ubuntu-latest-x64-32x;snapshot.key=yocto-qemuarm64' }} timeout-minutes: 240 steps: - uses: actions/checkout@v5 with: path: layers/meta-acme - name: Install bitbake host dependencies run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ gawk wget git diffstat unzip texinfo gcc build-essential \ chrpath socat cpio python3 python3-pip python3-pexpect \ xz-utils debianutils iputils-ping python3-git python3-jinja2 \ python3-subunit zstd liblz4-tool file locales libacl1 - name: Prepare persistent directories run: | mkdir -p "$BUILD_ROOT"/{downloads,sstate-cache,build} df -h / - name: Sync poky run: | if [ ! -d "$BUILD_ROOT/poky/.git" ]; then git clone -b scarthgap https://git.yoctoproject.org/poky "$BUILD_ROOT/poky" fi git -C "$BUILD_ROOT/poky" fetch origin scarthgap git -C "$BUILD_ROOT/poky" checkout FETCH_HEAD - name: Write build configuration run: | cd "$BUILD_ROOT/poky" source oe-init-build-env "$BUILD_ROOT/build" > /dev/null cat > conf/auto.conf < /dev/null bitbake core-image-minimal - name: Prune before snapshot if: github.ref == 'refs/heads/main' run: | rm -rf "$BUILD_ROOT/build/tmp/work" "$BUILD_ROOT/build/tmp/work-shared" rm -rf "$HOME/.ssh" "$HOME/.aws" df -h / - name: Save snapshot if: github.ref == 'refs/heads/main' uses: WarpBuilds/snapshot-save@v1 with: alias: "yocto-qemuarm64" fail-on-error: true wait-timeout-minutes: 60 ``` Five details carry the design. **`BUILD_ROOT` lives outside `/tmp`.** The `/tmp` directory is cleaned on reboot and a snapshot boot is a reboot, so anything bitbake wrote under `/tmp` is gone on the next run. Yocto's default `TMPDIR` sits inside the build directory rather than in `/tmp`, and the `auto.conf` line above pins it there explicitly so no local override moves it. **Configuration goes in `conf/auto.conf`, not `local.conf`.** bitbake reads `auto.conf` automatically, and writing the file fresh each run keeps a snapshot-booted job from appending duplicate assignments to a `local.conf` that already survived from the previous build. **The main branch produces the snapshot, pull requests consume it.** The `runs-on` expression sends main to `snapshot.enabled=true`, so the authoritative snapshot is always built from the base image, and sends every pull request to `snapshot.key=yocto-qemuarm64`. **`snapshot-save` takes three inputs.** `alias` is required. `fail-on-error` defaults to true, and leaving it there means a snapshot that fails to capture fails the job instead of quietly leaving the next run cold. `wait-timeout-minutes` defaults to 30, and a Yocto disk of this size is worth raising to 60. **Snapshots expire after 15 days.** A repository with steady main-branch traffic refreshes the alias long before that. A quiet fork will find the alias gone, in which case the job still runs and boots from the base image, paying the cold build back on that run. ## [Sizing](#sizing)Copy Anchor Link The Linux x64 catalog against GitHub's published list prices, with rates from the [WarpBuild pricing page](/pricing): | Runner label | vCPU | Memory | Storage | Per minute | GitHub-hosted equivalent | GitHub per minute | Lower by | | -------------------------- | ---- | ------ | --------- | ---------- | ------------------------ | ----------------- | ---------- | | warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 | ubuntu-latest | $0.006 | 33 percent | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | 4-core larger runner | $0.012 | 33 percent | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | 8-core larger runner | $0.022 | 27 percent | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | 16-core larger runner | $0.042 | 24 percent | | warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 | 32-core larger runner | $0.082 | 22 percent | GitHub rates are from the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13\. The `ubuntu-latest` row is the private repository shape; GitHub gives public repositories a 4 vCPU shape at no charge. **Take the 32 vCPU size for image builds.** bitbake scales on two dials at once: `BB_NUMBER_THREADS` sets how many recipe tasks run concurrently, and `PARALLEL_MAKE` sets the make jobs inside each compile task. Setting both to 32 keeps the dependency graph wide enough to fill the cores through the long middle of a build, when hundreds of independent recipes are eligible at the same time. Memory is what makes that safe. Every Linux size carries 4 GB per vCPU, so the 32 vCPU runner has 128 GB. Toolchain, kernel, and browser-engine recipes are the ones that will find the ceiling. If a `do_compile` step dies with a killed compiler, lower `PARALLEL_MAKE` to `-j 16` and leave `BB_NUMBER_THREADS` at 32, which keeps bitbake's scheduler busy while cutting peak memory per task. **The 150GB SSD is the real constraint.** Yocto grows in three directions at once, and a build that runs out of disk fails late and confusingly. Budget the space before the first run: | Directory | Holds | Budget | | --------------------------------- | ---------------------------------- | ------ | | downloads (DL\_DIR) | upstream tarballs and git mirrors | 40 GB | | sstate-cache (SSTATE\_DIR) | task output archives | 45 GB | | build/tmp (TMPDIR) | sysroots, deploy artifacts, images | 45 GB | | host packages, checkout, headroom | apt, poky, layers | 20 GB | Four prunes hold that line before the snapshot is taken: 1. `INHERIT += "rm_work"` deletes each recipe's work directory as the recipe completes, which is the single largest saving available. 2. `rm -rf build/tmp/work build/tmp/work-shared` before the save step. Both are reproducible from sstate, so dropping them costs nothing on the next boot. 3. Trim duplicate sstate entries with the sstate cache management script in poky's `scripts` directory, which removes superseded task archives that accumulate as recipes change. 4. Delete stale images under `build/tmp/deploy/images` beyond the build you just published. Run `df -h /` immediately before `snapshot-save`. When a snapshot capture is going to be tight, the job log then says so plainly. ### [Worked cost model](#worked-cost-model)Copy Anchor Link Take an embedded team running 200 image builds a month, averaging 45 minutes each on 32 vCPU machines, holding two snapshot aliases alive for two machine targets: | Line item | Rate | Volume | Monthly cost | | ----------------------------------- | ------------------------ | ----------------------- | ------------ | | GitHub-hosted 32-core larger runner | $0.082 per minute | 9,000 minutes | $738.00 | | warp-ubuntu-latest-x64-32x | $0.064 per minute | 9,000 minutes | $576.00 | | Snapshot restore | $0.04 per job | 200 jobs | $8.00 | | Snapshot storage | $0.025 per snapshot-hour | 2 aliases for 720 hours | $36.00 | The WarpBuild total is $620.00 against $738.00 for the same minutes on GitHub-hosted larger runners, a difference of $118.00 per month. As a standalone rate comparison: warp-ubuntu-latest-x64-32x (32 vCPU, 128 GB) costs $0.064 per minute against $0.082 per minute for the 32-core Linux larger runner (32 vCPU, 128 GB): 22 percent lower list price. GitHub list price checked on 2026-08-13. The model holds minutes equal on both sides. Full rates for every size and platform are on the [pricing page](/pricing). ## [Bottlenecks](#bottlenecks)Copy Anchor Link Three failure modes account for most of the wall clock in a Yocto pipeline on GitHub Actions. **Cold sstate.** With an empty `SSTATE_DIR`, bitbake executes every task instead of restoring it, which means compiling binutils, gcc, glibc, and a kernel before your own recipes get a turn. This is the bottleneck the snapshot removes. Two things quietly reset a warm cache back to cold: a poky branch bump, since the task input hashes change with the metadata, and a distro or machine configuration change that shifts hashes across the whole graph. Both are worth landing on main deliberately so the snapshot refresh happens on one build rather than on every pull request in flight. **Source downloads.** A cold `DL_DIR` fetches from many independent upstream hosts, and a single slow or unreachable mirror stalls the fetch phase while the cores idle. The durable fixes are ordinary Yocto practice: keep `DL_DIR` inside the snapshot, set `BB_GENERATE_MIRROR_TARBALLS = "1"` so git checkouts become reusable tarballs, and point `PREMIRRORS` and `SSTATE_MIRRORS` at your own object storage so a snapshot miss falls back to a fast internal source rather than the public internet. **Single-threaded configure phases.** `do_configure` for an autotools recipe is a shell script that runs hundreds of compiler probes in sequence, and `PARALLEL_MAKE` does nothing for it. The build fills the machine only when enough recipes are in flight to overlap their serial phases, which is why `BB_NUMBER_THREADS` matters more than raw core count on the early and late stretches of a build. Watch for the shape where one core is pinned and 31 are idle: that is a narrow point in the dependency graph, and the fix is usually splitting an over-broad recipe dependency rather than buying a larger machine. Telling these apart is a measurement problem. WarpBuild's CI observability streams system metrics from the runner and correlates them with GitHub Actions job logs, so a stalled fetch reads as idle cores with network activity, a cold sstate reads as sustained full-core compile, and a configure choke point reads as one busy core. For a build that hangs or fails only in GitHub Actions, the Action Debugger pauses the workflow and opens an SSH session on the live runner, which is the fastest way to read `bitbake-diffsigs` output in place. ## [Proof](#proof)Copy Anchor Link Public repositories running warp- labels are the citable evidence. The [bitcoin/bitcoin GitHub Actions workflow](https://github.com/bitcoin/bitcoin/blob/master/.github/workflows/ci.yml) routes its Linux jobs across warp- labels sized per job, from `warp-ubuntu-latest-x64-2x` for lint up to `warp-ubuntu-latest-x64-16x` for the fuzz and MSan matrices, with compiler caches restored and saved between runs (checked on 2026-08-13). It is a long native compile matrix rather than a Yocto build, and the sizing and cache-persistence pattern is the same one this page applies to bitbake. Related reading: the [snapshot runner reference](/runners/snapshot) covers the label forms and lifecycle in full, the [incremental builds guide](/guides/incremental-builds-github-actions) generalizes the warm-state pattern beyond Yocto, the [C++ builds page](/solutions/cpp-github-actions) covers compiler-cache tuning for the native toolchain work underneath a BSP, and the [large repository guide](/guides/large-repository-github-actions) deals with checkout and layer trees that outgrow a default runner. ## [FAQ](#faq)Copy Anchor Link **Which runner size should a Yocto build on GitHub Actions use?** Start on `warp-ubuntu-latest-x64-32x` at $0.064 per minute, with `BB_NUMBER_THREADS` set to 32 and `PARALLEL_MAKE` set to `-j 32`. The 128GB of memory is what keeps 32 concurrent bitbake tasks alive through kernel and toolchain compiles. Drop `PARALLEL_MAKE` to `-j 16` if a large recipe gets OOM killed. **Is a snapshot runner better than a cache archive for the sstate-cache?** For Yocto, yes. A sstate-cache is hundreds of thousands of small files, so a cache archive pays tar, upload, download, and untar on every run. A snapshot boots the whole runner disk in 45 to 60 seconds with sstate-cache, downloads, `build/tmp`, and the installed host packages already in place. **Do snapshot labels work on BYOC, Windows, or macOS runners?** No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. A `snapshot.key` or `snapshot.enabled` label on a BYOC, Windows, or macOS runner is silently ignored and the job runs at full setup cost with no error. **Will a Yocto build fit inside the 150GB runner disk?** A core image fits with room to spare when you budget the space: roughly 40GB for downloads, 45GB for sstate-cache, 45GB for `build/tmp`, and 20GB for host packages and headroom. Inherit `rm_work` and delete `build/tmp/work` before snapshotting to hold that line. --- # Zig Builds on GitHub Actions | WarpBuild URL: https://www.warpbuild.com/solutions/zig-github-actions Description: Zig builds on GitHub Actions lose minutes to cold compiler and package caches. Cross-target matrix YAML on warp- labels, cache keys, sizing, and rates. # Zig Builds on GitHub Actions Zig builds on GitHub Actions lose minutes to cold compiler and package caches. Cross-target matrix YAML on warp- labels, cache keys, sizing, and rates. Last verified: 2026-08-13 A `zig build` on GitHub Actions spends its minutes in three places: installing the compiler, fetching the packages named in `build.zig.zon`, and compiling every target in the matrix. Install the toolchain with the cache-enabled setup action, persist both Zig cache directories through a cache step, and point `runs-on` at a `warp-` label sized for the heaviest target. ## [Overview](#overview)Copy Anchor Link Zig carries cross-compilation in the compiler. One host builds `x86_64-linux-gnu`, `x86_64-linux-musl`, `aarch64-macos`, and `x86_64-windows-gnu` from the same checkout, because the distribution ships the libc sources it needs and compiles them on demand rather than reaching for a per-target toolchain. The [cross-compilation glossary entry](/glossary/cross-compilation) covers the general shape of that tradeoff. The compiler pays for that with caching. Zig keeps two directories, and both start empty on a GitHub Actions runner because runners are ephemeral virtual machines, allocated fresh per job and destroyed at the end. | Directory | What it holds | Where it comes from | | ---------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | Global cache | Packages fetched from build.zig.zon under p/, plus compiled artifacts shared across projects | ZIG\_GLOBAL\_CACHE\_DIR, \--global-cache-dir, or global\_cache\_dir from zig env | | Local cache | The build graph, manifests, and object files for this checkout, named .zig-cache in the project root on Zig 0.14 and later | ZIG\_LOCAL\_CACHE\_DIR or \--cache-dir | | Compiler archive | The downloaded Zig tarball for the pinned version | Cached by [WarpBuilds/setup-zig](/docs/ci/features/caching/setup-actions) by default | WarpBuild runners register under `warp-` labels, so the matrix below maps a Zig target triple onto a machine that can execute the binary it produces. Labels, machine shapes, and per-minute rates are in the [cloud runners documentation](/docs/ci/cloud-runners). ## [Configuration](#configuration)Copy Anchor Link The workflow below builds and tests five targets, each on the label that runs its output natively, with both Zig cache directories persisted per target. Copy Text ``` name: zig on: push: branches: [main] pull_request: env: ZIG_VERSION: 0.14.1 ZIG_LOCAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-global-cache jobs: build: name: ${{ matrix.target }} runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: - target: x86_64-linux-gnu runner: warp-ubuntu-latest-x64-8x - target: x86_64-linux-musl runner: warp-ubuntu-latest-x64-8x - target: aarch64-linux-gnu runner: warp-ubuntu-latest-arm64-8x - target: aarch64-macos runner: warp-macos-15-arm64-6x - target: x86_64-windows-gnu runner: warp-windows-latest-x64-4x steps: - uses: actions/checkout@v5 - uses: WarpBuilds/setup-zig@v2 with: version: ${{ env.ZIG_VERSION }} - name: Restore Zig caches uses: WarpBuilds/cache@v1 with: path: | ${{ github.workspace }}/.zig-cache ${{ github.workspace }}/.zig-global-cache key: zig-${{ env.ZIG_VERSION }}-${{ matrix.target }}-${{ hashFiles('build.zig', 'build.zig.zon') }}-${{ github.sha }} restore-keys: | zig-${{ env.ZIG_VERSION }}-${{ matrix.target }}-${{ hashFiles('build.zig', 'build.zig.zon') }}- zig-${{ env.ZIG_VERSION }}-${{ matrix.target }}- - name: Build run: zig build -Dtarget=${{ matrix.target }} -Doptimize=ReleaseSafe --summary all - name: Test run: zig build test -Dtarget=${{ matrix.target }} --summary all - uses: actions/upload-artifact@v4 with: name: ${{ matrix.target }} path: zig-out/bin ``` Four details in that file carry the weight. **The setup action caches the toolchain.** [WarpBuilds/setup-zig](https://github.com/WarpBuilds/setup-zig) installs the pinned compiler and caches the downloaded archives by default, so the download happens once per version rather than once per job. It is a drop-in replacement for the upstream setup action and takes the same inputs; the full list of cache-enabled forks is in the [setup actions documentation](/docs/ci/features/caching/setup-actions). Pin the version explicitly, because Zig's language and standard library change between releases and an unpinned toolchain turns a red build into a mystery. **Both cache directories move into the workspace.** Setting `ZIG_LOCAL_CACHE_DIR` and `ZIG_GLOBAL_CACHE_DIR` at the workflow level puts them where a cache action can address them with a single entry, and the same two paths can be passed as `--cache-dir` and `--global-cache-dir` on any `zig build` invocation. `WarpBuilds/cache@v1` takes the same `path`, `key`, and `restore-keys` inputs as `actions/cache@v4`. **The key carries the target.** Artifacts compiled for `aarch64-macos` do nothing for an `x86_64-windows-gnu` job, so a shared key means five jobs racing to overwrite one entry. The key ends in `github.sha` and never matches exactly, which is intended: the two `restore-keys` prefixes fall back first to the newest cache for the same dependency set and then to the newest cache for the target. **The runner column exists for test execution.** Every target in that matrix cross-compiles fine on `warp-ubuntu-latest-x64-8x`. The native labels are there because `zig build test` runs the binary it just produced, and a foreign binary needs an emulator to run at all. When a pipeline only needs release artifacts, collapse the matrix onto one Linux x64 label and let the compiler do the work; the [release builds guide](/guides/release-builds-github-actions) covers packaging the outputs afterward. ## [Sizing](#sizing)Copy Anchor Link Rates below come from the [pricing page](/pricing), and the shapes from the runner catalog. | Runner label | vCPU | RAM | Storage | Price per minute | | --------------------------- | ---- | ----- | --------- | ---------------- | | warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 | | warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 | | warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 | | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | 150GB SSD | $0.012 | | warp-macos-15-arm64-6x | 6 | 14 GB | 120GB SSD | $0.08 | | warp-windows-latest-x64-4x | 4 | 16 GB | 256GB SSD | $0.016 | Compile-heavy Zig targets consume cores in two different ways, and they want different answers. A build graph with many independent artifacts, the shape a project reaches once it has several executables, a static library, and a test module, fans out across cores and rewards the 8x and 16x sizes. A single large module optimized with `ReleaseFast` or `ReleaseSmall` concentrates the work in codegen, where added cores stop helping and memory becomes the constraint instead. Every Linux size carries 4 GB per vCPU, so the 32 GB ceiling on the 8x label is the practical starting point and the 64 GB ceiling on the 16x label is the answer to an out-of-memory kill during optimization. The matrix is the wider lever. Five targets running as five concurrent jobs put wall clock at the slowest target rather than the sum of all five, which is worth more on a cross-target project than any single size upgrade. Applying the rates to a fixed pipeline makes the bill concrete. GitHub publishes its per-minute Actions rates in the [GitHub Actions billing reference](https://docs.github.com/en/billing/reference/actions-minute-multipliers), checked on 2026-08-13. | Setup | Shape | Rate per minute | 6 minute job | 3 x64 jobs, 400 runs per month | | ---------------------------------- | ------------- | --------------- | ------------ | ------------------------------ | | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | $0.096 | $115.20 | | GitHub-hosted 8-core larger runner | 8 vCPU, 32 GB | $0.022 | $0.132 | $158.40 | `warp-ubuntu-latest-x64-8x` (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price, GitHub list price checked on 2026-08-13\. At the same shape and the same wall clock, the three x64 entries of this matrix come to $43.20 a month less. The `aarch64-linux-gnu` entry runs natively on `warp-ubuntu-latest-arm64-8x` at $0.012 per minute, below the x64 rate at the same shape. The [Linux ARM64 runner page](/runners/linux-arm64) lists the sizes and image details. ## [Bottlenecks](#bottlenecks)Copy Anchor Link **A cold global cache on every run.** Without a persisted global cache, every job re-fetches and re-verifies each dependency in `build.zig.zon` and recompiles artifacts that no commit touched. The restore and save pair above fixes it, and keying on a hash of `build.zig.zon` means a dependency bump invalidates exactly the entry that went stale. **One cache entry shared across targets.** Five jobs writing one key evict each other for the whole run, and the next pipeline starts cold anyway. Put `matrix.target` in the key, as the workflow above does. **Foreign tests under emulation.** Building `aarch64-linux-gnu` on an x64 host is quick; running its test binary there means an emulator in the loop, and a suite that finishes in a minute natively can stretch well past that. Point the aarch64 entry at an ARM64 label and the emulator leaves the picture. **Debug-only matrices.** A target that builds cleanly in `Debug` can fail in `ReleaseFast`, since the optimization pipeline exercises different code paths and undefined behavior stops being checked. Build the release modes you ship on every pull request rather than saving them for tags. The observability Jobs report shows duration, queue time, CPU, and memory per job, which separates a target pinned by single-threaded codegen from one that saturates every core. For a target that fails only inside GitHub Actions, the Action Debugger pauses the workflow and opens an SSH session on the live runner, which beats bisecting through pushes. The same bottleneck analysis with different weights is on the [C++ builds page](/solutions/cpp-github-actions), where the link step rather than codegen is the serial tail. ## [Proof](#proof)Copy Anchor Link The rest is checkable in your own repository without taking anyone's word for it. Run the matrix unchanged on the labels you use today, then on the `warp-` labels above with both cache directories persisted, and compare job duration at P75 rather than a single run, since one run hides queue time and cache-miss variance. Every cost number on this page comes from a published rate with a source link and a checked-on date, and no build-time number is claimed for your project, because your cache hit rate and target count decide it. ## [FAQ](#faq)Copy Anchor Link ### [Which Zig cache directories should a GitHub Actions workflow persist?](#which-zig-cache-directories-should-a-github-actions-workflow-persist)Copy Anchor Link Two. The global cache, reported as `global_cache_dir` by `zig env` and set by `ZIG_GLOBAL_CACHE_DIR`, holds fetched packages under `p/` and artifacts shared across projects. The local cache, set by `ZIG_LOCAL_CACHE_DIR` and named `.zig-cache` in the project root on Zig 0.14 and later, holds the build graph and object files for that checkout. Restore and save both in one cache entry keyed on the Zig version, the target, and a hash of `build.zig` and `build.zig.zon`. ### [Do I need one runner per target to build Zig for several targets?](#do-i-need-one-runner-per-target-to-build-zig-for-several-targets)Copy Anchor Link No. Zig cross-compiles every target in a matrix from a single x64 Linux host, so `warp-ubuntu-latest-x64-8x` can produce Linux gnu, Linux musl, macOS, and Windows artifacts in one job. Native labels earn their place when the job also runs `zig build test`, because that step executes the produced binary and a foreign binary otherwise needs an emulator. ### [Which runner size should a Zig project start with?](#which-runner-size-should-a-zig-project-start-with)Copy Anchor Link Start the build job on `warp-ubuntu-latest-x64-8x` at $0.016 per minute, which gives 8 vCPU and 32 GB. Move a target to `warp-ubuntu-latest-x64-16x` when the CPU chart shows every core saturated through codegen, and keep the matrix wide rather than the machine large, since separate targets are independent jobs. --- # Get Started with WarpBuild | $50 in Free Credits | WarpBuild URL: https://www.warpbuild.com/start Description: More AI code means more commits and more CI. Run GitHub Actions 2x faster at half the cost with a one-line change. Redeem code START50 for $50 in free credits. //Get started # More AI code. More commits. More CI. Run GitHub Actions 2x faster at half the cost with a one-line change. Linux, macOS, and Windows on x86-64 and ARM64, with unlimited macOS concurrency. Get started with $50 in free creditsSTART50Redeem under [Settings > Billing](https://app.warpbuild.com/settings/billing) after signing up [Claim $50 in Credits](https://app.warpbuild.com)[Schedule Demo](https://cal.com/suryao/start) No credit card required | 2-minute setup //CI in the AI era ## AI agents write the code. CI is now the bottleneck. AI is writing more code than ever, which means GitHub Actions is running more than ever. WarpBuild keeps the feedback loop fast and the bill low. ### More AI code, more commits Agents open pull requests all day. Every one has to build, test, and merge through the same GitHub Actions pipeline. ### More commits, more CI Every commit runs a full pipeline. CI is now the bottleneck for you and for your coding agents, and the bill grows with it. ### Twice as fast at half the cost WarpBuild runners are priced at half of GitHub-hosted runners and finish jobs faster, so you save on both rate and runtime. //Getting started ## One line of YAML. $50 in credits. Three steps from this page to your first faster build. ``` - runs-on: ubuntu-latest+ runs-on: warp-ubuntu-latest-x64-2x ``` 1 ### Create your account Sign up at [app.warpbuild.com](https://app.warpbuild.com). No credit card required. 2 ### Redeem your credits Add promo code START50 under [Settings > Billing](https://app.warpbuild.com/settings/billing) for $50 in free credits. 3 ### Change one line of YAML Swap runs-on in your workflow file and your next build runs on WarpBuild. //WarpBuild CI ## The broadest OS and architecture support Drop-in GitHub Actions runner infrastructure: 2-10x faster builds, half the cost, faster caching, and BYOC for teams that want CI in their own cloud. [2x faster, 50% cheaperFaster CPUs, faster disks, and 3-10x faster caching than GitHub-hosted runners.](/compare/github)[One line of YAMLChange runs-on and you're done. No workflow rewrites, no migration project.](/docs/ci/quick-start)[Linux, macOS, and Windowsx86-64 and ARM64 runners across all major operating systems, including M4 Pro Macs.](/docs/ci/cloud-runners)[macOS with unlimited concurrencyRun as many Mac jobs at once as your agents create. No queuing behind your own jobs.](/products/ci/macos-runners)[Our cloud or yoursManaged runners in WarpBuild's cloud, or BYOC deployments in your own AWS, GCP, or Azure.](/docs/ci/byoc)[Enterprise readySOC2 Type 2 attested, ephemeral VMs, SSO, and 24x7 support with SLA guarantees.](/docs/ci/security) //Trusted at scale ## Trusted by teams at Braintrust, Raycast, OpenRouter, Comcast, Jane Street, and more Enterprise ready and SOC2 Type 2 attested, from solo open-source projects to thousand-developer organizations. ![Sonar](/images/customers/sonarqube.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Comcast](/images/customers/comcast.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Sky](/_next/image?url=%2Fimages%2Fcustomers%2Fsky-uk.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Braintrust](/images/customers/braintrust.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Adaptive](/_next/image?url=%2Fimages%2Fcustomers%2Fadaptive.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![OxCaml](/images/customers/oxcaml.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![DDEV](/images/customers/ddev.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Kintsugi](/images/customers/kintsugi-ai.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Bitcoin](/images/customers/bitcoin.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![LanceDB](/_next/image?url=%2Fimages%2Fcustomers%2Flancedb.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) Run times went down from \~25 mins to \~9 mins. Even bazel cache load time was faster by 30% Wow. These macOS runners are a LOT faster than GitHub's. Previous builds would take 30 mins, now they're done in 8! Tried out pretty much every provider in the market, and this one works the best out of the box //FAQ ## Frequently asked questions How do I redeem the $50 in free credits? Sign up at then add promo code START50 on the billing settings page at The credits are applied to your account immediately. Do I need a credit card to get started? No. Sign up, redeem your credits, and run your first builds without entering a card. Will my existing GitHub Actions workflows work? Yes. WarpBuild runners are fully compatible with GitHub Actions and come with the standard tooling preinstalled. The only change is the runs-on label in your workflow file. Which operating systems and architectures are supported? Linux, macOS, and Windows, on x86-64 and ARM64\. macOS runners have unlimited concurrency, so Mac jobs do not queue behind each other. Can we run WarpBuild in our own cloud? Yes. Bring Your Own Cloud deployments run WarpBuild runners inside your own AWS, GCP, or Azure account, with static IPs and custom machine images. See for details. Is WarpBuild ready for enterprise use? Yes. WarpBuild is SOC2 Type 2 attested with ephemeral, fully isolated VMs, SSO and access controls, and 24x7 support backed by SLAs. Teams at Braintrust, Raycast, OpenRouter, Comcast, Jane Street, and more run their CI on WarpBuild. ## Stop waiting on CI. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Claim $50 in Credits](https://app.warpbuild.com) --- # WarpBuild for TypeCraft Viewers | $50 in Free Credits | WarpBuild URL: https://www.warpbuild.com/typecraft Description: More AI code means more commits and more CI. Run GitHub Actions 2x faster at half the cost with a one-line change. Redeem code type50 for $50 in free credits. //For TypeCraft Viewers # More AI code. More commits. More CI. Run GitHub Actions 2x faster at half the cost with a one-line change. Linux, macOS, and Windows on x86-64 and ARM64, with unlimited macOS concurrency. $50 in free credits for TypeCraft Viewerstype50Redeem under [Settings > Billing](https://app.warpbuild.com/settings/billing) after signing up [Claim $50 in Credits](https://app.warpbuild.com)[Schedule Demo](https://cal.com/suryao/start) No credit card required | 2-minute setup //CI in the AI era ## AI agents write the code. CI is now the bottleneck. AI is writing more code than ever, which means GitHub Actions is running more than ever. WarpBuild keeps the feedback loop fast and the bill low. ### More AI code, more commits Agents open pull requests all day. Every one has to build, test, and merge through the same GitHub Actions pipeline. ### More commits, more CI Every commit runs a full pipeline. CI is now the bottleneck for you and for your coding agents, and the bill grows with it. ### Twice as fast at half the cost WarpBuild runners are priced at half of GitHub-hosted runners and finish jobs faster, so you save on both rate and runtime. //Getting started ## One line of YAML. $50 in credits. Three steps from this page to your first faster build. ``` - runs-on: ubuntu-latest+ runs-on: warp-ubuntu-latest-x64-2x ``` 1 ### Create your account Sign up at [app.warpbuild.com](https://app.warpbuild.com). No credit card required. 2 ### Redeem your credits Add promo code type50 under [Settings > Billing](https://app.warpbuild.com/settings/billing) for $50 in free credits. 3 ### Change one line of YAML Swap runs-on in your workflow file and your next build runs on WarpBuild. //WarpBuild CI ## The broadest OS and architecture support Drop-in GitHub Actions runner infrastructure: 2-10x faster builds, half the cost, faster caching, and BYOC for teams that want CI in their own cloud. [2x faster, 50% cheaperFaster CPUs, faster disks, and 3-10x faster caching than GitHub-hosted runners.](/compare/github)[One line of YAMLChange runs-on and you're done. No workflow rewrites, no migration project.](/docs/ci/quick-start)[Linux, macOS, and Windowsx86-64 and ARM64 runners across all major operating systems, including M4 Pro Macs.](/docs/ci/cloud-runners)[macOS with unlimited concurrencyRun as many Mac jobs at once as your agents create. No queuing behind your own jobs.](/products/ci/macos-runners)[Our cloud or yoursManaged runners in WarpBuild's cloud, or BYOC deployments in your own AWS, GCP, or Azure.](/docs/ci/byoc)[Enterprise readySOC2 Type 2 attested, ephemeral VMs, SSO, and 24x7 support with SLA guarantees.](/docs/ci/security) //Trusted at scale ## Trusted by teams at Braintrust, Raycast, OpenRouter, Comcast, Jane Street, and more Enterprise ready and SOC2 Type 2 attested, from solo open-source projects to thousand-developer organizations. ![Sonar](/images/customers/sonarqube.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Comcast](/images/customers/comcast.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Sky](/_next/image?url=%2Fimages%2Fcustomers%2Fsky-uk.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Braintrust](/images/customers/braintrust.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Adaptive](/_next/image?url=%2Fimages%2Fcustomers%2Fadaptive.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![OxCaml](/images/customers/oxcaml.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![DDEV](/images/customers/ddev.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Kintsugi](/images/customers/kintsugi-ai.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![Bitcoin](/images/customers/bitcoin.svg?dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) ![LanceDB](/_next/image?url=%2Fimages%2Fcustomers%2Flancedb.png&w=384&q=75&dpl=dpl_HiojxaeNE6DuCkxENiJPnStqh2T7) Run times went down from \~25 mins to \~9 mins. Even bazel cache load time was faster by 30% Wow. These macOS runners are a LOT faster than GitHub's. Previous builds would take 30 mins, now they're done in 8! Tried out pretty much every provider in the market, and this one works the best out of the box //FAQ ## Frequently asked questions How do I redeem the $50 in free credits? Sign up at then add promo code type50 on the billing settings page at The credits are applied to your account immediately. Do I need a credit card to get started? No. Sign up, redeem your credits, and run your first builds without entering a card. Will my existing GitHub Actions workflows work? Yes. WarpBuild runners are fully compatible with GitHub Actions and come with the standard tooling preinstalled. The only change is the runs-on label in your workflow file. Which operating systems and architectures are supported? Linux, macOS, and Windows, on x86-64 and ARM64\. macOS runners have unlimited concurrency, so Mac jobs do not queue behind each other. Can we run WarpBuild in our own cloud? Yes. Bring Your Own Cloud deployments run WarpBuild runners inside your own AWS, GCP, or Azure account, with static IPs and custom machine images. See for details. Is WarpBuild ready for enterprise use? Yes. WarpBuild is SOC2 Type 2 attested with ephemeral, fully isolated VMs, SSO and access controls, and 24x7 support backed by SLAs. Teams at Braintrust, Raycast, OpenRouter, Comcast, Jane Street, and more run their CI on WarpBuild. ## Stop waiting on CI. Go Warp. [Schedule a Call](https://cal.com/suryao/start)[Claim $50 in Credits](https://app.warpbuild.com) --- # WarpBuild Documentation Base URL: https://www.warpbuild.com/docs # October 2026 URL: https://www.warpbuild.com/docs/ci/changelog/2026-october Description: List of updates in 2026-October --- title: "October 2026" slug: "2026-October" description: "List of updates in 2026-October" sidebar_position: -34 createdAt: "2026-10-01" updatedAt: "2026-10-30" --- ### October 30, 2026 - `Security`: **GCP cloud connection template v2.1 limits the runner service account to creating tokens for itself instead of for any service account in your project.** Upgrade your GCP cloud connection from the dashboard to apply it. [Learn more](/docs/ci/byoc/gcp). ### October 29, 2026 - `Feature`: **macOS sandboxes are now available.** Create a macOS 26 VM from the command line with the new `wb` CLI, run commands and copy files in it, and pause it to keep its disk for later, for up to 7 days. [Learn more](/docs/ci/sandboxes). ### October 26, 2026 - `Feature`: **Audit logs record every change made in your WarpBuild organization.** Each event has who made the change, what changed, the outcome and the time, and is kept for 13 months in write-once storage in your organization's data region. Organization admins can filter the log and download it as CSV from **Organization → Security → Audit logs**. Turning it on adds $400/month to your subscription. [Learn more](/docs/ci/features/audit-logs). ### October 24, 2026 - `Security`: **Runner images include the Node.js security releases of October 21, [22.23.4](https://nodejs.org/en/blog/release/v22.23.4) and [24.22.1](https://nodejs.org/en/blog/release/v24.22.1).** These are the Node.js 22 and 24 versions in the tool cache, which `actions/setup-node` uses, on every image. - `Enhancement`: **macOS 15 image has been updated.** The image now carries the tooling of the [upstream GitHub macOS 15 image](https://github.com/actions/runner-images/releases/tag/macos-15-arm64%2F20260907.0337) as of September 7, 2026, and runs macOS 15.8.1 (24H32). It also has the eMudhra certificate changes from October 23. - `Enhancement`: **macOS 26 image has been updated** ([upstream image](https://github.com/actions/runner-images/releases/tag/macos-26-arm64%2F20261021.0445)). Xcode 27.1 is now released: it is at `/Applications/Xcode_27.1.app`, and `/Applications/Xcode_27.1_Beta.app` points at it. ### October 23, 2026 - `Security`: **Runner images no longer trust the compromised eMudhra certificates** ([advisory](https://emudhra.we-have-a-problem.com/)). - Ubuntu images have the fixed `ca-certificates` package from Ubuntu. The Temurin JDKs use the updated store. - macOS 26 images remove the roots AAA Certificate Services, emSign Root TLS CA - G3 and emSign ECC Root CA - G3 from `/etc/ssl/cert.pem` and from every preinstalled JDK. - Windows images add the eight intermediates to the `Disallowed` certificate store and remove the same roots from every preinstalled JDK. `CURL_CA_BUNDLE` points the curl in Git for Windows at a fixed bundle in `C:\warpbuilds\ca-trust\cacert.pem`. - Node.js in actions uses the fixed system store through `NODE_OPTIONS=--use-openssl-ca`. If your workflow sets `NODE_OPTIONS`, add `--use-openssl-ca` to it. Tools that your job installs itself, such as `setup-java` or a container, keep their own certificate stores. - `Security`: **Ubuntu images include Ubuntu's curl security update ([USN-8932-1](https://ubuntu.com/security/notices/USN-8932-1)).** git now drops a token that is alone in an HTTPS URL, such as `https://@github.com/`, so those fetches fail with `could not read Username`. `actions/checkout` is not affected. Put a username before the token, for example `https://x-access-token:@github.com/`. ### October 19, 2026 - `Security`: **Linux runners on WarpBuild cloud are protected against PwnEscape (CVE-2026-99999).** Our hosts now block the network data that triggers the host kernel bug, so a VM cannot read host memory. You do not need to do anything. ### October 13, 2026 - `Enhancement`: **Xcode 27.1 RC and the iPhone Duo simulator on the macOS 26 runners.** `warp-macos-26-arm64-6x` and `warp-macos-26-arm64-12x` now ship the Xcode 27.1 release candidate. [Learn more](/docs/ci/cloud-runners#macos-26-tooling). - Xcode 27.1 RC (build `27A9275`) is at `/Applications/Xcode_27.1_Beta.app`, with its Metal toolchain and the iOS 27.1 simulator runtime (24A94232), which includes the iPhone Duo simulator. - Only the iOS 27.1 runtime is added; there are no tvOS, watchOS or visionOS 27.1 runtimes. - Xcode 27.0 (`27A266a`) stays at `/Applications/Xcode_27.0.app`, `XCODE_27_DEVELOPER_DIR` still points at it, and the default Xcode is unchanged at 26.6. - If you select Xcode with `maxim-lobanov/setup-xcode` and `latest-stable`, it now picks the Xcode 27.1 RC, because Apple marks the release candidate as a GM build; set `xcode-version: '27.0'` to stay on Xcode 27.0. - When Xcode 27.1 is released, it will move to `/Applications/Xcode_27.1.app` and `/Applications/Xcode_27.1_Beta.app` will point at it, as with Xcode 27.0. - `Enhancement`: **macOS 26 image has been updated.** The image now carries the tooling of the upstream GitHub macOS 26 image as of October 11, 2026. OpenSSL 4 is installed alongside OpenSSL 3 at `$(brew --prefix openssl@4)/bin/openssl`, and the default `openssl` is still OpenSSL 3, as on the upstream image. The macOS version is unchanged at 26.6.2 (25G83). ### October 7, 2026 - `Enhancement`: [Ubuntu 26.04 Arm64 image](https://github.com/actions/runner-images/releases/tag/ubuntu26-arm64%2F20260927.135) has been updated. - `Enhancement`: [Ubuntu 26.04 image](https://github.com/actions/runner-images/releases/tag/ubuntu26%2F20260927.149) has been updated. - `Enhancement`: [Ubuntu 24.04 Arm64 image](https://github.com/actions/runner-images/releases/tag/ubuntu24-arm64%2F20260927.135) has been updated. - `Enhancement`: [Ubuntu 24.04 image](https://github.com/actions/runner-images/releases/tag/ubuntu24%2F20260927.320) has been updated. - `Enhancement`: [Ubuntu 22.04 image](https://github.com/actions/runner-images/releases/tag/ubuntu22%2F20260927.309) has been updated. - `Bugfix`: Deleting many files on Ubuntu runners on GCP and Azure, such as Docker cleanup at the end of a job, is fast again. The images now mount the root disk without the `discard` option. ### October 4, 2026 - `Enhancement`: **Axiom is now a built-in export provider.** Choose **Axiom** under [Observability settings](https://app.warpbuild.com/ci/observability/settings), then enter an Axiom API token and a dataset for metrics and one for logs. [Learn more](/docs/ci/features/observability#exporting-to-your-own-backend). - `Enhancement`: **Exported telemetry now identifies the workflow, run and job.** Metrics and logs exported from Linux runners carry `cicd.pipeline.name`, `cicd.pipeline.run.id`, `cicd.pipeline.task.name` and `cicd.pipeline.task.run.id`, and `service.instance.id` identifies the runner. The job fields are labels on every metric data point, so backends that index labels, such as Grafana Cloud, can filter and group by them. [Learn more](/docs/ci/features/observability#exporting-to-your-own-backend). --- # macOS Sandboxes URL: https://www.warpbuild.com/docs/ci/sandboxes Description: Create resumable macOS 26 sandboxes with the wb CLI --- title: "macOS Sandboxes" excerpt: "macOS VMs you can create, use and pause from the command line" description: "Create resumable macOS 26 sandboxes with the wb CLI" icon: Laptop createdAt: "2026-10-27" updatedAt: "2026-10-27" --- A macOS sandbox is a macOS 26 virtual machine you can manage from the command line with the `wb` CLI. You can run commands in it, copy files in and out, and open an interactive shell. Pausing a sandbox saves its disk and stops it. Resuming starts a new VM from that disk, so your files and the tools you installed are still there. Sandboxes get the same full macOS 26 image as the [macOS 26 runners](/docs/ci/cloud-runners#macos-26-tooling), with Xcode and the same suite of preinstalled tools. ## Quick start 1. Install the `wb` CLI on macOS or Linux: ```bash curl -fsSL https://packages.warpbuild.com/wb-cli/install.sh | bash ``` 2. Create an [API key](/docs/ci/api-keys) with the **CI** scope and export it: ```bash export WB_TOKEN=wkey-... ``` 3. Create a sandbox. `wb` waits until it is ready: ```bash wb sbx create demo ``` A name must be unique among your organization's live sandboxes. 4. Run commands, copy files in and out, or open a shell: ```bash wb sbx exec demo sw_vers wb sbx fs put demo ./app.zip app.zip wb sbx exec demo unzip app.zip wb sbx fs cat demo build.log > build.log wb sbx pty create demo ``` 5. Pause the sandbox when you're done and resume it later. Kill it when you no longer need it: ```bash wb sbx pause demo wb sbx resume demo wb sbx kill demo --yes ``` ## Runner labels and pricing | Runner label | vCPU | Memory | Disk | Price | | ---------------------------------- | ---- | ------ | ----- | ------------ | | `warp-macos-26-arm64-6x` (default) | 6 | 14 GB | 60 GB | $0.08/minute | | `warp-macos-26-arm64-12x` | 12 | 28 GB | 60 GB | $0.16/minute | Sandboxes run on the macOS 26 labels of the [macOS M4 Pro runners](/docs/ci/cloud-runners#macos-m4-pro-on-arm64). Pass one with `--runner-label` when you create the sandbox, for example `wb sbx create demo --runner-label warp-macos-26-arm64-12x`. A sandbox keeps its runner label when you resume it. A running sandbox is billed per second at the macOS 26 runner rate. A paused sandbox has no compute charge. ### Saved disks Each pause compresses the sandbox's disk and save it to WarpBuild cache, billed at [cache pricing](/docs/ci/features/caching#pricing): $0.20 per GB-month of storage, plus $0.0001 per cache operation to save and restore it. For example, a 5 GB save kept for a week costs about $0.23. ## What persists When you resume a sandbox: - **Kept**: - home folder (`/Users/runner`) - Homebrew under `/opt/homebrew`, including packages you installed with `brew`. - **Not kept**: - `~/Library`, `~/.warpbuild`, `/tmp`, `/usr/local` and `/Applications` - running processes, memory is not persisted ## Lifetime A sandbox lasts 7 days from when you created it, running or paused. After that it is expired and can't be resumed. Check `wb sbx info ` to see when the sandbox expires. ## CLI reference Every command accepts `-f json` or `-f pretty` for the output format, and `-v` to print API calls to stderr. Output goes to stdout and messages to stderr, so `wb sbx list -f json | jq` works. Before use, you need to export your API token as `WB_TOKEN` env variable. `wb version` prints the CLI's version. `wb` checks for a new release once a day and tells you when one is available; run the install command again to update. | Command | What it does | | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `wb sbx create [--runner-label